mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-22 03:37:36 +08:00
Compare commits
11 Commits
feat/add-q
...
release/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50923f6a2e | ||
|
|
bdfd8dc0d0 | ||
|
|
0275e23b7e | ||
|
|
13fda47151 | ||
|
|
d664dcca3d | ||
|
|
04141642a5 | ||
|
|
7f7c4ea2a7 | ||
|
|
e07dc0de86 | ||
|
|
cc726adb57 | ||
|
|
7df8197542 | ||
|
|
60194306e1 |
13
.github/workflows/docker-build.yml
vendored
13
.github/workflows/docker-build.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- release/stable
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
@@ -152,12 +153,14 @@ jobs:
|
||||
env:
|
||||
IMAGE_BASE: ${{ needs.prepare.outputs.image_base }}
|
||||
run: |
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
# Convert branch name: release/stable -> release-stable (matching Docker metadata action)
|
||||
REF_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
|
||||
GHCR_IMAGE="${{ env.REGISTRY_GHCR }}/${IMAGE_BASE}/nofx-${{ matrix.image_suffix }}"
|
||||
|
||||
echo "📦 Creating manifest for ${{ matrix.image_suffix }}"
|
||||
echo "Repository: ${IMAGE_BASE}"
|
||||
echo "Image: ${GHCR_IMAGE}"
|
||||
echo "Ref name: ${REF_NAME}"
|
||||
|
||||
docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||
@@ -171,6 +174,14 @@ jobs:
|
||||
echo "✅ Created latest tag (main branch only)"
|
||||
fi
|
||||
|
||||
# release/stable branch gets the 'stable' tag
|
||||
if [[ "${{ github.ref }}" == "refs/heads/release/stable" ]]; then
|
||||
docker buildx imagetools create -t "${GHCR_IMAGE}:stable" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-arm64"
|
||||
echo "✅ Created stable tag (release/stable branch)"
|
||||
fi
|
||||
|
||||
if [[ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]]; then
|
||||
DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}"
|
||||
docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:${REF_NAME}" \
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"nofx/backtest"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
|
||||
var req backtestStartRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,23 +78,23 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
if cfg.StrategyID != "" {
|
||||
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load strategy: %v", err)})
|
||||
SafeBadRequest(c, "Failed to load strategy")
|
||||
return
|
||||
}
|
||||
if strategy == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("strategy not found: %s", cfg.StrategyID)})
|
||||
SafeBadRequest(c, "Strategy not found")
|
||||
return
|
||||
}
|
||||
var strategyConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to parse strategy config: %v", err)})
|
||||
SafeBadRequest(c, "Failed to parse strategy config")
|
||||
return
|
||||
}
|
||||
cfg.SetLoadedStrategy(&strategyConfig)
|
||||
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
||||
logger.Infof("📊 Strategy coin source: type=%s, use_coin_pool=%v, use_oi_top=%v, static_coins=%v",
|
||||
logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v",
|
||||
strategyConfig.CoinSource.SourceType,
|
||||
strategyConfig.CoinSource.UseCoinPool,
|
||||
strategyConfig.CoinSource.UseAI500,
|
||||
strategyConfig.CoinSource.UseOITop,
|
||||
strategyConfig.CoinSource.StaticCoins)
|
||||
|
||||
@@ -102,7 +102,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
if len(cfg.Symbols) == 0 {
|
||||
symbols, err := s.resolveStrategyCoins(&strategyConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to resolve coins from strategy: %v", err)})
|
||||
SafeBadRequest(c, "Failed to resolve coins from strategy")
|
||||
return
|
||||
}
|
||||
cfg.Symbols = symbols
|
||||
@@ -111,7 +111,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Failed to configure AI model")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
|
||||
runner, err := s.backtestManager.Start(context.Background(), cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,11 +149,11 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
||||
|
||||
var req runIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if req.RunID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
||||
}
|
||||
|
||||
if err := fn(req.RunID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to execute backtest operation", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ func (s *Server) handleBacktestLabel(c *gin.Context) {
|
||||
}
|
||||
var req labelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RunID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
@@ -194,7 +194,7 @@ func (s *Server) handleBacktestLabel(c *gin.Context) {
|
||||
}
|
||||
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Update backtest label", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, meta)
|
||||
@@ -207,11 +207,11 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
|
||||
}
|
||||
var req runIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RunID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
@@ -219,7 +219,7 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := s.backtestManager.Delete(req.RunID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Delete backtest run", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
@@ -277,7 +277,7 @@ func (s *Server) handleBacktestRuns(c *gin.Context) {
|
||||
|
||||
metas, err := s.backtestManager.ListRuns()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "List backtest runs", err)
|
||||
return
|
||||
}
|
||||
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
|
||||
@@ -349,7 +349,7 @@ func (s *Server) handleBacktestEquity(c *gin.Context) {
|
||||
|
||||
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, points)
|
||||
@@ -375,7 +375,7 @@ func (s *Server) handleBacktestTrades(c *gin.Context) {
|
||||
|
||||
events, err := s.backtestManager.LoadTrades(runID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
@@ -404,7 +404,7 @@ func (s *Server) handleBacktestMetrics(c *gin.Context) {
|
||||
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
@@ -427,7 +427,7 @@ func (s *Server) handleBacktestTrace(c *gin.Context) {
|
||||
cycle := queryInt(c, "cycle", 0)
|
||||
record, err := s.backtestManager.GetTrace(runID, cycle)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trace record")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, record)
|
||||
@@ -461,7 +461,7 @@ func (s *Server) handleBacktestDecisions(c *gin.Context) {
|
||||
|
||||
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Load decision records", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
@@ -483,7 +483,7 @@ func (s *Server) handleBacktestExport(c *gin.Context) {
|
||||
}
|
||||
path, err := s.backtestManager.ExportRun(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
|
||||
return
|
||||
}
|
||||
defer os.Remove(path)
|
||||
@@ -536,8 +536,7 @@ func (s *Server) handleBacktestKlines(c *gin.Context) {
|
||||
|
||||
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to fetch klines for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to fetch klines: %v", err)})
|
||||
SafeInternalError(c, "Fetch klines", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -620,11 +619,11 @@ func writeBacktestAccessError(c *gin.Context, err error) bool {
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errBacktestForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "No permission to access this backtest task"})
|
||||
SafeForbidden(c, "No permission to access this backtest task")
|
||||
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backtest task does not exist"})
|
||||
SafeNotFound(c, "Backtest task")
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Access backtest", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -639,21 +638,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
var symbols []string
|
||||
symbolSet := make(map[string]bool)
|
||||
|
||||
// Set custom API URLs if provided
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
}
|
||||
|
||||
// Handle empty source_type - check flags for backward compatibility
|
||||
sourceType := coinSource.SourceType
|
||||
if sourceType == "" {
|
||||
if coinSource.UseCoinPool && coinSource.UseOITop {
|
||||
if coinSource.UseAI500 && coinSource.UseOITop {
|
||||
sourceType = "mixed"
|
||||
} else if coinSource.UseCoinPool {
|
||||
sourceType = "coinpool"
|
||||
} else if coinSource.UseAI500 {
|
||||
sourceType = "ai500"
|
||||
} else if coinSource.UseOITop {
|
||||
sourceType = "oi_top"
|
||||
} else if len(coinSource.StaticCoins) > 0 {
|
||||
@@ -674,13 +665,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
}
|
||||
}
|
||||
|
||||
case "coinpool":
|
||||
limit := coinSource.CoinPoolLimit
|
||||
case "ai500":
|
||||
limit := coinSource.AI500Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
||||
coins, err := provider.GetTopRatedCoins(limit)
|
||||
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
|
||||
}
|
||||
@@ -694,7 +685,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
}
|
||||
|
||||
case "oi_top":
|
||||
coins, err := provider.GetOITopSymbols()
|
||||
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
|
||||
}
|
||||
@@ -714,13 +705,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
}
|
||||
|
||||
case "mixed":
|
||||
// Get from coin pool
|
||||
if coinSource.UseCoinPool {
|
||||
limit := coinSource.CoinPoolLimit
|
||||
// Get from AI500
|
||||
if coinSource.UseAI500 {
|
||||
limit := coinSource.AI500Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
coins, err := provider.GetTopRatedCoins(limit)
|
||||
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to get AI500 coins: %v", err)
|
||||
} else {
|
||||
@@ -736,7 +727,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
|
||||
// Get from OI Top
|
||||
if coinSource.UseOITop {
|
||||
coins, err := provider.GetOITopSymbols()
|
||||
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to get OI Top coins: %v", err)
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"nofx/debate"
|
||||
"nofx/logger"
|
||||
"nofx/provider"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -131,7 +131,7 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||
|
||||
var req CreateDebateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,35 +158,27 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||
if len(coinSource.StaticCoins) > 0 {
|
||||
req.Symbol = coinSource.StaticCoins[0]
|
||||
}
|
||||
case "coinpool":
|
||||
// Fetch from coin pool API
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
case "ai500":
|
||||
// Fetch from AI500 API
|
||||
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from pool API: %s", req.Symbol)
|
||||
logger.Infof("Fetched coin from AI500 API: %s", req.Symbol)
|
||||
}
|
||||
case "oi_top":
|
||||
// Fetch from OI top API
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
}
|
||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
||||
}
|
||||
case "mixed":
|
||||
// Try coin pool first, then OI top
|
||||
if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
// Try AI500 first, then OI top
|
||||
if coinSource.UseAI500 {
|
||||
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from pool API (mixed): %s", req.Symbol)
|
||||
logger.Infof("Fetched coin from AI500 API (mixed): %s", req.Symbol)
|
||||
}
|
||||
} else if coinSource.UseOITop && coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
} else if coinSource.UseOITop {
|
||||
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
||||
}
|
||||
@@ -292,7 +284,7 @@ func (h *DebateHandler) HandleStartDebate(c *gin.Context) {
|
||||
|
||||
// Start debate asynchronously
|
||||
if err := h.engine.StartDebate(debateID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Start debate", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,7 +308,7 @@ func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.engine.CancelDebate(debateID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Cancel debate", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -495,20 +487,20 @@ func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
|
||||
// Parse request
|
||||
var req ExecuteDebateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get trader executor
|
||||
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("trader not available: %v", err)})
|
||||
SafeError(c, http.StatusBadRequest, "Trader not available", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute consensus
|
||||
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Execute consensus", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -635,7 +627,9 @@ func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.Deb
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastError(sessionID string, err error) {
|
||||
// Sanitize error message before broadcasting to client
|
||||
safeMsg := SanitizeError(err, "An error occurred during debate")
|
||||
h.broadcast(sessionID, "error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"error": safeMsg,
|
||||
})
|
||||
}
|
||||
|
||||
95
api/errors.go
Normal file
95
api/errors.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// SafeError returns a safe error message without exposing internal details
|
||||
// It logs the actual error for debugging but returns a generic message to the client
|
||||
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
|
||||
// Log the actual error internally
|
||||
if internalErr != nil {
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{"error": publicMsg})
|
||||
}
|
||||
|
||||
// SafeInternalError logs internal error and returns a generic message
|
||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
|
||||
}
|
||||
|
||||
// SafeBadRequest returns a safe bad request error
|
||||
// For validation errors, we can be more specific since they're about user input
|
||||
func SafeBadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// SafeNotFound returns a generic not found error
|
||||
func SafeNotFound(c *gin.Context, resource string) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
|
||||
}
|
||||
|
||||
// SafeUnauthorized returns unauthorized error
|
||||
func SafeUnauthorized(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
// SafeForbidden returns forbidden error
|
||||
func SafeForbidden(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// IsSensitiveError checks if an error message contains sensitive information
|
||||
func IsSensitiveError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
|
||||
sensitivePatterns := []string{
|
||||
// Database
|
||||
"postgres", "mysql", "sqlite", "database", "sql",
|
||||
"connection", "connect", "failed to connect",
|
||||
// Network
|
||||
"dial", "tcp", "udp", "socket", "timeout",
|
||||
// Server info
|
||||
"127.0.0.1", "localhost", "0.0.0.0",
|
||||
// File system
|
||||
"no such file", "permission denied", "open /",
|
||||
// Credentials
|
||||
"password", "user=", "host=", "port=",
|
||||
// Internal
|
||||
"panic", "runtime error", "stack trace",
|
||||
}
|
||||
|
||||
for _, pattern := range sensitivePatterns {
|
||||
if strings.Contains(errMsg, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for IP addresses (simple pattern)
|
||||
if strings.Contains(errMsg, ":") && (strings.Contains(errMsg, ".") || strings.Contains(errMsg, "::")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SanitizeError returns the error message if safe, otherwise returns a generic message
|
||||
func SanitizeError(err error, fallbackMsg string) string {
|
||||
if err == nil {
|
||||
return fallbackMsg
|
||||
}
|
||||
if IsSensitiveError(err) {
|
||||
return fallbackMsg
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
224
api/server.go
224
api/server.go
@@ -406,7 +406,7 @@ type CreateTraderRequest struct {
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
UseCoinPool: req.UseCoinPool,
|
||||
UseAI500: req.UseAI500,
|
||||
UseOITop: req.UseOITop,
|
||||
CustomPrompt: req.CustomPrompt,
|
||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||
@@ -682,7 +682,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create trader: %v", err)})
|
||||
SafeInternalError(c, "Failed to create trader", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
||||
@@ -732,7 +732,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
|
||||
var req UpdateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -779,11 +779,13 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
|
||||
// Set scan interval, allow updates
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3
|
||||
}
|
||||
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
|
||||
|
||||
// Set system prompt template
|
||||
systemPromptTemplate := req.SystemPromptTemplate
|
||||
@@ -818,16 +820,26 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
IsRunning: existingTrader.IsRunning, // Keep original value
|
||||
}
|
||||
|
||||
// Check if trader was running before update (we'll restart it after)
|
||||
wasRunning := false
|
||||
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
|
||||
status := existingMemTrader.GetStatus()
|
||||
if running, ok := status["is_running"].(bool); ok && running {
|
||||
wasRunning = true
|
||||
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, req.StrategyID=%s",
|
||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, req.StrategyID)
|
||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
|
||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
|
||||
err = s.store.Trader().Update(traderRecord)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update trader: %v", err)})
|
||||
SafeInternalError(c, "Failed to update trader", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove old trader from memory first to ensure fresh config is loaded
|
||||
// Remove old trader from memory first (this also stops if running)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
// Reload traders into memory with fresh config
|
||||
@@ -836,6 +848,18 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
}
|
||||
|
||||
// If trader was running before, restart it with new config
|
||||
if wasRunning {
|
||||
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
|
||||
go func() {
|
||||
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
|
||||
if runErr := reloadedTrader.Run(); runErr != nil {
|
||||
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -854,7 +878,7 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
|
||||
// Delete from database
|
||||
err := s.store.Trader().Delete(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete trader: %v", err)})
|
||||
SafeInternalError(c, "Failed to delete trader", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1012,14 +1036,14 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update custom prompt: %v", err)})
|
||||
SafeInternalError(c, "Failed to update custom prompt", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1044,14 +1068,14 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update competition visibility: %v", err)})
|
||||
SafeInternalError(c, "Update competition visibility", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1150,7 +1174,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to exchange: %v", createErr)})
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1158,7 +1182,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to query balance: %v", balanceErr)})
|
||||
SafeInternalError(c, "Failed to query balance", balanceErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1302,7 +1326,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to exchange: %v", createErr)})
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1344,7 +1368,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
if closeErr != nil {
|
||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close position: %v", closeErr)})
|
||||
SafeInternalError(c, "Failed to close position", closeErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1582,7 +1606,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get AI model configs: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get AI model configs: %v", err)})
|
||||
SafeInternalError(c, "Failed to get AI model configs", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1684,7 +1708,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
for modelID, modelData := range req.Models {
|
||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update model %s: %v", modelID, err)})
|
||||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1706,8 +1730,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get exchange configs: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get exchange configs: %v", err)})
|
||||
SafeInternalError(c, "Failed to get exchange configs", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1805,7 +1828,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update exchange %s: %v", exchangeID, err)})
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1910,7 +1933,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create exchange account: %v", err)})
|
||||
SafeInternalError(c, "Failed to create exchange account", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1953,7 +1976,7 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
|
||||
err = s.store.Exchange().Delete(userID, exchangeID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to delete exchange account: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete exchange account: %v", err)})
|
||||
SafeInternalError(c, "Failed to delete exchange account", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1966,7 +1989,7 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get trader list: %v", err)})
|
||||
SafeInternalError(c, "Failed to get trader list", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2019,7 +2042,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Failed to get trader config: %v", err)})
|
||||
SafeNotFound(c, "Trader config")
|
||||
return
|
||||
}
|
||||
traderConfig := fullCfg.Trader
|
||||
@@ -2050,7 +2073,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
"custom_prompt": traderConfig.CustomPrompt,
|
||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||
"use_coin_pool": traderConfig.UseCoinPool,
|
||||
"use_ai500": traderConfig.UseAI500,
|
||||
"use_oi_top": traderConfig.UseOITop,
|
||||
"is_running": isRunning,
|
||||
}
|
||||
@@ -2062,13 +2085,13 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
func (s *Server) handleStatus(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2080,23 +2103,20 @@ func (s *Server) handleStatus(c *gin.Context) {
|
||||
func (s *Server) handleAccount(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
||||
account, err := trader.GetAccountInfo()
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get account info [%s]: %v", trader.GetName(), err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get account info: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get account info", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2113,21 +2133,19 @@ func (s *Server) handleAccount(c *gin.Context) {
|
||||
func (s *Server) handlePositions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get position list: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get positions", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2138,13 +2156,13 @@ func (s *Server) handlePositions(c *gin.Context) {
|
||||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2165,9 +2183,7 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get position history: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get position history", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2192,13 +2208,13 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
func (s *Server) handleTrades(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2224,9 +2240,7 @@ func (s *Server) handleTrades(c *gin.Context) {
|
||||
|
||||
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get trades: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get trades", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2249,13 +2263,13 @@ func (s *Server) handleTrades(c *gin.Context) {
|
||||
func (s *Server) handleOrders(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2283,9 +2297,7 @@ func (s *Server) handleOrders(c *gin.Context) {
|
||||
// Get all orders for this trader
|
||||
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get orders: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2317,13 +2329,13 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2336,9 +2348,7 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
// Get fills for this order
|
||||
fills, err := store.Order().GetOrderFills(orderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get order fills: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get order fills", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2376,30 +2386,21 @@ func (s *Server) handleKlines(c *gin.Context) {
|
||||
// US Stocks via Alpaca
|
||||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ Alpaca API failed for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from Alpaca: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from Alpaca", err)
|
||||
return
|
||||
}
|
||||
case "forex", "metals":
|
||||
// Forex and Metals via Twelve Data
|
||||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ TwelveData API failed for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from TwelveData: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from TwelveData", err)
|
||||
return
|
||||
}
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
||||
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ Hyperliquid API failed for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from Hyperliquid: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -2407,10 +2408,7 @@ func (s *Server) handleKlines(c *gin.Context) {
|
||||
symbol = market.Normalize(symbol)
|
||||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from CoinAnk", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2728,22 +2726,20 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
||||
func (s *Server) handleDecisions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get all historical decision records (unlimited)
|
||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get decision log", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2754,13 +2750,13 @@ func (s *Server) handleDecisions(c *gin.Context) {
|
||||
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2777,9 +2773,7 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
|
||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get decision log", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2796,21 +2790,19 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
func (s *Server) handleStatistics(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get statistics: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get statistics", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2829,9 +2821,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
||||
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get competition data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2843,7 +2833,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2851,9 +2841,7 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
||||
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get historical data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get historical data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2931,7 +2919,8 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||
// Validate JWT token
|
||||
claims, err := auth.ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token: " + err.Error()})
|
||||
logger.Errorf("[Auth] Invalid token: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -2999,7 +2988,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3036,7 +3025,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
|
||||
err = s.store.User().Create(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3059,14 +3048,14 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByID(req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
||||
SafeNotFound(c, "User")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3112,7 +3101,7 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3156,14 +3145,14 @@ func (s *Server) handleVerifyOTP(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByID(req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
||||
SafeNotFound(c, "User")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3197,7 +3186,7 @@ func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3326,9 +3315,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
// Get trader information from all users
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get trader list: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get trader list", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3371,9 +3358,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get competition data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3384,9 +3369,7 @@ func (s *Server) handlePublicCompetition(c *gin.Context) {
|
||||
func (s *Server) handleTopTraders(c *gin.Context) {
|
||||
topTraders, err := s.traderManager.GetTopTradersData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get top 10 trader data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get top traders data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3409,9 +3392,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
||||
// If no trader_ids specified, return historical data for top 5
|
||||
topTraders, err := s.traderManager.GetTopTradersData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get top 5 traders: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get top traders", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3506,7 +3487,8 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
||||
}
|
||||
if err != nil {
|
||||
errors[traderID] = fmt.Sprintf("Failed to get historical data: %v", err)
|
||||
logger.Errorf("[API] Failed to get equity history for %s: %v", traderID, err)
|
||||
errors[traderID] = "Failed to get historical data"
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
var warnings []string
|
||||
|
||||
// Validate quant data URL if enabled
|
||||
if config.Indicators.EnableQuantData && config.Indicators.QuantDataAPIURL != "" {
|
||||
if !strings.Contains(config.Indicators.QuantDataAPIURL, "{symbol}") {
|
||||
warnings = append(warnings, "Quant data URL does not contain {symbol} placeholder. The same data will be used for all coins, which may not be correct.")
|
||||
}
|
||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||
config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) &&
|
||||
config.Indicators.NofxOSAPIKey == "" {
|
||||
warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.")
|
||||
}
|
||||
|
||||
return warnings
|
||||
@@ -33,7 +33,7 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
strategies, err := s.store.Strategy().ListPublic()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get public strategies: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to get public strategies", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
|
||||
strategies, err := s.store.Strategy().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get strategy list: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to get strategy list", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,14 +151,14 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"})
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to create strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -221,14 +221,14 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"})
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Update(strategy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to update strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to delete strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ func (s *Server) handleActivateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().SetActive(userID, strategyID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to activate strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to activate strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -309,13 +309,13 @@ func (s *Server) handleDuplicateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
newID := uuid.New().String()
|
||||
if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to duplicate strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to duplicate strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -396,7 +396,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Create strategy engine to build prompt
|
||||
engine := decision.NewStrategyEngine(&req.Config)
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
|
||||
// Build system prompt (using built-in method from strategy engine)
|
||||
systemPrompt := engine.BuildSystemPrompt(
|
||||
@@ -433,7 +433,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,13 +442,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Create strategy engine to build prompt
|
||||
engine := decision.NewStrategyEngine(&req.Config)
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
|
||||
// Get candidate coins
|
||||
candidates, err := engine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
logger.Errorf("[API Error] Failed to get candidate coins: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get candidate coins: " + err.Error(),
|
||||
"error": "Failed to get candidate coins",
|
||||
"ai_response": "",
|
||||
})
|
||||
return
|
||||
@@ -502,12 +503,18 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := engine.FetchOIRankingData()
|
||||
|
||||
// Fetch NetFlow ranking data (market-wide fund flow)
|
||||
netFlowRankingData := engine.FetchNetFlowRankingData()
|
||||
|
||||
// Fetch Price ranking data (market-wide gainers/losers)
|
||||
priceRankingData := engine.FetchPriceRankingData()
|
||||
|
||||
// Build real context (for generating User Prompt)
|
||||
testContext := &decision.Context{
|
||||
testContext := &kernel.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: 0,
|
||||
CallCount: 1,
|
||||
Account: decision.AccountInfo{
|
||||
Account: kernel.AccountInfo{
|
||||
TotalEquity: 1000.0,
|
||||
AvailableBalance: 1000.0,
|
||||
UnrealizedPnL: 0,
|
||||
@@ -517,12 +524,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
MarginUsedPct: 0,
|
||||
PositionCount: 0,
|
||||
},
|
||||
Positions: []decision.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
Positions: []kernel.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
PriceRankingData: priceRankingData,
|
||||
}
|
||||
|
||||
// Build System Prompt
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ type cachedDecision struct {
|
||||
Key string `json:"key"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Decision *decision.FullDecision `json:"decision"`
|
||||
Decision *kernel.FullDecision `json:"decision"`
|
||||
}
|
||||
|
||||
// AICache persists AI decisions for repeated backtesting or replay.
|
||||
@@ -67,7 +67,7 @@ func (c *AICache) Path() string {
|
||||
return c.path
|
||||
}
|
||||
|
||||
func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
|
||||
func (c *AICache) Get(key string) (*kernel.FullDecision, bool) {
|
||||
if c == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
|
||||
return cloneDecision(entry.Decision), true
|
||||
}
|
||||
|
||||
func (c *AICache) Put(key string, variant string, ts int64, decision *decision.FullDecision) error {
|
||||
func (c *AICache) Put(key string, variant string, ts int64, decision *kernel.FullDecision) error {
|
||||
if c == nil || key == "" || decision == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (c *AICache) save() error {
|
||||
return writeFileAtomic(c.path, data, 0o644)
|
||||
}
|
||||
|
||||
func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
||||
func cloneDecision(src *kernel.FullDecision) *kernel.FullDecision {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -117,14 +117,14 @@ func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var dst decision.FullDecision
|
||||
var dst kernel.FullDecision
|
||||
if err := json.Unmarshal(data, &dst); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &dst
|
||||
}
|
||||
|
||||
func computeCacheKey(ctx *decision.Context, variant string, ts int64) (string, error) {
|
||||
func computeCacheKey(ctx *kernel.Context, variant string, ts int64) (string, error) {
|
||||
if ctx == nil {
|
||||
return "", fmt.Errorf("context is nil")
|
||||
}
|
||||
@@ -132,9 +132,9 @@ func computeCacheKey(ctx *decision.Context, variant string, ts int64) (string, e
|
||||
Variant string `json:"variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
Account decision.AccountInfo `json:"account"`
|
||||
Positions []decision.PositionInfo `json:"positions"`
|
||||
CandidateCoins []decision.CandidateCoin `json:"candidate_coins"`
|
||||
Account kernel.AccountInfo `json:"account"`
|
||||
Positions []kernel.PositionInfo `json:"positions"`
|
||||
CandidateCoins []kernel.CandidateCoin `json:"candidate_coins"`
|
||||
MarketData map[string]market.Data `json:"market"`
|
||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||
Runtime int `json:"runtime_minutes"`
|
||||
|
||||
@@ -199,7 +199,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||
if len(cfg.Symbols) > 0 {
|
||||
result.CoinSource.SourceType = "static"
|
||||
result.CoinSource.StaticCoins = cfg.Symbols
|
||||
result.CoinSource.UseCoinPool = false
|
||||
result.CoinSource.UseAI500 = false
|
||||
result.CoinSource.UseOITop = false
|
||||
}
|
||||
|
||||
@@ -241,12 +241,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||
|
||||
return &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "static",
|
||||
StaticCoins: cfg.Symbols,
|
||||
UseCoinPool: false,
|
||||
CoinPoolLimit: len(cfg.Symbols),
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
SourceType: "static",
|
||||
StaticCoins: cfg.Symbols,
|
||||
UseAI500: false,
|
||||
AI500Limit: len(cfg.Symbols),
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
},
|
||||
Indicators: store.IndicatorConfig{
|
||||
Klines: store.KlineConfig{
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
@@ -34,7 +34,7 @@ type Runner struct {
|
||||
cfg BacktestConfig
|
||||
feed *DataFeed
|
||||
account *BacktestAccount
|
||||
strategyEngine *decision.StrategyEngine
|
||||
strategyEngine *kernel.StrategyEngine
|
||||
|
||||
decisionLogDir string
|
||||
mcpClient mcp.AIClient
|
||||
@@ -118,7 +118,7 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) {
|
||||
|
||||
// Create strategy engine from backtest config for unified prompt generation
|
||||
strategyConfig := cfg.ToStrategyConfig()
|
||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
||||
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
|
||||
|
||||
r := &Runner{
|
||||
cfg: cfg,
|
||||
@@ -305,7 +305,7 @@ func (r *Runner) stepOnce() error {
|
||||
record = rec
|
||||
|
||||
var (
|
||||
fullDecision *decision.FullDecision
|
||||
fullDecision *kernel.FullDecision
|
||||
fromCache bool
|
||||
cacheKey string
|
||||
)
|
||||
@@ -470,7 +470,7 @@ func (r *Runner) stepOnce() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*decision.Context, *store.DecisionRecord, error) {
|
||||
func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*kernel.Context, *store.DecisionRecord, error) {
|
||||
equity, unrealized, _ := r.account.TotalEquity(priceMap)
|
||||
available := r.account.Cash()
|
||||
marginUsed := r.totalMarginUsed()
|
||||
@@ -479,7 +479,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
marginPct = (marginUsed / equity) * 100
|
||||
}
|
||||
|
||||
accountInfo := decision.AccountInfo{
|
||||
accountInfo := kernel.AccountInfo{
|
||||
TotalEquity: equity,
|
||||
AvailableBalance: available,
|
||||
TotalPnL: equity - r.account.InitialBalance(),
|
||||
@@ -495,14 +495,14 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
candidateCoins, err := r.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
// Fallback to simple list if strategy engine fails
|
||||
candidateCoins = make([]decision.CandidateCoin, 0, len(r.cfg.Symbols))
|
||||
candidateCoins = make([]kernel.CandidateCoin, 0, len(r.cfg.Symbols))
|
||||
for _, sym := range r.cfg.Symbols {
|
||||
candidateCoins = append(candidateCoins, decision.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
|
||||
candidateCoins = append(candidateCoins, kernel.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
|
||||
}
|
||||
}
|
||||
|
||||
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
|
||||
ctx := &decision.Context{
|
||||
ctx := &kernel.Context{
|
||||
CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: runtime,
|
||||
CallCount: callCount,
|
||||
@@ -519,7 +519,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
|
||||
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
|
||||
strategyConfig := r.strategyEngine.GetConfig()
|
||||
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
|
||||
if strategyConfig.Indicators.EnableQuantData {
|
||||
// Collect symbols to query (candidate coins + position coins)
|
||||
symbolSet := make(map[string]bool)
|
||||
for _, sym := range r.cfg.Symbols {
|
||||
@@ -547,6 +547,24 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch NetFlow ranking data if enabled in strategy
|
||||
if strategyConfig.Indicators.EnableNetFlowRanking {
|
||||
ctx.NetFlowRankingData = r.strategyEngine.FetchNetFlowRankingData()
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
logger.Infof("💰 Backtest: NetFlow ranking data ready: inst_in=%d, inst_out=%d",
|
||||
len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Price ranking data if enabled in strategy
|
||||
if strategyConfig.Indicators.EnablePriceRanking {
|
||||
ctx.PriceRankingData = r.strategyEngine.FetchPriceRankingData()
|
||||
if ctx.PriceRankingData != nil {
|
||||
logger.Infof("📈 Backtest: Price ranking data ready for %d durations",
|
||||
len(ctx.PriceRankingData.Durations))
|
||||
}
|
||||
}
|
||||
|
||||
record := &store.DecisionRecord{
|
||||
AccountState: store.AccountSnapshot{
|
||||
TotalBalance: accountInfo.TotalEquity,
|
||||
@@ -566,7 +584,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
return ctx, record, nil
|
||||
}
|
||||
|
||||
func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision.FullDecision) {
|
||||
func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *kernel.FullDecision) {
|
||||
record.InputPrompt = full.UserPrompt
|
||||
record.CoTTrace = full.CoTTrace
|
||||
if len(full.Decisions) > 0 {
|
||||
@@ -576,12 +594,12 @@ func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecision, error) {
|
||||
func (r *Runner) invokeAIWithRetry(ctx *kernel.Context) (*kernel.FullDecision, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
|
||||
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
|
||||
// This ensures backtest uses the same unified prompt generation as live trading
|
||||
fd, err := decision.GetFullDecisionWithStrategy(
|
||||
fd, err := kernel.GetFullDecisionWithStrategy(
|
||||
ctx,
|
||||
r.mcpClient,
|
||||
r.strategyEngine,
|
||||
@@ -597,7 +615,7 @@ func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecisio
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
|
||||
func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
|
||||
symbol := dec.Symbol
|
||||
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
|
||||
actionRecord := store.DecisionAction{
|
||||
@@ -739,7 +757,7 @@ func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]floa
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64 {
|
||||
func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
|
||||
snapshot := r.snapshotState()
|
||||
equity := snapshot.Equity
|
||||
if equity <= 0 {
|
||||
@@ -777,7 +795,7 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
|
||||
return qty
|
||||
}
|
||||
|
||||
func (r *Runner) determineCloseQuantity(symbol, side string, dec decision.Decision) float64 {
|
||||
func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision) float64 {
|
||||
for _, pos := range r.account.Positions() {
|
||||
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
|
||||
return pos.Quantity
|
||||
@@ -831,12 +849,12 @@ func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.Position
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *Runner) convertPositions(priceMap map[string]float64) []decision.PositionInfo {
|
||||
func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.PositionInfo {
|
||||
positions := r.account.Positions()
|
||||
list := make([]decision.PositionInfo, 0, len(positions))
|
||||
list := make([]kernel.PositionInfo, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
price := priceMap[pos.Symbol]
|
||||
list = append(list, decision.PositionInfo{
|
||||
list = append(list, kernel.PositionInfo{
|
||||
Symbol: pos.Symbol,
|
||||
Side: pos.Side,
|
||||
EntryPrice: pos.EntryPrice,
|
||||
@@ -1416,7 +1434,7 @@ func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot {
|
||||
return positions
|
||||
}
|
||||
|
||||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
||||
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||||
if len(decisions) <= 1 {
|
||||
return decisions
|
||||
}
|
||||
@@ -1434,7 +1452,7 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]decision.Decision, len(decisions))
|
||||
result := make([]kernel.Decision, len(decisions))
|
||||
copy(result, decisions)
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// TraderExecutor interface for executing trades
|
||||
type TraderExecutor interface {
|
||||
ExecuteDecision(decision *decision.Decision) error
|
||||
ExecuteDecision(decision *kernel.Decision) error
|
||||
GetBalance() (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
|
||||
}()
|
||||
|
||||
// Create strategy engine for building context
|
||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
||||
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
|
||||
|
||||
// Build market context using strategy config
|
||||
ctx, err := e.buildMarketContext(session, strategyEngine)
|
||||
@@ -289,7 +289,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
|
||||
}
|
||||
|
||||
// buildMarketContext builds the market context using strategy engine
|
||||
func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetails, strategyEngine *decision.StrategyEngine) (*decision.Context, error) {
|
||||
func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetails, strategyEngine *kernel.StrategyEngine) (*kernel.Context, error) {
|
||||
config := strategyEngine.GetConfig()
|
||||
|
||||
// Get candidate coins
|
||||
@@ -335,12 +335,18 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := strategyEngine.FetchOIRankingData()
|
||||
|
||||
// Fetch NetFlow ranking data (market-wide fund flow)
|
||||
netFlowRankingData := strategyEngine.FetchNetFlowRankingData()
|
||||
|
||||
// Fetch Price ranking data (market-wide gainers/losers)
|
||||
priceRankingData := strategyEngine.FetchPriceRankingData()
|
||||
|
||||
// Build context
|
||||
ctx := &decision.Context{
|
||||
ctx := &kernel.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: 0,
|
||||
CallCount: 1,
|
||||
Account: decision.AccountInfo{
|
||||
Account: kernel.AccountInfo{
|
||||
TotalEquity: 1000.0, // Simulated for debate
|
||||
AvailableBalance: 1000.0,
|
||||
UnrealizedPnL: 0,
|
||||
@@ -350,12 +356,14 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
||||
MarginUsedPct: 0,
|
||||
PositionCount: 0,
|
||||
},
|
||||
Positions: []decision.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: session.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
Positions: []kernel.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: session.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
PriceRankingData: priceRankingData,
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
@@ -539,7 +547,7 @@ func (e *DebateEngine) getParticipantResponse(
|
||||
}
|
||||
|
||||
// collectVotes collects final votes from all participants
|
||||
func (e *DebateEngine) collectVotes(session *store.DebateSessionWithDetails, strategyEngine *decision.StrategyEngine, allMessages []*store.DebateMessage) ([]*store.DebateVote, error) {
|
||||
func (e *DebateEngine) collectVotes(session *store.DebateSessionWithDetails, strategyEngine *kernel.StrategyEngine, allMessages []*store.DebateMessage) ([]*store.DebateVote, error) {
|
||||
var votes []*store.DebateVote
|
||||
|
||||
// Build voting context
|
||||
@@ -1009,7 +1017,7 @@ func (e *DebateEngine) ExecuteConsensus(sessionID string, executor TraderExecuto
|
||||
}
|
||||
|
||||
// Create decision
|
||||
tradeDecision := &decision.Decision{
|
||||
tradeDecision := &kernel.Decision{
|
||||
Symbol: session.Symbol,
|
||||
Action: action,
|
||||
Leverage: session.FinalDecision.Leverage,
|
||||
|
||||
48
docker-compose.stable.yml
Normal file
48
docker-compose.stable.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
# NOFX Stable Release Deployment
|
||||
# Production-ready stable version
|
||||
|
||||
services:
|
||||
nofx:
|
||||
image: ghcr.io/nofxaios/nofx/nofx-backend:stable
|
||||
container_name: nofx-trading
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- AI_MAX_TOKENS=8000
|
||||
networks:
|
||||
- nofx-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
nofx-frontend:
|
||||
image: ghcr.io/nofxaios/nofx/nofx-frontend:stable
|
||||
container_name: nofx-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NOFX_FRONTEND_PORT:-3000}:80"
|
||||
networks:
|
||||
- nofx-network
|
||||
depends_on:
|
||||
- nofx
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
nofx-network:
|
||||
driver: bridge
|
||||
852
docs/api/API_REFERENCE.md
Normal file
852
docs/api/API_REFERENCE.md
Normal file
@@ -0,0 +1,852 @@
|
||||
# CryptoMaster API 接口文档
|
||||
|
||||
## 概述
|
||||
|
||||
### 基础信息
|
||||
- **Base URL**: `https://nofxos.ai`
|
||||
- **响应格式**: JSON
|
||||
- **缓存时间**: 15秒(所有数据接口)
|
||||
- **限流**: 每个IP每秒最多30次请求
|
||||
|
||||
### 认证方式
|
||||
所有数据接口需要认证,支持两种方式:
|
||||
|
||||
#### 方式1: Query参数(推荐)
|
||||
```
|
||||
GET /api/ai500/list?auth=your_api_key
|
||||
```
|
||||
|
||||
#### 方式2: Authorization Header
|
||||
```
|
||||
GET /api/ai500/list
|
||||
Authorization: Bearer your_api_key
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 重要:数值格式说明
|
||||
|
||||
### 百分比字段格式
|
||||
|
||||
不同接口的百分比字段使用不同的格式,请注意区分:
|
||||
|
||||
| 字段名 | 格式 | 示例 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `price_delta` (涨跌幅榜/币种详情) | **小数** | `0.05` = 5% | 需要 ×100 转换为百分比 |
|
||||
| `oi_delta_percent` | **已×100** | `5.0` = 5% | 直接使用,无需转换 |
|
||||
| `price_delta_percent` (OI接口) | **已×100** | `5.0` = 5% | 直接使用,无需转换 |
|
||||
| `increase_percent` (AI500) | **已×100** | `7.14` = 7.14% | 直接使用,无需转换 |
|
||||
|
||||
### 金额字段
|
||||
|
||||
| 字段名 | 单位 | 说明 |
|
||||
|--------|------|------|
|
||||
| `oi_delta_value` | USDT | 持仓价值变化 |
|
||||
| `amount` / `future_flow` / `spot_flow` | USDT | 资金流量 |
|
||||
| `price` | USDT | 当前价格 |
|
||||
|
||||
### 持仓量字段
|
||||
|
||||
| 字段名 | 单位 | 说明 |
|
||||
|--------|------|------|
|
||||
| `oi_delta` | 张/个 | 持仓量变化 |
|
||||
| `current_oi` / `oi` | 张/个 | 当前持仓量 |
|
||||
| `net_long` / `net_short` | 张/个 | 净多头/空头持仓 |
|
||||
|
||||
---
|
||||
|
||||
## 时间范围参数说明
|
||||
|
||||
所有接口支持的 `duration` 参数值:
|
||||
|
||||
| 参数值 | 说明 | 备注 |
|
||||
|--------|------|------|
|
||||
| `1m` | 1分钟 | |
|
||||
| `5m` | 5分钟 | |
|
||||
| `15m` | 15分钟 | |
|
||||
| `30m` | 30分钟 | |
|
||||
| `1h` | 1小时 | 默认值 |
|
||||
| `4h` | 4小时 | |
|
||||
| `8h` | 8小时 | |
|
||||
| `12h` | 12小时 | |
|
||||
| `24h` / `1d` | 24小时 | 两种写法均可 |
|
||||
| `2d` | 2天 | |
|
||||
| `3d` | 3天 | |
|
||||
| `5d` | 5天 | |
|
||||
| `7d` | 7天 | |
|
||||
|
||||
---
|
||||
|
||||
## 1. AI500 智能评分接口
|
||||
|
||||
AI500 是基于多维度量化指标的智能评分系统,用于筛选具有上涨潜力的币种。
|
||||
|
||||
### 1.1 获取AI500推荐币种列表
|
||||
|
||||
获取经过严格筛选的优质币种列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/ai500/list
|
||||
```
|
||||
|
||||
**过滤条件**
|
||||
- AI评分 > 70
|
||||
- 币安OI持仓价值 > 15M USDT
|
||||
- 现价 > 上榜起始价格(只返回上涨中的币种)
|
||||
- 资金没有持续流出(1h/4h/12h/24h不能全为负)
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"count": 5,
|
||||
"coins": [
|
||||
{
|
||||
"pair": "BTCUSDT",
|
||||
"score": 85.234,
|
||||
"start_time": 1704067200,
|
||||
"start_price": 42000.5,
|
||||
"last_score": 83.5,
|
||||
"max_score": 87.2,
|
||||
"max_price": 45000.0,
|
||||
"increase_percent": 7.14
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `pair` | string | 交易对名称,如 BTCUSDT |
|
||||
| `score` | float | 当前AI评分(0-100) |
|
||||
| `start_time` | int64 | 上榜时间戳(Unix秒) |
|
||||
| `start_price` | float | 上榜时价格(USDT) |
|
||||
| `last_score` | float | 上次记录的评分 |
|
||||
| `max_score` | float | 在榜期间最高评分 |
|
||||
| `max_price` | float | 在榜期间最高价格(USDT) |
|
||||
| `increase_percent` | float | 最大涨幅百分比(**已×100**,7.14 = 7.14%) |
|
||||
|
||||
---
|
||||
|
||||
### 1.2 获取单个币种AI500信息
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/ai500/:symbol
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `symbol` | string | 是 | 币种符号,支持 `BTCUSDT` 或 `BTC` 格式 |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/ai500/BTC
|
||||
GET /api/ai500/ETHUSDT
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"info": {
|
||||
"pair": "BTCUSDT",
|
||||
"score": 85.234,
|
||||
"start_time": 1704067200,
|
||||
"start_price": 42000.5,
|
||||
"last_score": 83.5,
|
||||
"max_score": 87.2,
|
||||
"max_price": 45000.0,
|
||||
"increase_percent": 7.14
|
||||
},
|
||||
"current_price": 44500.0,
|
||||
"score": 85.234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 获取AI500统计信息
|
||||
|
||||
获取AI500整体统计数据。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/ai500/stats
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"statistics": {
|
||||
"total_count": 50,
|
||||
"average_score": 72.5,
|
||||
"max_score": 95.2,
|
||||
"min_score": 55.3,
|
||||
"average_increase": 12.5
|
||||
},
|
||||
"top_coins": [...],
|
||||
"bottom_coins": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 持仓量(OI)排行接口
|
||||
|
||||
监控各币种的合约持仓量变化,用于判断市场资金动向。
|
||||
|
||||
### 2.1 获取OI增加排行榜
|
||||
|
||||
返回持仓价值增加最多的币种排行。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/oi/top-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `limit` | int | 20 | 返回数量,最大100 |
|
||||
| `duration` | string | `1h` | 时间范围,见[时间范围参数](#时间范围参数说明) |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/oi/top-ranking?limit=50&duration=4h
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"count": 20,
|
||||
"exchange": "binance",
|
||||
"time_range": "4小时",
|
||||
"time_range_param": "4h",
|
||||
"rank_type": "top",
|
||||
"limit": 50,
|
||||
"positions": [
|
||||
{
|
||||
"rank": 1,
|
||||
"symbol": "BTCUSDT",
|
||||
"price": 44500.0,
|
||||
"oi_delta": 1500.5,
|
||||
"oi_delta_value": 65000000,
|
||||
"oi_delta_percent": 2.5,
|
||||
"current_oi": 62000,
|
||||
"price_delta_percent": 1.2,
|
||||
"net_long": 35000,
|
||||
"net_short": 27000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `rank` | int | - | 排名 |
|
||||
| `symbol` | string | - | 交易对名称 |
|
||||
| `price` | float | USDT | 当前价格 |
|
||||
| `oi_delta` | float | 张/个 | 持仓量变化 |
|
||||
| `oi_delta_value` | float | USDT | 持仓价值变化(**排序依据**) |
|
||||
| `oi_delta_percent` | float | **已×100** | 持仓量变化百分比,2.5 = 2.5% |
|
||||
| `current_oi` | float | 张/个 | 当前持仓量 |
|
||||
| `price_delta_percent` | float | **已×100** | 价格变化百分比,1.2 = 1.2% |
|
||||
| `net_long` | float | 张/个 | 净多头持仓 |
|
||||
| `net_short` | float | 张/个 | 净空头持仓 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 获取OI减少排行榜
|
||||
|
||||
返回持仓价值减少最多的币种排行。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/oi/low-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
同 [OI增加排行榜](#21-获取oi增加排行榜)
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/oi/low-ranking?limit=30&duration=24h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 获取OI Top20(向后兼容)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/oi/top
|
||||
```
|
||||
|
||||
固定返回1小时内OI增加最多的Top20,用于向后兼容。
|
||||
|
||||
---
|
||||
|
||||
## 3. 资金流量(NetFlow)排行接口
|
||||
|
||||
监控机构和散户的资金流向。
|
||||
|
||||
### 3.1 获取资金流入排行榜
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/netflow/top-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `limit` | int | 20 | 返回数量,最大100 |
|
||||
| `duration` | string | `1h` | 时间范围,见[时间范围参数](#时间范围参数说明) |
|
||||
| `type` | string | `institution` | 资金类型:`institution`(机构), `personal`(散户) |
|
||||
| `trade` | string | `future` | 交易类型:`future`(合约), `spot`(现货) |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/netflow/top-ranking?limit=30&duration=4h&type=institution&trade=future
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"count": 30,
|
||||
"type": "institution",
|
||||
"trade": "合约",
|
||||
"time_range": "4h",
|
||||
"rank_type": "top",
|
||||
"limit": 30,
|
||||
"netflows": [
|
||||
{
|
||||
"rank": 1,
|
||||
"symbol": "BTCUSDT",
|
||||
"amount": 15000000.5,
|
||||
"price": 44500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `rank` | int | - | 排名 |
|
||||
| `symbol` | string | - | 交易对名称 |
|
||||
| `amount` | float | USDT | 资金流量,**正数=流入,负数=流出** |
|
||||
| `price` | float | USDT | 当前价格 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 获取资金流出排行榜
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/netflow/low-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
同 [资金流入排行榜](#31-获取资金流入排行榜)
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/netflow/low-ranking?limit=20&duration=1h&type=personal&trade=spot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 获取资金流入Top20(向后兼容)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/netflow/top
|
||||
```
|
||||
|
||||
固定返回1小时内机构合约资金流入最多的Top20。
|
||||
|
||||
---
|
||||
|
||||
## 4. 涨跌幅榜接口
|
||||
|
||||
### 4.1 获取涨跌幅榜
|
||||
|
||||
同时返回涨幅榜(top)和跌幅榜(low),支持多个时间周期同时查询。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/price/ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `duration` | string | `1h` | 时间范围,可多选逗号分隔:`1h,4h,24h` |
|
||||
| `limit` | int | 20 | 每个榜单返回数量,最大100 |
|
||||
| `exchange` | string | `binance` | 交易所 |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/price/ranking?duration=1h,4h,24h&limit=20
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"durations": ["1h", "4h", "24h"],
|
||||
"limit": 20,
|
||||
"data": {
|
||||
"1h": {
|
||||
"top": [
|
||||
{
|
||||
"pair": "MOGUSDT",
|
||||
"symbol": "MOG",
|
||||
"price_delta": 0.0723,
|
||||
"price": 0.00123,
|
||||
"future_flow": 201500,
|
||||
"spot_flow": 0,
|
||||
"oi": 15000000,
|
||||
"oi_delta": 500000,
|
||||
"oi_delta_value": 615
|
||||
}
|
||||
],
|
||||
"low": [
|
||||
{
|
||||
"pair": "XYZUSDT",
|
||||
"symbol": "XYZ",
|
||||
"price_delta": -0.0512,
|
||||
"price": 1.234,
|
||||
"future_flow": -50000,
|
||||
"spot_flow": -10000,
|
||||
"oi": 8000000,
|
||||
"oi_delta": -200000,
|
||||
"oi_delta_value": -246800
|
||||
}
|
||||
]
|
||||
},
|
||||
"4h": { ... },
|
||||
"24h": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `pair` | string | - | 完整交易对名称,如 BTCUSDT |
|
||||
| `symbol` | string | - | 币种符号(去除USDT),如 BTC |
|
||||
| `price_delta` | float | **小数** | 价格变动比例,**0.0723 = 7.23%**(需×100显示) |
|
||||
| `price` | float | USDT | 当前价格 |
|
||||
| `future_flow` | float | USDT | 合约资金流量,正数=流入 |
|
||||
| `spot_flow` | float | USDT | 现货资金流量,正数=流入 |
|
||||
| `oi` | float | 张/个 | 当前持仓量 |
|
||||
| `oi_delta` | float | 张/个 | 持仓变化量 |
|
||||
| `oi_delta_value` | float | USDT | 持仓变化价值 |
|
||||
|
||||
> **注意**:`price_delta` 使用小数格式,与 OI 接口的 `price_delta_percent` 不同!
|
||||
|
||||
---
|
||||
|
||||
## 5. 币种详情接口
|
||||
|
||||
### 5.1 获取单币种完整数据
|
||||
|
||||
获取指定币种的所有统计信息,一次调用获取全部数据。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/coin/:symbol
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `symbol` | string | 是 | 币种符号,支持 `BTC` 或 `BTCUSDT` 格式 |
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `include` | string | `netflow,oi,price,ai500` | 包含的数据类型,逗号分隔 |
|
||||
|
||||
**include 参数选项**
|
||||
| 值 | 说明 |
|
||||
|------|------|
|
||||
| `netflow` | 资金流量数据(机构/散户,合约/现货) |
|
||||
| `oi` | 持仓量数据(币安/Bybit) |
|
||||
| `price` | 价格变化数据 |
|
||||
| `ai500` | AI500评分 |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/coin/BTC?include=netflow,oi,price,ai500
|
||||
GET /api/coin/ETHUSDT?include=netflow,oi
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"symbol": "BTCUSDT",
|
||||
"price": 44500.0,
|
||||
"ai500": {
|
||||
"score": 85.234,
|
||||
"is_active": true,
|
||||
"start_time": 1704067200,
|
||||
"start_price": 42000.5,
|
||||
"increase_percent": 5.95
|
||||
},
|
||||
"netflow": {
|
||||
"institution": {
|
||||
"future": {
|
||||
"1m": 50000,
|
||||
"5m": 200000,
|
||||
"15m": 500000,
|
||||
"30m": 800000,
|
||||
"1h": 1500000,
|
||||
"4h": 5000000,
|
||||
"8h": 8000000,
|
||||
"12h": 10000000,
|
||||
"24h": 15000000,
|
||||
"2d": 25000000,
|
||||
"3d": 35000000,
|
||||
"5d": 50000000,
|
||||
"7d": 75000000
|
||||
},
|
||||
"spot": { ... }
|
||||
},
|
||||
"personal": {
|
||||
"future": { ... },
|
||||
"spot": { ... }
|
||||
}
|
||||
},
|
||||
"oi": {
|
||||
"binance": {
|
||||
"current_oi": 62000,
|
||||
"net_long": 35000,
|
||||
"net_short": 27000,
|
||||
"delta": {
|
||||
"1m": {
|
||||
"oi_delta": 50,
|
||||
"oi_delta_value": 2225000,
|
||||
"oi_delta_percent": 0.08
|
||||
},
|
||||
"5m": { ... },
|
||||
"1h": { ... },
|
||||
"4h": { ... },
|
||||
"24h": { ... }
|
||||
}
|
||||
},
|
||||
"bybit": { ... }
|
||||
},
|
||||
"price_change": {
|
||||
"1m": 0.001,
|
||||
"5m": 0.005,
|
||||
"15m": 0.008,
|
||||
"30m": 0.012,
|
||||
"1h": 0.015,
|
||||
"4h": 0.025,
|
||||
"8h": 0.035,
|
||||
"12h": 0.042,
|
||||
"24h": 0.055,
|
||||
"2d": 0.08,
|
||||
"3d": 0.12,
|
||||
"5d": 0.18,
|
||||
"7d": 0.25
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
**price_change 对象**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `{duration}` | float | **小数** | 价格变化比例,**0.015 = 1.5%**(需×100显示) |
|
||||
|
||||
**netflow 对象**
|
||||
| 路径 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `institution.future.{duration}` | float | USDT | 机构合约资金流量 |
|
||||
| `institution.spot.{duration}` | float | USDT | 机构现货资金流量 |
|
||||
| `personal.future.{duration}` | float | USDT | 散户合约资金流量 |
|
||||
| `personal.spot.{duration}` | float | USDT | 散户现货资金流量 |
|
||||
|
||||
**oi 对象**
|
||||
| 路径 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `binance.current_oi` | float | 张/个 | 币安当前持仓量 |
|
||||
| `binance.net_long` | float | 张/个 | 币安净多头 |
|
||||
| `binance.net_short` | float | 张/个 | 币安净空头 |
|
||||
| `binance.delta.{duration}.oi_delta` | float | 张/个 | 持仓量变化 |
|
||||
| `binance.delta.{duration}.oi_delta_value` | float | USDT | 持仓价值变化 |
|
||||
| `binance.delta.{duration}.oi_delta_percent` | float | **已×100** | 持仓变化百分比,0.08 = 0.08% |
|
||||
| `bybit.*` | - | - | Bybit数据,结构同上 |
|
||||
|
||||
**ai500 对象**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `score` | float | 0-100 | AI综合评分 |
|
||||
| `is_active` | bool | - | 是否为活跃高分币种 |
|
||||
| `start_time` | int64 | Unix秒 | 上榜时间 |
|
||||
| `start_price` | float | USDT | 上榜时价格 |
|
||||
| `increase_percent` | float | **已×100** | 最大涨幅,5.95 = 5.95% |
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| HTTP状态码 | 说明 | 常见原因 |
|
||||
|------------|------|----------|
|
||||
| 200 | 成功 | - |
|
||||
| 400 | 请求参数错误 | 参数格式不正确、缺少必填参数 |
|
||||
| 401 | 未授权 | 缺少认证信息或API Key无效 |
|
||||
| 404 | 资源不存在 | 币种不存在或未被追踪 |
|
||||
| 429 | 请求过于频繁 | 超过限流阈值(30次/秒) |
|
||||
| 500 | 服务器内部错误 | 服务端异常 |
|
||||
|
||||
**错误响应示例**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL 示例
|
||||
|
||||
```bash
|
||||
# 方式1: Query参数认证
|
||||
curl "https://nofxos.ai/api/ai500/list?auth=your_api_key"
|
||||
|
||||
# 方式2: Header认证
|
||||
curl "https://nofxos.ai/api/ai500/list" \
|
||||
-H "Authorization: Bearer your_api_key"
|
||||
|
||||
# 获取1小时涨跌幅榜
|
||||
curl "https://nofxos.ai/api/price/ranking?duration=1h&limit=20&auth=your_api_key"
|
||||
|
||||
# 获取多个时间周期涨跌幅榜
|
||||
curl "https://nofxos.ai/api/price/ranking?duration=1h,4h,24h&limit=10&auth=your_api_key"
|
||||
|
||||
# 获取BTC详细数据
|
||||
curl "https://nofxos.ai/api/coin/BTC?auth=your_api_key"
|
||||
|
||||
# 只获取BTC的资金流和OI数据
|
||||
curl "https://nofxos.ai/api/coin/BTC?include=netflow,oi&auth=your_api_key"
|
||||
|
||||
# 获取4小时OI增加排行Top50
|
||||
curl "https://nofxos.ai/api/oi/top-ranking?duration=4h&limit=50&auth=your_api_key"
|
||||
|
||||
# 获取24小时OI减少排行Top30
|
||||
curl "https://nofxos.ai/api/oi/low-ranking?duration=24h&limit=30&auth=your_api_key"
|
||||
|
||||
# 获取机构合约资金流入排行
|
||||
curl "https://nofxos.ai/api/netflow/top-ranking?type=institution&trade=future&duration=1h&auth=your_api_key"
|
||||
|
||||
# 获取散户现货资金流出排行
|
||||
curl "https://nofxos.ai/api/netflow/low-ranking?type=personal&trade=spot&duration=4h&auth=your_api_key"
|
||||
```
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://nofxos.ai"
|
||||
API_KEY = "your_api_key"
|
||||
|
||||
# 方式1: Query参数认证
|
||||
def get_with_query_auth(endpoint, params=None):
|
||||
if params is None:
|
||||
params = {}
|
||||
params["auth"] = API_KEY
|
||||
response = requests.get(f"{BASE_URL}{endpoint}", params=params)
|
||||
return response.json()
|
||||
|
||||
# 方式2: Header认证
|
||||
def get_with_header_auth(endpoint, params=None):
|
||||
headers = {"Authorization": f"Bearer {API_KEY}"}
|
||||
response = requests.get(f"{BASE_URL}{endpoint}", params=params, headers=headers)
|
||||
return response.json()
|
||||
|
||||
# 获取AI500列表
|
||||
def get_ai500_list():
|
||||
return get_with_query_auth("/api/ai500/list")
|
||||
|
||||
# 获取涨跌幅榜
|
||||
def get_price_ranking(durations="1h,4h,24h", limit=20):
|
||||
return get_with_query_auth("/api/price/ranking", {
|
||||
"duration": durations,
|
||||
"limit": limit
|
||||
})
|
||||
|
||||
# 获取币种详情
|
||||
def get_coin_stats(symbol, include="netflow,oi,price,ai500"):
|
||||
return get_with_query_auth(f"/api/coin/{symbol}", {
|
||||
"include": include
|
||||
})
|
||||
|
||||
# 获取OI排行
|
||||
def get_oi_ranking(rank_type="top", duration="1h", limit=20):
|
||||
endpoint = f"/api/oi/{rank_type}-ranking"
|
||||
return get_with_query_auth(endpoint, {
|
||||
"duration": duration,
|
||||
"limit": limit
|
||||
})
|
||||
|
||||
# 获取资金流排行
|
||||
def get_netflow_ranking(rank_type="top", duration="1h", limit=20,
|
||||
flow_type="institution", trade="future"):
|
||||
endpoint = f"/api/netflow/{rank_type}-ranking"
|
||||
return get_with_query_auth(endpoint, {
|
||||
"duration": duration,
|
||||
"limit": limit,
|
||||
"type": flow_type,
|
||||
"trade": trade
|
||||
})
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
# 获取AI500推荐币种
|
||||
ai500 = get_ai500_list()
|
||||
print(f"AI500推荐币种数量: {ai500['data']['count']}")
|
||||
|
||||
# 获取1小时涨幅榜前10
|
||||
ranking = get_price_ranking("1h", 10)
|
||||
for coin in ranking['data']['data']['1h']['top'][:3]:
|
||||
# 注意: price_delta 是小数,需要×100
|
||||
pct = coin['price_delta'] * 100
|
||||
print(f"{coin['symbol']}: {pct:.2f}%")
|
||||
|
||||
# 获取BTC详情
|
||||
btc = get_coin_stats("BTC")
|
||||
# 注意: price_change 是小数
|
||||
print(f"BTC 1小时涨跌: {btc['data']['price_change']['1h'] * 100:.2f}%")
|
||||
|
||||
# 获取4小时OI增加Top20
|
||||
oi = get_oi_ranking("top", "4h", 20)
|
||||
for pos in oi['data']['positions'][:3]:
|
||||
# 注意: oi_delta_percent 已×100
|
||||
print(f"{pos['symbol']}: OI变化 {pos['oi_delta_percent']:.2f}%")
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript 示例
|
||||
|
||||
```typescript
|
||||
const BASE_URL = "https://nofxos.ai";
|
||||
const API_KEY = "your_api_key";
|
||||
|
||||
// 通用请求函数
|
||||
async function apiRequest<T>(endpoint: string, params: Record<string, any> = {}): Promise<T> {
|
||||
const url = new URL(`${BASE_URL}${endpoint}`);
|
||||
params.auth = API_KEY;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取涨跌幅榜
|
||||
interface PriceRankingItem {
|
||||
pair: string;
|
||||
symbol: string;
|
||||
price_delta: number; // 小数格式,0.05 = 5%
|
||||
price: number;
|
||||
future_flow: number;
|
||||
spot_flow: number;
|
||||
}
|
||||
|
||||
async function getPriceRanking(durations = "1h", limit = 20) {
|
||||
const data = await apiRequest<any>("/api/price/ranking", { duration: durations, limit });
|
||||
return data;
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
async function main() {
|
||||
const ranking = await getPriceRanking("1h,4h", 10);
|
||||
|
||||
for (const coin of ranking.data.data["1h"].top) {
|
||||
// 转换为百分比显示
|
||||
const pctChange = (coin.price_delta * 100).toFixed(2);
|
||||
console.log(`${coin.symbol}: ${pctChange}%`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么有些百分比字段格式不同?
|
||||
|
||||
A: 这是历史原因造成的:
|
||||
- **OI接口**的 `oi_delta_percent` 和 `price_delta_percent` 是**已乘100**的格式(5.0 = 5%)
|
||||
- **涨跌幅榜和币种详情**的 `price_delta` / `price_change` 是**小数**格式(0.05 = 5%)
|
||||
|
||||
建议在前端显示时统一处理。
|
||||
|
||||
### Q: duration 参数支持哪些值?
|
||||
|
||||
A: 支持以下值:`1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `8h`, `12h`, `24h`(或`1d`), `2d`, `3d`, `5d`, `7d`
|
||||
|
||||
### Q: 如何判断资金是流入还是流出?
|
||||
|
||||
A: `amount`、`future_flow`、`spot_flow` 等字段:
|
||||
- **正数** = 资金流入
|
||||
- **负数** = 资金流出
|
||||
|
||||
### Q: API缓存时间是多久?
|
||||
|
||||
A: 所有数据接口缓存15秒,相同请求在15秒内返回缓存数据。
|
||||
|
||||
### Q: 限流规则是什么?
|
||||
|
||||
A: 每个IP每秒最多30次请求,超过会返回 429 错误。
|
||||
@@ -1,350 +0,0 @@
|
||||
# 币种综合数据接口文档
|
||||
|
||||
## 接口概述
|
||||
|
||||
该接口提供单个币种的综合数据查询,一次请求即可获取资金净流入、持仓变化、价格变化等多维度数据。
|
||||
|
||||
## 请求信息
|
||||
|
||||
### 接口地址
|
||||
|
||||
```
|
||||
GET /api/coin/{symbol}
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||
|-----|------|------|-----|------|
|
||||
| symbol | path | string | 是 | 币种符号,如 `PIPPINUSDT`、`ETH`(会自动补全USDT后缀) |
|
||||
| include | query | string | 否 | 返回数据类型,逗号分隔。可选值:`netflow,oi,price`。默认返回全部 |
|
||||
| auth | query | string | 是 | 认证密钥 |
|
||||
|
||||
### include 参数说明
|
||||
|
||||
| 值 | 说明 |
|
||||
|---|------|
|
||||
| netflow | 资金净流入数据(机构/散户、合约/现货) |
|
||||
| oi | 持仓数据(币安、Bybit) |
|
||||
| price | 价格变化百分比 |
|
||||
|
||||
---
|
||||
|
||||
## 返回数据
|
||||
|
||||
### 完整响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"symbol": "PIPPINUSDT",
|
||||
"price": 0.085,
|
||||
"netflow": {
|
||||
"institution": {
|
||||
"future": {
|
||||
"1m": 120000,
|
||||
"5m": 580000,
|
||||
"15m": 1200000,
|
||||
"30m": 2500000,
|
||||
"1h": 5800000,
|
||||
"4h": 12000000,
|
||||
"8h": 25000000,
|
||||
"12h": 38000000,
|
||||
"24h": 65000000,
|
||||
"2d": 120000000,
|
||||
"3d": 180000000
|
||||
},
|
||||
"spot": {
|
||||
"1m": 50000,
|
||||
"5m": 280000,
|
||||
"15m": 600000,
|
||||
"30m": 1200000,
|
||||
"1h": 2800000,
|
||||
"4h": 6000000,
|
||||
"8h": 12000000,
|
||||
"12h": 18000000,
|
||||
"24h": 32000000,
|
||||
"2d": 60000000,
|
||||
"3d": 90000000
|
||||
}
|
||||
},
|
||||
"personal": {
|
||||
"future": {
|
||||
"1m": -80000,
|
||||
"5m": -350000,
|
||||
"15m": -800000,
|
||||
"30m": -1500000,
|
||||
"1h": -3200000,
|
||||
"4h": -8000000,
|
||||
"8h": -15000000,
|
||||
"12h": -22000000,
|
||||
"24h": -40000000,
|
||||
"2d": -75000000,
|
||||
"3d": -110000000
|
||||
},
|
||||
"spot": {
|
||||
"1m": -30000,
|
||||
"5m": -150000,
|
||||
"15m": -400000,
|
||||
"30m": -800000,
|
||||
"1h": -1800000,
|
||||
"4h": -4000000,
|
||||
"8h": -8000000,
|
||||
"12h": -12000000,
|
||||
"24h": -22000000,
|
||||
"2d": -40000000,
|
||||
"3d": -60000000
|
||||
}
|
||||
}
|
||||
},
|
||||
"oi": {
|
||||
"binance": {
|
||||
"current_oi": 85000,
|
||||
"net_long": 48000,
|
||||
"net_short": 37000,
|
||||
"delta": {
|
||||
"1m": {
|
||||
"oi_delta": 150,
|
||||
"oi_delta_value": 14550000,
|
||||
"oi_delta_percent": 0.18
|
||||
},
|
||||
"5m": {
|
||||
"oi_delta": 680,
|
||||
"oi_delta_value": 65960000,
|
||||
"oi_delta_percent": 0.8
|
||||
},
|
||||
"1h": {
|
||||
"oi_delta": 2500,
|
||||
"oi_delta_value": 242500000,
|
||||
"oi_delta_percent": 2.94
|
||||
},
|
||||
"4h": {
|
||||
"oi_delta": 5200,
|
||||
"oi_delta_value": 504400000,
|
||||
"oi_delta_percent": 6.12
|
||||
},
|
||||
"24h": {
|
||||
"oi_delta": 8500,
|
||||
"oi_delta_value": 824500000,
|
||||
"oi_delta_percent": 10.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"bybit": {
|
||||
"current_oi": 42000,
|
||||
"net_long": 24000,
|
||||
"net_short": 18000,
|
||||
"delta": {
|
||||
"1h": {
|
||||
"oi_delta": 1200,
|
||||
"oi_delta_value": 116400000,
|
||||
"oi_delta_percent": 2.86
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"price_change": {
|
||||
"1m": 0.05,
|
||||
"5m": 0.18,
|
||||
"15m": 0.35,
|
||||
"30m": 0.62,
|
||||
"1h": 1.25,
|
||||
"4h": 2.80,
|
||||
"8h": 3.50,
|
||||
"12h": 2.95,
|
||||
"24h": 4.80,
|
||||
"2d": 6.50,
|
||||
"3d": 8.20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段详细说明
|
||||
|
||||
### 基础字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| symbol | string | 币种交易对,如 `PIPPINUSDT` |
|
||||
| price | float | 当前期货价格(单位:USDT) |
|
||||
|
||||
---
|
||||
|
||||
### netflow - 资金净流入
|
||||
|
||||
资金净流入数据,**正数表示资金流入,负数表示资金流出**,单位为 USDT。
|
||||
|
||||
#### 数据结构
|
||||
|
||||
```
|
||||
netflow
|
||||
├── institution # 机构资金
|
||||
│ ├── future # 合约市场
|
||||
│ └── spot # 现货市场
|
||||
└── personal # 散户资金
|
||||
├── future # 合约市场
|
||||
└── spot # 现货市场
|
||||
```
|
||||
|
||||
#### 分类说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-----|------|
|
||||
| institution.future | 机构在合约市场的资金净流入 |
|
||||
| institution.spot | 机构在现货市场的资金净流入 |
|
||||
| personal.future | 散户在合约市场的资金净流入 |
|
||||
| personal.spot | 散户在现货市场的资金净流入 |
|
||||
|
||||
#### 时间周期
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-----|------|
|
||||
| 1m | 最近 1 分钟 |
|
||||
| 5m | 最近 5 分钟 |
|
||||
| 15m | 最近 15 分钟 |
|
||||
| 30m | 最近 30 分钟 |
|
||||
| 1h | 最近 1 小时 |
|
||||
| 4h | 最近 4 小时 |
|
||||
| 8h | 最近 8 小时 |
|
||||
| 12h | 最近 12 小时 |
|
||||
| 24h | 最近 24 小时 |
|
||||
| 2d | 最近 2 天 |
|
||||
| 3d | 最近 3 天 |
|
||||
|
||||
#### 使用建议
|
||||
|
||||
- **机构资金流入 + 散户资金流出** = 典型的主力吸筹信号
|
||||
- **机构资金流出 + 散户资金流入** = 典型的主力出货信号
|
||||
- 关注 **合约与现货的资金流向是否一致**,判断市场情绪
|
||||
|
||||
---
|
||||
|
||||
### oi - 持仓数据
|
||||
|
||||
持仓量(Open Interest)数据,来源于币安和 Bybit 交易所。
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| current_oi | float | 当前总持仓量(单位:币) |
|
||||
| net_long | float | 净多头持仓量 |
|
||||
| net_short | float | 净空头持仓量 |
|
||||
| delta | object | 各时间周期的持仓变化 |
|
||||
|
||||
#### delta 子字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| oi_delta | float | 持仓量变化(单位:币) |
|
||||
| oi_delta_value | float | 持仓价值变化(单位:USDT) |
|
||||
| oi_delta_percent | float | 持仓量变化百分比(%) |
|
||||
|
||||
#### 使用建议
|
||||
|
||||
- **持仓量增加 + 价格上涨** = 多头主导,趋势可能延续
|
||||
- **持仓量增加 + 价格下跌** = 空头主导,下跌趋势可能延续
|
||||
- **持仓量减少 + 价格变化** = 平仓为主,趋势可能反转
|
||||
- **net_long > net_short** = 市场整体偏多
|
||||
|
||||
---
|
||||
|
||||
### price_change - 价格变化
|
||||
|
||||
各时间周期的价格涨跌幅,**单位为百分比(%)**,正数表示上涨,负数表示下跌。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-----|------|
|
||||
| 1m | 最近 1 分钟涨跌幅 |
|
||||
| 5m | 最近 5 分钟涨跌幅 |
|
||||
| 15m | 最近 15 分钟涨跌幅 |
|
||||
| 30m | 最近 30 分钟涨跌幅 |
|
||||
| 1h | 最近 1 小时涨跌幅 |
|
||||
| 4h | 最近 4 小时涨跌幅 |
|
||||
| 8h | 最近 8 小时涨跌幅 |
|
||||
| 12h | 最近 12 小时涨跌幅 |
|
||||
| 24h | 最近 24 小时涨跌幅 |
|
||||
| 2d | 最近 2 天涨跌幅 |
|
||||
| 3d | 最近 3 天涨跌幅 |
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 400 | 参数错误(如缺少 symbol) |
|
||||
| 401 | 认证失败(auth 无效) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
错误响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "symbol parameter is required"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X GET "http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://nofxaios.com:30006/api/coin/PIPPINUSDT"
|
||||
params = {
|
||||
"include": "netflow,oi,price",
|
||||
"auth": "cm_568c67eae410d912c54c"
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
print(f"当前价格: {data['data']['price']}")
|
||||
print(f"1小时机构合约净流入: {data['data']['netflow']['institution']['future']['1h']}")
|
||||
print(f"24小时价格涨跌幅: {data['data']['price_change']['24h']}%")
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const url = 'http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c';
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('当前价格:', data.data.price);
|
||||
console.log('1小时机构合约净流入:', data.data.netflow.institution.future['1h']);
|
||||
console.log('24小时价格涨跌幅:', data.data.price_change['24h'], '%');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **symbol 参数**:支持带或不带 `USDT` 后缀,如 `PIPPIN` 和 `PIPPINUSDT` 等效
|
||||
2. **include 参数**:可按需选择返回数据,减少不必要的数据传输
|
||||
3. **数据更新频率**:数据实时更新,建议轮询间隔不低于 1 秒
|
||||
4. **资金流向解读**:机构与散户的资金流向通常呈相反趋势,可作为市场情绪判断依据
|
||||
@@ -1,254 +0,0 @@
|
||||
# OI 持仓数据接口文档
|
||||
|
||||
## 接口概述
|
||||
|
||||
该接口提供币安交易所的合约持仓量(Open Interest)排行数据,支持查询持仓增加和减少排行榜。
|
||||
|
||||
## 接口列表
|
||||
|
||||
| 接口 | 说明 |
|
||||
|-----|------|
|
||||
| `/api/oi/top` | 持仓增加排行 Top20(固定参数,向后兼容) |
|
||||
| `/api/oi/top-ranking` | 持仓增加排行(支持自定义参数) |
|
||||
| `/api/oi/low-ranking` | 持仓减少排行(支持自定义参数) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 持仓增加排行 Top20
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/oi/top
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/oi/top?auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| auth | string | 是 | 认证密钥 |
|
||||
|
||||
### 说明
|
||||
|
||||
固定返回 1 小时内持仓价值增加最多的前 20 个币种,向后兼容接口。
|
||||
|
||||
---
|
||||
|
||||
## 2. 持仓增加排行(自定义参数)
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/oi/top-ranking
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|-----|------|-----|-------|------|
|
||||
| limit | int | 否 | 20 | 获取数量,范围 1-100 |
|
||||
| duration | string | 否 | 1h | 时间范围 |
|
||||
| auth | string | 是 | - | 认证密钥 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 持仓减少排行
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/oi/low-ranking
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/oi/low-ranking?limit=30&duration=24h&auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
同持仓增加排行接口。
|
||||
|
||||
---
|
||||
|
||||
## duration 时间范围参数
|
||||
|
||||
| 值 | 说明 |
|
||||
|---|------|
|
||||
| 1m | 1 分钟 |
|
||||
| 5m | 5 分钟 |
|
||||
| 15m | 15 分钟 |
|
||||
| 30m | 30 分钟 |
|
||||
| 1h | 1 小时(默认) |
|
||||
| 4h | 4 小时 |
|
||||
| 8h | 8 小时 |
|
||||
| 12h | 12 小时 |
|
||||
| 24h | 24 小时 |
|
||||
| 1d | 1 天(同 24h) |
|
||||
| 2d | 2 天 |
|
||||
| 3d | 3 天 |
|
||||
|
||||
---
|
||||
|
||||
## 返回数据
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"count": 20,
|
||||
"exchange": "binance",
|
||||
"time_range": "4小时",
|
||||
"time_range_param": "4h",
|
||||
"rank_type": "top",
|
||||
"limit": 20,
|
||||
"positions": [
|
||||
{
|
||||
"rank": 1,
|
||||
"symbol": "BTCUSDT",
|
||||
"oi_delta": 1500.5,
|
||||
"oi_delta_value": 145500000,
|
||||
"oi_delta_percent": 3.52,
|
||||
"current_oi": 44000,
|
||||
"price_delta_percent": 2.15,
|
||||
"net_long": 26000,
|
||||
"net_short": 18000
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"symbol": "ETHUSDT",
|
||||
"oi_delta": 25000,
|
||||
"oi_delta_value": 87500000,
|
||||
"oi_delta_percent": 2.85,
|
||||
"current_oi": 900000,
|
||||
"price_delta_percent": 1.80,
|
||||
"net_long": 520000,
|
||||
"net_short": 380000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
#### 外层字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| count | int | 返回的币种数量 |
|
||||
| exchange | string | 交易所,固定为 `binance` |
|
||||
| time_range | string | 时间范围显示名称 |
|
||||
| time_range_param | string | 时间范围参数值 |
|
||||
| rank_type | string | 排行类型:`top` 增加 / `low` 减少 |
|
||||
| limit | int | 请求的数量限制 |
|
||||
| positions | array | 持仓数据列表 |
|
||||
|
||||
#### positions 数组字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| rank | int | 排名 |
|
||||
| symbol | string | 币种交易对,如 `BTCUSDT` |
|
||||
| oi_delta | float | 持仓量变化(单位:币) |
|
||||
| oi_delta_value | float | 持仓价值变化(单位:USDT),**排序依据** |
|
||||
| oi_delta_percent | float | 持仓量变化百分比(%) |
|
||||
| current_oi | float | 当前持仓量(单位:币) |
|
||||
| price_delta_percent | float | 价格变化百分比(%) |
|
||||
| net_long | float | 净多头持仓量 |
|
||||
| net_short | float | 净空头持仓量 |
|
||||
|
||||
---
|
||||
|
||||
## 数据解读
|
||||
|
||||
### 持仓量与价格的关系
|
||||
|
||||
| 持仓变化 | 价格变化 | 市场含义 |
|
||||
|---------|---------|---------|
|
||||
| 增加 | 上涨 | 多头主导,上涨趋势可能延续 |
|
||||
| 增加 | 下跌 | 空头主导,下跌趋势可能延续 |
|
||||
| 减少 | 上涨 | 空头平仓,可能是反弹 |
|
||||
| 减少 | 下跌 | 多头平仓,可能是回调 |
|
||||
|
||||
### 多空比例
|
||||
|
||||
- `net_long > net_short`:市场整体偏多
|
||||
- `net_long < net_short`:市场整体偏空
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X GET "http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://nofxaios.com:30006/api/oi/top-ranking"
|
||||
params = {
|
||||
"limit": 50,
|
||||
"duration": "4h",
|
||||
"auth": "cm_568c67eae410d912c54c"
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
for pos in data['data']['positions']:
|
||||
print(f"#{pos['rank']} {pos['symbol']}: 持仓价值变化 ${pos['oi_delta_value']:,.0f}")
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const url = 'http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c';
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.data.positions.forEach(pos => {
|
||||
console.log(`#${pos.rank} ${pos.symbol}: 持仓价值变化 $${pos.oi_delta_value.toLocaleString()}`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 401 | 认证失败(auth 无效) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 数据来源为币安交易所
|
||||
2. 排行依据为 `oi_delta_value`(持仓价值变化),非持仓量变化
|
||||
3. 数据缓存 2 秒,高频请求会命中缓存
|
||||
4. `limit` 最大值为 100
|
||||
@@ -112,7 +112,7 @@ func (e *StrategyEngine) getCoinPoolCoins(limit int) []CandidateCoin {
|
||||
}
|
||||
```
|
||||
|
||||
- **API:** `config.CoinSource.CoinPoolAPIURL` (默认: `http://nofxaios.com:30006/api/ai500/list`)
|
||||
- **API:** `config.CoinSource.CoinPoolAPIURL` (默认: `https://nofxos.ai/api/ai500/list`)
|
||||
- **用途:** 获取 AI 评分最高的 N 个币种
|
||||
- **标签:** `["ai500"]`
|
||||
|
||||
|
||||
106
install-stable.sh
Executable file
106
install-stable.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# NOFX Stable Release Installation Script
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/release/stable/install-stable.sh | bash
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
INSTALL_DIR="${1:-$HOME/nofx}"
|
||||
COMPOSE_FILE="docker-compose.stable.yml"
|
||||
GITHUB_RAW="https://raw.githubusercontent.com/NoFxAiOS/nofx/release/stable"
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ NOFX Stable Release ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker daemon is not running.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if docker compose version &> /dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
echo -e "${RED}Error: Docker Compose is not available.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Docker ready${NC}"
|
||||
}
|
||||
|
||||
setup_directory() {
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
echo -e "${GREEN}✓ Directory: $INSTALL_DIR${NC}"
|
||||
}
|
||||
|
||||
download_files() {
|
||||
curl -fsSL "$GITHUB_RAW/$COMPOSE_FILE" -o docker-compose.yml
|
||||
echo -e "${GREEN}✓ Config downloaded${NC}"
|
||||
}
|
||||
|
||||
generate_env() {
|
||||
if [ -f ".env" ]; then
|
||||
echo -e "${GREEN}✓ .env exists${NC}"
|
||||
return
|
||||
fi
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
DATA_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null | tr '\n' '\\' | sed 's/\\/\\n/g' | sed 's/\\n$//')
|
||||
cat > .env << EOF
|
||||
NOFX_BACKEND_PORT=8080
|
||||
NOFX_FRONTEND_PORT=3000
|
||||
TZ=Asia/Shanghai
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY}
|
||||
RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY}
|
||||
EOF
|
||||
echo -e "${GREEN}✓ Keys generated${NC}"
|
||||
}
|
||||
|
||||
start_services() {
|
||||
$COMPOSE_CMD pull
|
||||
$COMPOSE_CMD up -d
|
||||
echo -e "${GREEN}✓ Services started${NC}"
|
||||
}
|
||||
|
||||
get_server_ip() {
|
||||
local ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "")
|
||||
echo "${ip:-127.0.0.1}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
local IP=$(get_server_ip)
|
||||
echo ""
|
||||
echo -e "${GREEN}Installation Complete!${NC}"
|
||||
echo -e " Web: http://${IP}:3000"
|
||||
echo -e " API: http://${IP}:8080"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main() {
|
||||
check_docker
|
||||
setup_directory
|
||||
download_files
|
||||
generate_env
|
||||
start_services
|
||||
print_success
|
||||
}
|
||||
|
||||
main
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/provider"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"regexp"
|
||||
@@ -119,8 +119,10 @@ type Context struct {
|
||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||
OITopDataMap map[string]*OITopData `json:"-"`
|
||||
QuantDataMap map[string]*QuantData `json:"-"`
|
||||
OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
BTCETHLeverage int `json:"-"`
|
||||
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
|
||||
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
|
||||
BTCETHLeverage int `json:"-"`
|
||||
AltcoinLeverage int `json:"-"`
|
||||
Timeframes []string `json:"-"`
|
||||
}
|
||||
@@ -189,12 +191,23 @@ type OIDeltaData struct {
|
||||
|
||||
// StrategyEngine strategy execution engine
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
}
|
||||
|
||||
// NewStrategyEngine creates strategy execution engine
|
||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
||||
return &StrategyEngine{config: config}
|
||||
// Create NofxOS client with API key from config
|
||||
apiKey := config.Indicators.NofxOSAPIKey
|
||||
if apiKey == "" {
|
||||
apiKey = nofxos.DefaultAuthKey
|
||||
}
|
||||
client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey)
|
||||
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRiskControlConfig gets risk control configuration
|
||||
@@ -202,6 +215,19 @@ func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
||||
return e.config.RiskControl
|
||||
}
|
||||
|
||||
// GetLanguage returns the language from config or falls back to auto-detection
|
||||
func (e *StrategyEngine) GetLanguage() Language {
|
||||
switch e.config.Language {
|
||||
case "zh":
|
||||
return LangChinese
|
||||
case "en":
|
||||
return LangEnglish
|
||||
default:
|
||||
// Fall back to auto-detection from prompt content for backward compatibility
|
||||
return detectLanguage(e.config.PromptSections.RoleDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig gets complete strategy configuration
|
||||
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
||||
return e.config
|
||||
@@ -239,7 +265,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||
oiPositions, err := provider.GetOITopPositions()
|
||||
oiPositions, err := engine.nofxosClient.GetOITopPositions()
|
||||
if err == nil {
|
||||
for _, pos := range oiPositions {
|
||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||
@@ -385,13 +411,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
|
||||
coinSource := e.config.CoinSource
|
||||
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
}
|
||||
|
||||
switch coinSource.SourceType {
|
||||
case "static":
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
@@ -401,12 +420,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
|
||||
case "coinpool":
|
||||
// 检查 use_coin_pool 标志,如果为 false 则回退到静态币种
|
||||
if !coinSource.UseCoinPool {
|
||||
logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins")
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
|
||||
case "ai500":
|
||||
// 检查 use_ai500 标志,如果为 false 则回退到静态币种
|
||||
if !coinSource.UseAI500 {
|
||||
logger.Infof("⚠️ source_type is 'ai500' but use_ai500 is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
@@ -414,9 +434,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
return e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||
coins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_top":
|
||||
// 检查 use_oi_top 标志,如果为 false 则回退到静态币种
|
||||
@@ -429,15 +453,19 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
return e.getOITopCoins(coinSource.OITopLimit)
|
||||
coins, err := e.getOITopCoins(coinSource.OITopLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
if coinSource.UseCoinPool {
|
||||
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||
if coinSource.UseAI500 {
|
||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err)
|
||||
logger.Infof("⚠️ Failed to get AI500 coins: %v", err)
|
||||
} else {
|
||||
for _, coin := range poolCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
||||
@@ -471,19 +499,45 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: sources,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
|
||||
// filterExcludedCoins removes excluded coins from the candidates list
|
||||
func (e *StrategyEngine) filterExcludedCoins(candidates []CandidateCoin) []CandidateCoin {
|
||||
if len(e.config.CoinSource.ExcludedCoins) == 0 {
|
||||
return candidates
|
||||
}
|
||||
|
||||
// Build excluded set for O(1) lookup
|
||||
excluded := make(map[string]bool)
|
||||
for _, coin := range e.config.CoinSource.ExcludedCoins {
|
||||
normalized := market.Normalize(coin)
|
||||
excluded[normalized] = true
|
||||
}
|
||||
|
||||
// Filter out excluded coins
|
||||
filtered := make([]CandidateCoin, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
if !excluded[c.Symbol] {
|
||||
filtered = append(filtered, c)
|
||||
} else {
|
||||
logger.Infof("🚫 Excluded coin: %s", c.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
symbols, err := provider.GetTopRatedCoins(limit)
|
||||
symbols, err := e.nofxosClient.GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -503,7 +557,7 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
positions, err := provider.GetOITopPositions()
|
||||
positions, err := e.nofxosClient.GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -610,50 +664,82 @@ func extractJSONPath(data interface{}, path string) interface{} {
|
||||
|
||||
// FetchQuantData fetches quantitative data for a single coin
|
||||
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
||||
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
apiURL := e.config.Indicators.QuantDataAPIURL
|
||||
url := strings.Replace(apiURL, "{symbol}", symbol, -1)
|
||||
// Use nofxos client with unified API key
|
||||
include := "oi,price"
|
||||
if e.config.Indicators.EnableQuantNetflow {
|
||||
include = "netflow,oi,price"
|
||||
}
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(url, 10*time.Second)
|
||||
nofxosData, err := e.nofxosClient.GetCoinData(symbol, include)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("failed to fetch quant data: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
if nofxosData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Data *QuantData `json:"data"`
|
||||
// Convert nofxos.QuantData to kernel.QuantData
|
||||
quantData := &QuantData{
|
||||
Symbol: nofxosData.Symbol,
|
||||
Price: nofxosData.Price,
|
||||
PriceChange: nofxosData.PriceChange,
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
// Convert OI data
|
||||
if nofxosData.OI != nil {
|
||||
quantData.OI = make(map[string]*OIData)
|
||||
for exchange, oiData := range nofxosData.OI {
|
||||
if oiData != nil {
|
||||
kData := &OIData{
|
||||
CurrentOI: oiData.CurrentOI,
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
kData.Delta = make(map[string]*OIDeltaData)
|
||||
for dur, delta := range oiData.Delta {
|
||||
if delta != nil {
|
||||
kData.Delta[dur] = &OIDeltaData{
|
||||
OIDelta: delta.OIDelta,
|
||||
OIDeltaValue: delta.OIDeltaValue,
|
||||
OIDeltaPercent: delta.OIDeltaPercent,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quantData.OI[exchange] = kData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
return nil, fmt.Errorf("API returned error code: %d", apiResp.Code)
|
||||
// Convert Netflow data
|
||||
if nofxosData.Netflow != nil {
|
||||
quantData.Netflow = &NetflowData{}
|
||||
if nofxosData.Netflow.Institution != nil {
|
||||
quantData.Netflow.Institution = &FlowTypeData{
|
||||
Future: nofxosData.Netflow.Institution.Future,
|
||||
Spot: nofxosData.Netflow.Institution.Spot,
|
||||
}
|
||||
}
|
||||
if nofxosData.Netflow.Personal != nil {
|
||||
quantData.Netflow.Personal = &FlowTypeData{
|
||||
Future: nofxosData.Netflow.Personal.Future,
|
||||
Spot: nofxosData.Netflow.Personal.Spot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apiResp.Data, nil
|
||||
return quantData, nil
|
||||
}
|
||||
|
||||
// FetchQuantDataBatch batch fetches quantitative data
|
||||
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
||||
result := make(map[string]*QuantData)
|
||||
|
||||
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -672,28 +758,12 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
||||
}
|
||||
|
||||
// FetchOIRankingData fetches market-wide OI ranking data
|
||||
func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
||||
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableOIRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURL := indicators.OIRankingAPIURL
|
||||
if baseURL == "" {
|
||||
baseURL = "http://nofxaios.com:30006"
|
||||
}
|
||||
|
||||
// Get auth key from existing API URL or use default
|
||||
authKey := "cm_568c67eae410d912c54c"
|
||||
if indicators.QuantDataAPIURL != "" {
|
||||
if idx := strings.Index(indicators.QuantDataAPIURL, "auth="); idx != -1 {
|
||||
authKey = indicators.QuantDataAPIURL[idx+5:]
|
||||
if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 {
|
||||
authKey = authKey[:ampIdx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := indicators.OIRankingDuration
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
@@ -706,7 +776,7 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
||||
|
||||
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit)
|
||||
|
||||
data, err := provider.GetOIRankingData(baseURL, authKey, duration, limit)
|
||||
data, err := e.nofxosClient.GetOIRanking(duration, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
||||
return nil
|
||||
@@ -718,6 +788,68 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchNetFlowRankingData fetches market-wide NetFlow ranking data
|
||||
func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableNetFlowRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.NetFlowRankingDuration
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
|
||||
limit := indicators.NetFlowRankingLimit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
logger.Infof("💰 Fetching NetFlow ranking data (duration: %s, limit: %d)", duration, limit)
|
||||
|
||||
data, err := e.nofxosClient.GetNetFlowRanking(duration, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch NetFlow ranking data: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("✓ NetFlow ranking data ready: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d",
|
||||
len(data.InstitutionFutureTop), len(data.InstitutionFutureLow),
|
||||
len(data.PersonalFutureTop), len(data.PersonalFutureLow))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchPriceRankingData fetches market-wide price ranking data (gainers/losers)
|
||||
func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnablePriceRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
durations := indicators.PriceRankingDuration
|
||||
if durations == "" {
|
||||
durations = "1h"
|
||||
}
|
||||
|
||||
limit := indicators.PriceRankingLimit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
logger.Infof("📈 Fetching Price ranking data (durations: %s, limit: %d)", durations, limit)
|
||||
|
||||
data, err := e.nofxosClient.GetPriceRanking(durations, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Price ranking data: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("✓ Price ranking data ready for %d durations", len(data.Durations))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - System Prompt
|
||||
// ============================================================================
|
||||
@@ -729,7 +861,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
promptSections := e.config.PromptSections
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := detectLanguage(promptSections.RoleDefinition)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
@@ -920,7 +1052,7 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop {
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
}
|
||||
|
||||
@@ -976,8 +1108,8 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
|
||||
// Historical trading statistics (helps AI understand past performance)
|
||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||
// Detect language from strategy config
|
||||
lang := detectLanguage(e.config.PromptSections.RoleDefinition)
|
||||
// Get language from strategy config
|
||||
lang := e.GetLanguage()
|
||||
|
||||
// Win/Loss ratio
|
||||
var winLossRatio float64
|
||||
@@ -1081,9 +1213,25 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Get language for market data formatting
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if e.GetLanguage() == LangChinese {
|
||||
nofxosLang = nofxos.LangChinese
|
||||
}
|
||||
|
||||
// OI Ranking data (market-wide open interest changes)
|
||||
if ctx.OIRankingData != nil {
|
||||
sb.WriteString(provider.FormatOIRankingForAI(ctx.OIRankingData))
|
||||
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// NetFlow Ranking data (market-wide fund flow)
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatNetFlowRankingForAI(ctx.NetFlowRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// Price Ranking data (market-wide gainers/losers)
|
||||
if ctx.PriceRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatPriceRankingForAI(ctx.PriceRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
@@ -1,8 +1,9 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -89,11 +90,11 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
|
||||
// 7. OI排名数据(如果有)
|
||||
if ctx.OIRankingData != nil {
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatOIRankingZH(ctx.OIRankingData))
|
||||
} else {
|
||||
sb.WriteString(formatOIRankingEN(ctx.OIRankingData))
|
||||
nofxosLang = nofxos.LangChinese
|
||||
}
|
||||
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -354,11 +355,6 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIRankingZH 格式化OI排名数据(中文)
|
||||
func formatOIRankingZH(oiData interface{}) string {
|
||||
// TODO: 根据实际OIRankingData结构实现
|
||||
return "## 市场持仓量排名\n\n(数据加载中...)\n\n"
|
||||
}
|
||||
|
||||
// getOIInterpretationZH 获取OI变化解读(中文)
|
||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||
@@ -624,10 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIRankingEN 格式化OI排名数据(英文)
|
||||
func formatOIRankingEN(oiData interface{}) string {
|
||||
return "## Market-wide OI Ranking\n\n(Loading data...)\n\n"
|
||||
}
|
||||
|
||||
// getOIInterpretationEN 获取OI变化解读(英文)
|
||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import "fmt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"nofx/debate"
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
@@ -19,7 +19,7 @@ type TraderExecutorAdapter struct {
|
||||
}
|
||||
|
||||
// ExecuteDecision executes a trading decision
|
||||
func (a *TraderExecutorAdapter) ExecuteDecision(d *decision.Decision) error {
|
||||
func (a *TraderExecutorAdapter) ExecuteDecision(d *kernel.Decision) error {
|
||||
return a.autoTrader.ExecuteDecision(d)
|
||||
}
|
||||
|
||||
@@ -410,11 +410,18 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
||||
|
||||
// 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
|
||||
func (tm *TraderManager) RemoveTrader(traderID string) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
if _, exists := tm.traders[traderID]; exists {
|
||||
if t, exists := tm.traders[traderID]; exists {
|
||||
// Stop the trader if it's running (this ensures the goroutine exits)
|
||||
status := t.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
logger.Infof("⏹ Stopping trader %s before removing from memory...", traderID)
|
||||
t.Stop()
|
||||
}
|
||||
delete(tm.traders, traderID)
|
||||
logger.Infof("✓ Trader %s removed from memory", traderID)
|
||||
}
|
||||
@@ -606,7 +613,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to TraderManager (coinPoolURL/oiTopURL already obtained from strategy config)
|
||||
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
|
||||
@@ -641,7 +648,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
|
||||
}
|
||||
|
||||
// Build AutoTraderConfig (coinPoolURL/oiTopURL obtained from strategy config, used in StrategyEngine)
|
||||
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
|
||||
traderConfig := trader.AutoTraderConfig{
|
||||
ID: traderCfg.ID,
|
||||
Name: traderCfg.Name,
|
||||
@@ -664,6 +671,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
StrategyConfig: strategyConfig,
|
||||
}
|
||||
|
||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||
traderCfg.Name, traderCfg.ScanIntervalMinutes, traderConfig.ScanInterval)
|
||||
|
||||
// Set API keys based on exchange type (convert EncryptedString to string)
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"nofx/security"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AI500Config AI500 data provider configuration
|
||||
type AI500Config struct {
|
||||
APIURL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
var ai500Config = AI500Config{
|
||||
APIURL: "",
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// CoinData coin information
|
||||
type CoinData struct {
|
||||
Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT)
|
||||
Score float64 `json:"score"` // Current score
|
||||
StartTime int64 `json:"start_time"` // Start time (Unix timestamp)
|
||||
StartPrice float64 `json:"start_price"` // Start price
|
||||
LastScore float64 `json:"last_score"` // Latest score
|
||||
MaxScore float64 `json:"max_score"` // Highest score
|
||||
MaxPrice float64 `json:"max_price"` // Highest price
|
||||
IncreasePercent float64 `json:"increase_percent"` // Increase percentage
|
||||
IsAvailable bool `json:"-"` // Whether tradable (internal use)
|
||||
}
|
||||
|
||||
// AI500APIResponse raw data structure returned by AI500 API
|
||||
type AI500APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Coins []CoinData `json:"coins"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SetAI500API sets AI500 data provider API
|
||||
func SetAI500API(apiURL string) {
|
||||
ai500Config.APIURL = apiURL
|
||||
}
|
||||
|
||||
// SetOITopAPI sets OI Top API
|
||||
func SetOITopAPI(apiURL string) {
|
||||
oiTopConfig.APIURL = apiURL
|
||||
}
|
||||
|
||||
|
||||
// GetAI500Data retrieves AI500 coin list (with retry mechanism)
|
||||
func GetAI500Data() ([]CoinData, error) {
|
||||
// Check if API URL is configured
|
||||
if strings.TrimSpace(ai500Config.APIURL) == "" {
|
||||
return nil, fmt.Errorf("AI500 API URL not configured")
|
||||
}
|
||||
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
// Try to fetch from API
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
coins, err := fetchAI500()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
log.Printf("✓ Retry attempt %d succeeded", attempt)
|
||||
}
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("❌ Request attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all API requests failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// fetchAI500 actually executes AI500 request
|
||||
func fetchAI500() ([]CoinData, error) {
|
||||
log.Printf("🔄 Requesting AI500 data...")
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(ai500Config.APIURL, ai500Config.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request AI500 API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.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("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse API response
|
||||
var response AI500APIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
if len(response.Data.Coins) == 0 {
|
||||
return nil, fmt.Errorf("coin list is empty")
|
||||
}
|
||||
|
||||
// Set IsAvailable flag
|
||||
coins := response.Data.Coins
|
||||
for i := range coins {
|
||||
coins[i].IsAvailable = true
|
||||
}
|
||||
|
||||
log.Printf("✓ Successfully fetched %d coins", len(coins))
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
// GetAvailableCoins retrieves available coin list (filters out unavailable ones)
|
||||
func GetAvailableCoins() ([]string, error) {
|
||||
coins, err := GetAI500Data()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
symbol := normalizeSymbol(coin.Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetTopRatedCoins retrieves top N coins by score (sorted by score descending)
|
||||
func GetTopRatedCoins(limit int) ([]string, error) {
|
||||
coins, err := GetAI500Data()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter available coins
|
||||
var availableCoins []CoinData
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
availableCoins = append(availableCoins, coin)
|
||||
}
|
||||
}
|
||||
|
||||
if len(availableCoins) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
// Sort by Score descending (bubble sort)
|
||||
for i := 0; i < len(availableCoins); i++ {
|
||||
for j := i + 1; j < len(availableCoins); j++ {
|
||||
if availableCoins[i].Score < availableCoins[j].Score {
|
||||
availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top N
|
||||
maxCount := limit
|
||||
if len(availableCoins) < maxCount {
|
||||
maxCount = len(availableCoins)
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for i := 0; i < maxCount; i++ {
|
||||
symbol := normalizeSymbol(availableCoins[i].Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol normalizes coin symbol
|
||||
func normalizeSymbol(symbol string) string {
|
||||
symbol = trimSpaces(symbol)
|
||||
symbol = toUpper(symbol)
|
||||
if !endsWith(symbol, "USDT") {
|
||||
symbol = symbol + "USDT"
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func trimSpaces(s string) string {
|
||||
result := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != ' ' {
|
||||
result += string(s[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toUpper(s string) string {
|
||||
result := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c = c - 'a' + 'A'
|
||||
}
|
||||
result += string(c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func endsWith(s, suffix string) bool {
|
||||
if len(s) < len(suffix) {
|
||||
return false
|
||||
}
|
||||
return s[len(s)-len(suffix):] == suffix
|
||||
}
|
||||
|
||||
|
||||
// ========== OI Top (Open Interest Growth Top 20) Data ==========
|
||||
|
||||
// OIPosition open interest data
|
||||
type OIPosition struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Rank int `json:"rank"`
|
||||
CurrentOI float64 `json:"current_oi"`
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaPercent float64 `json:"oi_delta_percent"`
|
||||
OIDeltaValue float64 `json:"oi_delta_value"`
|
||||
PriceDeltaPercent float64 `json:"price_delta_percent"`
|
||||
NetLong float64 `json:"net_long"`
|
||||
NetShort float64 `json:"net_short"`
|
||||
}
|
||||
|
||||
// OITopAPIResponse data structure returned by OI Top API
|
||||
type OITopAPIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Positions []OIPosition `json:"positions"`
|
||||
Count int `json:"count"`
|
||||
Exchange string `json:"exchange"`
|
||||
TimeRange string `json:"time_range"`
|
||||
TimeRangeParam string `json:"time_range_param"`
|
||||
RankType string `json:"rank_type"`
|
||||
Limit int `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
var oiTopConfig = struct {
|
||||
APIURL string
|
||||
Timeout time.Duration
|
||||
}{
|
||||
APIURL: "",
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// GetOITopPositions retrieves OI Top 20 data (with retry)
|
||||
func GetOITopPositions() ([]OIPosition, error) {
|
||||
if strings.TrimSpace(oiTopConfig.APIURL) == "" {
|
||||
log.Printf("⚠️ OI Top API URL not configured, skipping OI Top data fetch")
|
||||
return []OIPosition{}, nil
|
||||
}
|
||||
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("⚠️ Retry attempt %d of %d to fetch OI Top data...", attempt, maxRetries)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
positions, err := fetchOITop()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
log.Printf("✓ Retry attempt %d succeeded", attempt)
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("❌ OI Top request attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
log.Printf("⚠️ All OI Top API requests failed (last error: %v), skipping OI Top data", lastErr)
|
||||
return []OIPosition{}, nil
|
||||
}
|
||||
|
||||
// fetchOITop actually executes OI Top request
|
||||
func fetchOITop() ([]OIPosition, error) {
|
||||
log.Printf("🔄 Requesting OI Top data...")
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(oiTopConfig.APIURL, oiTopConfig.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request OI Top API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read OI Top response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OI Top API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var response OITopAPIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("OI Top JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, fmt.Errorf("OI Top API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
if len(response.Data.Positions) == 0 {
|
||||
return nil, fmt.Errorf("OI Top position list is empty")
|
||||
}
|
||||
|
||||
log.Printf("✓ Successfully fetched %d OI Top coins (time range: %s, type: %s)",
|
||||
len(response.Data.Positions), response.Data.TimeRange, response.Data.RankType)
|
||||
return response.Data.Positions, nil
|
||||
}
|
||||
|
||||
// GetOITopSymbols retrieves OI Top coin symbol list
|
||||
func GetOITopSymbols() ([]string, error) {
|
||||
positions, err := GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := normalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// MergedData merged data (AI500 + OI Top)
|
||||
type MergedData struct {
|
||||
AI500Coins []CoinData
|
||||
OITopCoins []OIPosition
|
||||
AllSymbols []string
|
||||
SymbolSources map[string][]string
|
||||
}
|
||||
|
||||
// OIRankingData OI ranking data for debate (includes both top and low)
|
||||
type OIRankingData struct {
|
||||
TimeRange string `json:"time_range"`
|
||||
Duration string `json:"duration"`
|
||||
TopPositions []OIPosition `json:"top_positions"`
|
||||
LowPositions []OIPosition `json:"low_positions"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetOIRankingData retrieves OI ranking data (both top increase and low decrease)
|
||||
func GetOIRankingData(baseURL, authKey string, duration string, limit int) (*OIRankingData, error) {
|
||||
if baseURL == "" || authKey == "" {
|
||||
return nil, fmt.Errorf("OI API URL or auth key not configured")
|
||||
}
|
||||
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
result := &OIRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch top ranking
|
||||
topURL := fmt.Sprintf("%s/api/oi/top-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
|
||||
topPositions, timeRange, err := fetchOIRanking(topURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
|
||||
} else {
|
||||
result.TopPositions = topPositions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch low ranking
|
||||
lowURL := fmt.Sprintf("%s/api/oi/low-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
|
||||
lowPositions, _, err := fetchOIRanking(lowURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
|
||||
} else {
|
||||
result.LowPositions = lowPositions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
|
||||
len(result.TopPositions), len(result.LowPositions), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchOIRanking fetches OI ranking from a single endpoint
|
||||
func fetchOIRanking(url string) ([]OIPosition, string, error) {
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(url, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.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("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var response OITopAPIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data.Positions, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 📊 市场持仓量变化数据 (Open Interest Changes in %s / %s)\n\n", data.TimeRange, data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 🔺 持仓量增加排行 (OI Increase Ranking)\n")
|
||||
sb.WriteString("市场资金正在流入以下币种,可能表示趋势延续或新仓位建立:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 |\n")
|
||||
sb.WriteString("|------|------|------------------|----------|----------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank,
|
||||
pos.Symbol,
|
||||
formatOIValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent,
|
||||
pos.PriceDeltaPercent,
|
||||
))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**解读**: 持仓增加 + 价格上涨 = 多头主导; 持仓增加 + 价格下跌 = 空头主导\n\n")
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 🔻 持仓量减少排行 (OI Decrease Ranking)\n")
|
||||
sb.WriteString("市场资金正在流出以下币种,可能表示趋势反转或仓位平仓:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 |\n")
|
||||
sb.WriteString("|------|------|------------------|----------|----------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank,
|
||||
pos.Symbol,
|
||||
formatOIValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent,
|
||||
pos.PriceDeltaPercent,
|
||||
))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**解读**: 持仓减少 + 价格上涨 = 空头平仓(反弹); 持仓减少 + 价格下跌 = 多头平仓(回调)\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIValue formats OI value for display
|
||||
func formatOIValue(v float64) string {
|
||||
sign := ""
|
||||
if v >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
absV := v
|
||||
if absV < 0 {
|
||||
absV = -absV
|
||||
}
|
||||
if absV >= 1e9 {
|
||||
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||
} else if absV >= 1e6 {
|
||||
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||
} else if absV >= 1e3 {
|
||||
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", sign, v)
|
||||
}
|
||||
|
||||
// GetMergedData retrieves merged data (AI500 + OI Top, deduplicated)
|
||||
func GetMergedData(ai500Limit int) (*MergedData, error) {
|
||||
ai500TopSymbols, err := GetTopRatedCoins(ai500Limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to get AI500 data: %v", err)
|
||||
ai500TopSymbols = []string{}
|
||||
}
|
||||
|
||||
oiTopSymbols, err := GetOITopSymbols()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to get OI Top data: %v", err)
|
||||
oiTopSymbols = []string{}
|
||||
}
|
||||
|
||||
symbolSet := make(map[string]bool)
|
||||
symbolSources := make(map[string][]string)
|
||||
|
||||
for _, symbol := range ai500TopSymbols {
|
||||
symbolSet[symbol] = true
|
||||
symbolSources[symbol] = append(symbolSources[symbol], "ai500")
|
||||
}
|
||||
|
||||
for _, symbol := range oiTopSymbols {
|
||||
if !symbolSet[symbol] {
|
||||
symbolSet[symbol] = true
|
||||
}
|
||||
symbolSources[symbol] = append(symbolSources[symbol], "oi_top")
|
||||
}
|
||||
|
||||
var allSymbols []string
|
||||
for symbol := range symbolSet {
|
||||
allSymbols = append(allSymbols, symbol)
|
||||
}
|
||||
|
||||
ai500Coins, _ := GetAI500Data()
|
||||
oiTopPositions, _ := GetOITopPositions()
|
||||
|
||||
merged := &MergedData{
|
||||
AI500Coins: ai500Coins,
|
||||
OITopCoins: oiTopPositions,
|
||||
AllSymbols: allSymbols,
|
||||
SymbolSources: symbolSources,
|
||||
}
|
||||
|
||||
log.Printf("📊 Data merge complete: AI500=%d, OI_Top=%d, Total(deduplicated)=%d",
|
||||
len(ai500TopSymbols), len(oiTopSymbols), len(allSymbols))
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ========== Backward Compatibility Aliases ==========
|
||||
|
||||
// Deprecated: Use SetAI500API instead
|
||||
func SetCoinPoolAPI(apiURL string) {
|
||||
SetAI500API(apiURL)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetAI500Data instead
|
||||
func GetCoinPool() ([]CoinData, error) {
|
||||
return GetAI500Data()
|
||||
}
|
||||
|
||||
// Deprecated: Use MergedData instead
|
||||
type MergedCoinPool = MergedData
|
||||
|
||||
// Deprecated: Use GetMergedData instead
|
||||
func GetMergedCoinPool(ai500Limit int) (*MergedData, error) {
|
||||
return GetMergedData(ai500Limit)
|
||||
}
|
||||
|
||||
// Deprecated: Use CoinData instead
|
||||
type CoinInfo = CoinData
|
||||
163
provider/nofxos/ai500.go
Normal file
163
provider/nofxos/ai500.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoinData represents AI500 coin information
|
||||
type CoinData struct {
|
||||
Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT)
|
||||
Score float64 `json:"score"` // Current AI score (0-100)
|
||||
StartTime int64 `json:"start_time"` // Start time (Unix timestamp)
|
||||
StartPrice float64 `json:"start_price"` // Start price
|
||||
LastScore float64 `json:"last_score"` // Latest score
|
||||
MaxScore float64 `json:"max_score"` // Highest score
|
||||
MaxPrice float64 `json:"max_price"` // Highest price
|
||||
IncreasePercent float64 `json:"increase_percent"` // Increase percentage (already x100)
|
||||
IsAvailable bool `json:"-"` // Whether tradable (internal use)
|
||||
}
|
||||
|
||||
// AI500Response is the API response structure
|
||||
type AI500Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Coins []CoinData `json:"coins"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// GetAI500List retrieves AI500 coin list with retry mechanism
|
||||
func (c *Client) GetAI500List() ([]CoinData, error) {
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
coins, err := c.fetchAI500()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
log.Printf("✓ Retry attempt %d succeeded", attempt)
|
||||
}
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("❌ AI500 request attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all AI500 API requests failed: %w", lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) fetchAI500() ([]CoinData, error) {
|
||||
log.Printf("🔄 Requesting AI500 data from %s...", c.GetBaseURL())
|
||||
|
||||
body, err := c.doRequest("/api/ai500/list")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request AI500 API: %w", err)
|
||||
}
|
||||
|
||||
var response AI500Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
if len(response.Data.Coins) == 0 {
|
||||
return nil, fmt.Errorf("coin list is empty")
|
||||
}
|
||||
|
||||
// Set IsAvailable flag
|
||||
coins := response.Data.Coins
|
||||
for i := range coins {
|
||||
coins[i].IsAvailable = true
|
||||
}
|
||||
|
||||
log.Printf("✓ Successfully fetched %d AI500 coins", len(coins))
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
// GetTopRatedCoins retrieves top N coins by score (sorted descending)
|
||||
func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
|
||||
coins, err := c.GetAI500List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter available coins
|
||||
var availableCoins []CoinData
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
availableCoins = append(availableCoins, coin)
|
||||
}
|
||||
}
|
||||
|
||||
if len(availableCoins) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
// Sort by Score descending (bubble sort)
|
||||
for i := 0; i < len(availableCoins); i++ {
|
||||
for j := i + 1; j < len(availableCoins); j++ {
|
||||
if availableCoins[i].Score < availableCoins[j].Score {
|
||||
availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top N
|
||||
maxCount := limit
|
||||
if len(availableCoins) < maxCount {
|
||||
maxCount = len(availableCoins)
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for i := 0; i < maxCount; i++ {
|
||||
symbol := NormalizeSymbol(availableCoins[i].Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetAvailableCoins retrieves all available coin symbols
|
||||
func (c *Client) GetAvailableCoins() ([]string, error) {
|
||||
coins, err := c.GetAI500List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
symbol := NormalizeSymbol(coin.Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// NormalizeSymbol normalizes coin symbol to XXXUSDT format
|
||||
func NormalizeSymbol(symbol string) string {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
symbol = strings.ToUpper(symbol)
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol = symbol + "USDT"
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
146
provider/nofxos/client.go
Normal file
146
provider/nofxos/client.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package nofxos provides data access to the NofxOS API (https://nofxos.ai)
|
||||
// for quantitative trading data including AI500 scores, OI rankings,
|
||||
// fund flow (NetFlow), price rankings, and coin details.
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"nofx/security"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default configuration
|
||||
const (
|
||||
DefaultBaseURL = "https://nofxos.ai"
|
||||
DefaultTimeout = 30 * time.Second
|
||||
DefaultAuthKey = "cm_568c67eae410d912c54c"
|
||||
)
|
||||
|
||||
// Client is the NofxOS API client
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
AuthKey string
|
||||
Timeout time.Duration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
defaultClient *Client
|
||||
clientOnce sync.Once
|
||||
)
|
||||
|
||||
// DefaultClient returns the singleton default client
|
||||
func DefaultClient() *Client {
|
||||
clientOnce.Do(func() {
|
||||
defaultClient = &Client{
|
||||
BaseURL: DefaultBaseURL,
|
||||
AuthKey: DefaultAuthKey,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
})
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// NewClient creates a new NofxOS API client
|
||||
func NewClient(baseURL, authKey string) *Client {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
if authKey == "" {
|
||||
authKey = DefaultAuthKey
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
AuthKey: authKey,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfig updates client configuration
|
||||
func (c *Client) SetConfig(baseURL, authKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if baseURL != "" {
|
||||
c.BaseURL = baseURL
|
||||
}
|
||||
if authKey != "" {
|
||||
c.AuthKey = authKey
|
||||
}
|
||||
}
|
||||
|
||||
// GetBaseURL returns the current base URL
|
||||
func (c *Client) GetBaseURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
// GetAuthKey returns the current auth key
|
||||
func (c *Client) GetAuthKey() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.AuthKey
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP GET request with authentication
|
||||
func (c *Client) doRequest(endpoint string) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
baseURL := c.BaseURL
|
||||
authKey := c.AuthKey
|
||||
timeout := c.Timeout
|
||||
c.mu.RUnlock()
|
||||
|
||||
url := baseURL + endpoint
|
||||
if !strings.Contains(url, "auth=") {
|
||||
if strings.Contains(url, "?") {
|
||||
url += "&auth=" + authKey
|
||||
} else {
|
||||
url += "?auth=" + authKey
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := security.SafeGet(url, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return body, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// APIError represents an API error response
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ExtractAuthKey extracts auth key from a URL string
|
||||
func ExtractAuthKey(url string) string {
|
||||
if idx := strings.Index(url, "auth="); idx != -1 {
|
||||
authKey := url[idx+5:]
|
||||
if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 {
|
||||
authKey = authKey[:ampIdx]
|
||||
}
|
||||
return authKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
216
provider/nofxos/coin.go
Normal file
216
provider/nofxos/coin.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QuantData represents quantitative data for a single coin
|
||||
type QuantData struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Price float64 `json:"price"`
|
||||
Netflow *NetflowData `json:"netflow,omitempty"`
|
||||
OI map[string]*OIData `json:"oi,omitempty"` // keyed by exchange: "binance", "bybit"
|
||||
PriceChange map[string]float64 `json:"price_change,omitempty"` // keyed by duration: "1h", "4h", etc.
|
||||
}
|
||||
|
||||
// NetflowData contains fund flow data
|
||||
type NetflowData struct {
|
||||
Institution *FlowTypeData `json:"institution,omitempty"`
|
||||
Personal *FlowTypeData `json:"personal,omitempty"`
|
||||
}
|
||||
|
||||
// FlowTypeData contains flow data by trade type
|
||||
type FlowTypeData struct {
|
||||
Future map[string]float64 `json:"future,omitempty"` // keyed by duration
|
||||
Spot map[string]float64 `json:"spot,omitempty"` // keyed by duration
|
||||
}
|
||||
|
||||
// OIData contains open interest data for an exchange
|
||||
type OIData struct {
|
||||
CurrentOI float64 `json:"current_oi"`
|
||||
NetLong float64 `json:"net_long"`
|
||||
NetShort float64 `json:"net_short"`
|
||||
Delta map[string]*OIDeltaData `json:"delta,omitempty"` // keyed by duration
|
||||
}
|
||||
|
||||
// OIDeltaData contains OI change data
|
||||
type OIDeltaData struct {
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaValue float64 `json:"oi_delta_value"`
|
||||
OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100
|
||||
}
|
||||
|
||||
// CoinResponse is the API response structure for coin details
|
||||
type CoinResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data *QuantData `json:"data"`
|
||||
}
|
||||
|
||||
// GetCoinData retrieves quantitative data for a single coin
|
||||
func (c *Client) GetCoinData(symbol string, include string) (*QuantData, error) {
|
||||
if symbol == "" {
|
||||
return nil, fmt.Errorf("symbol is required")
|
||||
}
|
||||
|
||||
if include == "" {
|
||||
include = "netflow,oi,price"
|
||||
}
|
||||
|
||||
// Normalize symbol (remove USDT suffix for API call if needed)
|
||||
symbol = strings.TrimSuffix(strings.ToUpper(symbol), "USDT")
|
||||
|
||||
endpoint := fmt.Sprintf("/api/coin/%s?include=%s", symbol, include)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response CoinResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for success (support both success field and code field)
|
||||
if !response.Success && response.Code != 0 {
|
||||
return nil, fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// GetCoinDataBatch retrieves quantitative data for multiple coins
|
||||
func (c *Client) GetCoinDataBatch(symbols []string, include string) map[string]*QuantData {
|
||||
result := make(map[string]*QuantData)
|
||||
|
||||
for _, symbol := range symbols {
|
||||
data, err := c.GetCoinData(symbol, include)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch coin data for %s: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
if data != nil {
|
||||
// Use normalized symbol as key
|
||||
normalizedSymbol := NormalizeSymbol(symbol)
|
||||
result[normalizedSymbol] = data
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatQuantDataForAI formats single coin quant data for AI consumption
|
||||
func FormatQuantDataForAI(symbol string, data *QuantData, lang Language) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatQuantDataZH(symbol, data)
|
||||
}
|
||||
return formatQuantDataEN(symbol, data)
|
||||
}
|
||||
|
||||
func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 量化数据\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("价格: $%.4f\n\n", data.Price))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("**价格变化**:\n")
|
||||
durations := []string{"1h", "4h", "8h", "12h", "24h"}
|
||||
for _, d := range durations {
|
||||
if change, ok := data.PriceChange[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if oiData != nil {
|
||||
sb.WriteString(fmt.Sprintf("**%s持仓**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("- OI: %.2f\n", oiData.CurrentOI))
|
||||
if oiData.NetLong > 0 || oiData.NetShort > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- 多头: %.2f, 空头: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 1h变化: %s (%.2f%%)\n",
|
||||
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
|
||||
sb.WriteString("**机构资金流**:\n")
|
||||
durations := []string{"1h", "4h", "24h"}
|
||||
for _, d := range durations {
|
||||
if flow, ok := data.Netflow.Institution.Future[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatQuantDataEN(symbol string, data *QuantData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s Quant Data\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("Price: $%.4f\n\n", data.Price))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("**Price Change**:\n")
|
||||
durations := []string{"1h", "4h", "8h", "12h", "24h"}
|
||||
for _, d := range durations {
|
||||
if change, ok := data.PriceChange[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if oiData != nil {
|
||||
sb.WriteString(fmt.Sprintf("**%s OI**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("- Current OI: %.2f\n", oiData.CurrentOI))
|
||||
if oiData.NetLong > 0 || oiData.NetShort > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- Net Long: %.2f, Net Short: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %s (%.2f%%)\n",
|
||||
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
|
||||
sb.WriteString("**Institution Fund Flow**:\n")
|
||||
durations := []string{"1h", "4h", "24h"}
|
||||
for _, d := range durations {
|
||||
if flow, ok := data.Netflow.Institution.Future[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
263
provider/nofxos/netflow.go
Normal file
263
provider/nofxos/netflow.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NetFlowPosition represents fund flow data for a single coin
|
||||
type NetFlowPosition struct {
|
||||
Rank int `json:"rank"`
|
||||
Symbol string `json:"symbol"`
|
||||
Amount float64 `json:"amount"` // Fund flow amount in USDT (positive=inflow, negative=outflow)
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
// NetFlowResponse is the API response structure
|
||||
type NetFlowResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Netflows []NetFlowPosition `json:"netflows"`
|
||||
Count int `json:"count"`
|
||||
Type string `json:"type"` // institution or personal
|
||||
Trade string `json:"trade"` // 合约 or 现货
|
||||
TimeRange string `json:"time_range"`
|
||||
RankType string `json:"rank_type"` // top or low
|
||||
Limit int `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NetFlowRankingData contains institution and personal fund flow rankings
|
||||
type NetFlowRankingData struct {
|
||||
Duration string `json:"duration"`
|
||||
TimeRange string `json:"time_range"`
|
||||
InstitutionFutureTop []NetFlowPosition `json:"institution_future_top"`
|
||||
InstitutionFutureLow []NetFlowPosition `json:"institution_future_low"`
|
||||
PersonalFutureTop []NetFlowPosition `json:"personal_future_top"`
|
||||
PersonalFutureLow []NetFlowPosition `json:"personal_future_low"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetNetFlowRanking retrieves NetFlow ranking data (institution/personal, top/low)
|
||||
func (c *Client) GetNetFlowRanking(duration string, limit int) (*NetFlowRankingData, error) {
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
result := &NetFlowRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch institution futures top (inflow)
|
||||
positions, timeRange, err := c.fetchNetFlowRanking("top", duration, limit, "institution", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch institution future inflow ranking: %v", err)
|
||||
} else {
|
||||
result.InstitutionFutureTop = positions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch institution futures low (outflow)
|
||||
positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "institution", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch institution future outflow ranking: %v", err)
|
||||
} else {
|
||||
result.InstitutionFutureLow = positions
|
||||
}
|
||||
|
||||
// Fetch personal futures top (retail inflow)
|
||||
positions, _, err = c.fetchNetFlowRanking("top", duration, limit, "personal", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch personal future inflow ranking: %v", err)
|
||||
} else {
|
||||
result.PersonalFutureTop = positions
|
||||
}
|
||||
|
||||
// Fetch personal futures low (retail outflow)
|
||||
positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "personal", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch personal future outflow ranking: %v", err)
|
||||
} else {
|
||||
result.PersonalFutureLow = positions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched NetFlow ranking data: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d (duration: %s)",
|
||||
len(result.InstitutionFutureTop), len(result.InstitutionFutureLow),
|
||||
len(result.PersonalFutureTop), len(result.PersonalFutureLow), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchNetFlowRanking(rankType, duration string, limit int, flowType, trade string) ([]NetFlowPosition, string, error) {
|
||||
endpoint := fmt.Sprintf("/api/netflow/%s-ranking?limit=%d&duration=%s&type=%s&trade=%s",
|
||||
rankType, limit, duration, flowType, trade)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response NetFlowResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, "", fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
return response.Data.Netflows, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// FormatNetFlowRankingForAI formats NetFlow ranking data for AI consumption
|
||||
func FormatNetFlowRankingForAI(data *NetFlowRankingData, lang Language) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatNetFlowRankingZH(data)
|
||||
}
|
||||
return formatNetFlowRankingEN(data)
|
||||
}
|
||||
|
||||
func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 资金流向排行 (%s)\n\n", data.Duration))
|
||||
|
||||
// Institution inflow
|
||||
if len(data.InstitutionFutureTop) > 0 {
|
||||
sb.WriteString("### 机构资金流入榜\n")
|
||||
sb.WriteString("Smart Money买入信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流入金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureTop {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Institution outflow
|
||||
if len(data.InstitutionFutureLow) > 0 {
|
||||
sb.WriteString("### 机构资金流出榜\n")
|
||||
sb.WriteString("Smart Money卖出信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流出金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureLow {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Retail flow summary
|
||||
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("### 散户资金动向\n")
|
||||
if len(data.PersonalFutureTop) > 0 {
|
||||
sb.WriteString("散户买入: ")
|
||||
for i, pos := range data.PersonalFutureTop {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("散户卖出: ")
|
||||
for i, pos := range data.PersonalFutureLow {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 机构买入+散户卖出=强烈看多 | 机构卖出+散户买入=强烈看空\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatNetFlowRankingEN(data *NetFlowRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Fund Flow Ranking (%s)\n\n", data.Duration))
|
||||
|
||||
// Institution inflow
|
||||
if len(data.InstitutionFutureTop) > 0 {
|
||||
sb.WriteString("### Institution Inflow\n")
|
||||
sb.WriteString("Smart Money buying signals:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Inflow (USDT) | Price |\n")
|
||||
sb.WriteString("|------|--------|---------------|-------|\n")
|
||||
for _, pos := range data.InstitutionFutureTop {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Institution outflow
|
||||
if len(data.InstitutionFutureLow) > 0 {
|
||||
sb.WriteString("### Institution Outflow\n")
|
||||
sb.WriteString("Smart Money selling signals:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Outflow (USDT) | Price |\n")
|
||||
sb.WriteString("|------|--------|----------------|-------|\n")
|
||||
for _, pos := range data.InstitutionFutureLow {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Retail flow summary
|
||||
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("### Retail Flow\n")
|
||||
if len(data.PersonalFutureTop) > 0 {
|
||||
sb.WriteString("Retail buying: ")
|
||||
for i, pos := range data.PersonalFutureTop {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("Retail selling: ")
|
||||
for i, pos := range data.PersonalFutureLow {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**Key**: Institution buy + Retail sell = Strong bullish | Institution sell + Retail buy = Strong bearish\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
212
provider/nofxos/oi.go
Normal file
212
provider/nofxos/oi.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OIPosition represents open interest data for a single coin
|
||||
type OIPosition struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Rank int `json:"rank"`
|
||||
Price float64 `json:"price"`
|
||||
CurrentOI float64 `json:"current_oi"`
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100 (5.0 = 5%)
|
||||
OIDeltaValue float64 `json:"oi_delta_value"` // USDT value
|
||||
PriceDeltaPercent float64 `json:"price_delta_percent"` // Already x100 (5.0 = 5%)
|
||||
NetLong float64 `json:"net_long"`
|
||||
NetShort float64 `json:"net_short"`
|
||||
}
|
||||
|
||||
// OIRankingResponse is the API response structure for OI ranking
|
||||
type OIRankingResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Positions []OIPosition `json:"positions"`
|
||||
Count int `json:"count"`
|
||||
Exchange string `json:"exchange"`
|
||||
TimeRange string `json:"time_range"`
|
||||
TimeRangeParam string `json:"time_range_param"`
|
||||
RankType string `json:"rank_type"`
|
||||
Limit int `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// OIRankingData contains both top and low OI rankings
|
||||
type OIRankingData struct {
|
||||
TimeRange string `json:"time_range"`
|
||||
Duration string `json:"duration"`
|
||||
TopPositions []OIPosition `json:"top_positions"`
|
||||
LowPositions []OIPosition `json:"low_positions"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetOIRanking retrieves OI ranking data (both top increase and low decrease)
|
||||
func (c *Client) GetOIRanking(duration string, limit int) (*OIRankingData, error) {
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
result := &OIRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch top ranking (OI increase)
|
||||
topPositions, timeRange, err := c.fetchOIRanking("top", duration, limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
|
||||
} else {
|
||||
result.TopPositions = topPositions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch low ranking (OI decrease)
|
||||
lowPositions, _, err := c.fetchOIRanking("low", duration, limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
|
||||
} else {
|
||||
result.LowPositions = lowPositions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
|
||||
len(result.TopPositions), len(result.LowPositions), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosition, string, error) {
|
||||
endpoint := fmt.Sprintf("/api/oi/%s-ranking?limit=%d&duration=%s", rankType, limit, duration)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response OIRankingResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for success (support both success field and code field)
|
||||
if !response.Success && response.Code != 0 {
|
||||
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data.Positions, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
|
||||
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
|
||||
data, err := c.GetOIRanking("1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.TopPositions, nil
|
||||
}
|
||||
|
||||
// GetOITopSymbols retrieves OI top coin symbol list
|
||||
func (c *Client) GetOITopSymbols() ([]string, error) {
|
||||
positions, err := c.GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := NormalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatOIRankingZH(data)
|
||||
}
|
||||
return formatOIRankingEN(data)
|
||||
}
|
||||
|
||||
func formatOIRankingZH(data *OIRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 持仓量变化排行 (%s)\n\n", data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 持仓增加榜\n")
|
||||
sb.WriteString("资金流入,趋势延续或新仓建立信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 持仓减少榜\n")
|
||||
sb.WriteString("资金流出,趋势反转或仓位平仓信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: OI增+价涨=多头主导 | OI增+价跌=空头主导 | OI减+价涨=空头平仓 | OI减+价跌=多头平仓\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatOIRankingEN(data *OIRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Open Interest Changes (%s)\n\n", data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### OI Increase Ranking\n")
|
||||
sb.WriteString("Capital inflow signals - trend continuation or new positions:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n")
|
||||
sb.WriteString("|------|--------|------------------|-------------|----------------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### OI Decrease Ranking\n")
|
||||
sb.WriteString("Capital outflow signals - trend reversal or position closing:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n")
|
||||
sb.WriteString("|------|--------|------------------|-------------|----------------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**Key**: OI up + Price up = Bulls dominant | OI up + Price down = Bears dominant | OI down + Price up = Short covering | OI down + Price down = Long liquidation\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
182
provider/nofxos/price.go
Normal file
182
provider/nofxos/price.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PriceRankingItem represents single coin price ranking data
|
||||
type PriceRankingItem struct {
|
||||
Pair string `json:"pair"`
|
||||
Symbol string `json:"symbol"`
|
||||
PriceDelta float64 `json:"price_delta"` // Decimal format: 0.0723 = 7.23%
|
||||
Price float64 `json:"price"`
|
||||
FutureFlow float64 `json:"future_flow"`
|
||||
SpotFlow float64 `json:"spot_flow"`
|
||||
OI float64 `json:"oi"`
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaValue float64 `json:"oi_delta_value"`
|
||||
}
|
||||
|
||||
// PriceRankingDuration contains top gainers and losers for a single duration
|
||||
type PriceRankingDuration struct {
|
||||
Top []PriceRankingItem `json:"top"`
|
||||
Low []PriceRankingItem `json:"low"`
|
||||
}
|
||||
|
||||
// PriceRankingResponse is the API response structure
|
||||
type PriceRankingResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Durations []string `json:"durations"`
|
||||
Limit int `json:"limit"`
|
||||
Data map[string]PriceRankingDuration `json:"data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// PriceRankingData contains price ranking data for multiple durations
|
||||
type PriceRankingData struct {
|
||||
Durations map[string]*PriceRankingDuration `json:"durations"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetPriceRanking retrieves price ranking data (gainers/losers)
|
||||
func (c *Client) GetPriceRanking(durations string, limit int) (*PriceRankingData, error) {
|
||||
if durations == "" {
|
||||
durations = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/api/price/ranking?duration=%s&limit=%d", durations, limit)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response PriceRankingResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
result := &PriceRankingData{
|
||||
Durations: make(map[string]*PriceRankingDuration),
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
for duration, data := range response.Data.Data {
|
||||
d := data // Create a copy to avoid pointer issues
|
||||
result.Durations[duration] = &d
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched Price ranking data for %d durations", len(result.Durations))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FormatPriceRankingForAI formats Price ranking data for AI consumption
|
||||
func FormatPriceRankingForAI(data *PriceRankingData, lang Language) string {
|
||||
if data == nil || len(data.Durations) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatPriceRankingZH(data)
|
||||
}
|
||||
return formatPriceRankingEN(data)
|
||||
}
|
||||
|
||||
func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## 涨跌幅排行\n\n")
|
||||
|
||||
durationOrder := []string{"1h", "4h", "24h"}
|
||||
for _, duration := range durationOrder {
|
||||
durationData, exists := data.Durations[duration]
|
||||
if !exists || durationData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 涨跌幅\n\n", duration))
|
||||
|
||||
if len(durationData.Top) > 0 {
|
||||
sb.WriteString("**涨幅榜**\n")
|
||||
sb.WriteString("| 币种 | 涨幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Top {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(durationData.Low) > 0 {
|
||||
sb.WriteString("**跌幅榜**\n")
|
||||
sb.WriteString("| 币种 | 跌幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Low {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 涨幅大+资金流入+OI增加=强势上涨 | 跌幅大+资金流出+OI减少=弱势下跌\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatPriceRankingEN(data *PriceRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Price Gainers/Losers\n\n")
|
||||
|
||||
durationOrder := []string{"1h", "4h", "24h"}
|
||||
for _, duration := range durationOrder {
|
||||
durationData, exists := data.Durations[duration]
|
||||
if !exists || durationData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s Price Change\n\n", duration))
|
||||
|
||||
if len(durationData.Top) > 0 {
|
||||
sb.WriteString("**Top Gainers**\n")
|
||||
sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n")
|
||||
sb.WriteString("|--------|--------|-------|-----------|----------|\n")
|
||||
for _, item := range durationData.Top {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(durationData.Low) > 0 {
|
||||
sb.WriteString("**Top Losers**\n")
|
||||
sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n")
|
||||
sb.WriteString("|--------|--------|-------|-----------|----------|\n")
|
||||
for _, item := range durationData.Low {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("**Key**: Big gain + Fund inflow + OI increase = Strong bullish | Big loss + Fund outflow + OI decrease = Strong bearish\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
31
provider/nofxos/util.go
Normal file
31
provider/nofxos/util.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package nofxos
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Language represents the language for formatting output
|
||||
type Language string
|
||||
|
||||
const (
|
||||
LangChinese Language = "zh-CN"
|
||||
LangEnglish Language = "en-US"
|
||||
)
|
||||
|
||||
// formatValue formats a numeric value with sign and appropriate suffix
|
||||
func formatValue(v float64) string {
|
||||
sign := "+"
|
||||
if v < 0 {
|
||||
sign = ""
|
||||
}
|
||||
absV := v
|
||||
if absV < 0 {
|
||||
absV = -absV
|
||||
}
|
||||
if absV >= 1e9 {
|
||||
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||
} else if absV >= 1e6 {
|
||||
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||
} else if absV >= 1e3 {
|
||||
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", sign, v)
|
||||
}
|
||||
@@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" }
|
||||
|
||||
// StrategyConfig strategy configuration details (JSON structure)
|
||||
type StrategyConfig struct {
|
||||
// language setting: "zh" for Chinese, "en" for English
|
||||
// This determines the language used for data formatting and prompt generation
|
||||
Language string `json:"language,omitempty"`
|
||||
// coin source configuration
|
||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||
// quantitative data configuration
|
||||
@@ -58,22 +61,21 @@ type PromptSectionsConfig struct {
|
||||
|
||||
// CoinSourceConfig coin source configuration
|
||||
type CoinSourceConfig struct {
|
||||
// source type: "static" | "coinpool" | "oi_top" | "mixed"
|
||||
// source type: "static" | "ai500" | "oi_top" | "mixed"
|
||||
SourceType string `json:"source_type"`
|
||||
// static coin list (used when source_type = "static")
|
||||
StaticCoins []string `json:"static_coins,omitempty"`
|
||||
// excluded coins list (filtered out from all sources)
|
||||
ExcludedCoins []string `json:"excluded_coins,omitempty"`
|
||||
// whether to use AI500 coin pool
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
// AI500 coin pool maximum count
|
||||
CoinPoolLimit int `json:"coin_pool_limit,omitempty"`
|
||||
// AI500 coin pool API URL (strategy-level configuration)
|
||||
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
|
||||
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||
// whether to use OI Top
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
// OI Top maximum count
|
||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||
// OI Top API URL (strategy-level configuration)
|
||||
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
|
||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||
}
|
||||
|
||||
// IndicatorConfig indicator configuration
|
||||
@@ -101,16 +103,30 @@ type IndicatorConfig struct {
|
||||
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
||||
// external data sources
|
||||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
||||
|
||||
// ========== NofxOS Unified API Configuration ==========
|
||||
// Unified API Key for all NofxOS data sources
|
||||
NofxOSAPIKey string `json:"nofxos_api_key,omitempty"`
|
||||
|
||||
// quantitative data sources (capital flow, position changes, price changes)
|
||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||||
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
|
||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||
|
||||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
||||
OIRankingAPIURL string `json:"oi_ranking_api_url,omitempty"` // OI ranking API base URL
|
||||
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
||||
|
||||
// NetFlow ranking data (market-wide fund flow rankings - institution/personal)
|
||||
EnableNetFlowRanking bool `json:"enable_netflow_ranking"` // whether to enable NetFlow ranking data
|
||||
NetFlowRankingDuration string `json:"netflow_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||
NetFlowRankingLimit int `json:"netflow_ranking_limit,omitempty"` // number of entries (default 10)
|
||||
|
||||
// Price ranking data (market-wide gainers/losers)
|
||||
EnablePriceRanking bool `json:"enable_price_ranking"` // whether to enable price ranking data
|
||||
PriceRankingDuration string `json:"price_ranking_duration,omitempty"` // durations: "1h" or "1h,4h,24h"
|
||||
PriceRankingLimit int `json:"price_ranking_limit,omitempty"` // number of entries per ranking (default 10)
|
||||
}
|
||||
|
||||
// KlineConfig K-line configuration
|
||||
@@ -172,14 +188,7 @@ func NewStrategyStore(db *gorm.DB) *StrategyStore {
|
||||
}
|
||||
|
||||
func (s *StrategyStore) initTables() error {
|
||||
// For PostgreSQL with existing table, skip AutoMigrate
|
||||
if s.db.Dialector.Name() == "postgres" {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'strategies'`).Scan(&tableExists)
|
||||
if tableExists > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// AutoMigrate will add missing columns without dropping existing data
|
||||
return s.db.AutoMigrate(&Strategy{})
|
||||
}
|
||||
|
||||
@@ -190,15 +199,20 @@ func (s *StrategyStore) initDefaultData() error {
|
||||
|
||||
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
||||
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
// Normalize language to "zh" or "en"
|
||||
normalizedLang := "en"
|
||||
if lang == "zh" {
|
||||
normalizedLang = "zh"
|
||||
}
|
||||
|
||||
config := StrategyConfig{
|
||||
Language: normalizedLang,
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "coinpool",
|
||||
UseCoinPool: true,
|
||||
CoinPoolLimit: 10,
|
||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||||
UseOITop: false,
|
||||
OITopLimit: 20,
|
||||
OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c",
|
||||
SourceType: "ai500",
|
||||
UseAI500: true,
|
||||
AI500Limit: 10,
|
||||
UseOITop: false,
|
||||
OITopLimit: 20,
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
@@ -222,15 +236,24 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
BOLLPeriods: []int{20},
|
||||
// NofxOS unified API key
|
||||
NofxOSAPIKey: "cm_568c67eae410d912c54c",
|
||||
// Quant data
|
||||
EnableQuantData: true,
|
||||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
||||
EnableQuantOI: true,
|
||||
EnableQuantNetflow: true,
|
||||
// OI ranking data - market-wide OI increase/decrease rankings
|
||||
// OI ranking data
|
||||
EnableOIRanking: true,
|
||||
OIRankingAPIURL: "http://nofxaios.com:30006",
|
||||
OIRankingDuration: "1h",
|
||||
OIRankingLimit: 10,
|
||||
// NetFlow ranking data
|
||||
EnableNetFlowRanking: true,
|
||||
NetFlowRankingDuration: "1h",
|
||||
NetFlowRankingLimit: 10,
|
||||
// Price ranking data
|
||||
EnablePriceRanking: true,
|
||||
PriceRankingDuration: "1h,4h,24h",
|
||||
PriceRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
|
||||
@@ -37,7 +37,7 @@ type Trader struct {
|
||||
BTCETHLeverage int `gorm:"column:btc_eth_leverage;default:5" json:"btc_eth_leverage,omitempty"`
|
||||
AltcoinLeverage int `gorm:"column:altcoin_leverage;default:5" json:"altcoin_leverage,omitempty"`
|
||||
TradingSymbols string `gorm:"column:trading_symbols;default:''" json:"trading_symbols,omitempty"`
|
||||
UseCoinPool bool `gorm:"column:use_coin_pool;default:false" json:"use_coin_pool,omitempty"`
|
||||
UseAI500 bool `gorm:"column:use_coin_pool;default:false" json:"use_ai500,omitempty"`
|
||||
UseOITop bool `gorm:"column:use_oi_top;default:false" json:"use_oi_top,omitempty"`
|
||||
CustomPrompt string `gorm:"column:custom_prompt;default:''" json:"custom_prompt,omitempty"`
|
||||
OverrideBasePrompt bool `gorm:"column:override_base_prompt;default:false" json:"override_base_prompt,omitempty"`
|
||||
@@ -124,6 +124,9 @@ func (s *TraderStore) Update(trader *Trader) error {
|
||||
}
|
||||
if trader.ScanIntervalMinutes > 0 {
|
||||
updates["scan_interval_minutes"] = trader.ScanIntervalMinutes
|
||||
fmt.Printf("📊 TraderStore.Update: scan_interval_minutes=%d will be saved\n", trader.ScanIntervalMinutes)
|
||||
} else {
|
||||
fmt.Printf("⚠️ TraderStore.Update: scan_interval_minutes=%d (<=0, NOT updating)\n", trader.ScanIntervalMinutes)
|
||||
}
|
||||
|
||||
return s.db.Model(&Trader{}).
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/experience"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
@@ -104,7 +104,7 @@ type AutoTrader struct {
|
||||
trader Trader // Use Trader interface (supports multiple platforms)
|
||||
mcpClient mcp.AIClient
|
||||
store *store.Store // Data storage (decision records, etc.)
|
||||
strategyEngine *decision.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||
cycleNumber int // Current cycle number
|
||||
initialBalance float64
|
||||
dailyPnL float64
|
||||
@@ -310,7 +310,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
if config.StrategyConfig == nil {
|
||||
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
|
||||
}
|
||||
strategyEngine := decision.NewStrategyEngine(config.StrategyConfig)
|
||||
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig)
|
||||
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
|
||||
|
||||
return &AutoTrader{
|
||||
@@ -524,7 +524,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
|
||||
// 5. Use strategy engine to call AI for decision
|
||||
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
|
||||
aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
|
||||
aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
|
||||
|
||||
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
|
||||
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
|
||||
@@ -585,8 +585,8 @@ func (at *AutoTrader) runCycle() error {
|
||||
// logger.Infof(strings.Repeat("-", 70) + "\n")
|
||||
|
||||
// 7. Print AI decisions
|
||||
// logger.Infof("📋 AI decision list (%d items):\n", len(decision.Decisions))
|
||||
// for i, d := range decision.Decisions {
|
||||
// logger.Infof("📋 AI decision list (%d items):\n", len(kernel.Decisions))
|
||||
// for i, d := range kernel.Decisions {
|
||||
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
||||
// if d.Action == "open_long" || d.Action == "open_short" {
|
||||
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f",
|
||||
@@ -664,7 +664,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
|
||||
// buildTradingContext builds trading context
|
||||
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
// 1. Get account information
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
@@ -701,7 +701,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var positionInfos []decision.PositionInfo
|
||||
var positionInfos []kernel.PositionInfo
|
||||
totalMarginUsed := 0.0
|
||||
|
||||
// Current position key set (for cleaning up closed position records)
|
||||
@@ -768,7 +768,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
peakPnlPct := at.peakPnLCache[posKey]
|
||||
at.peakPnLCacheMutex.RUnlock()
|
||||
|
||||
positionInfos = append(positionInfos, decision.PositionInfo{
|
||||
positionInfos = append(positionInfos, kernel.PositionInfo{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -820,13 +820,13 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
|
||||
|
||||
// 6. Build context
|
||||
ctx := &decision.Context{
|
||||
ctx := &kernel.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||
CallCount: at.callCount,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
Account: decision.AccountInfo{
|
||||
Account: kernel.AccountInfo{
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: availableBalance,
|
||||
UnrealizedPnL: totalUnrealizedProfit,
|
||||
@@ -859,7 +859,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, kernel.RecentOrder{
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
EntryPrice: trade.EntryPrice,
|
||||
@@ -881,7 +881,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
} else if stats.TotalTrades == 0 {
|
||||
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
|
||||
} else {
|
||||
ctx.TradingStats = &decision.TradingStats{
|
||||
ctx.TradingStats = &kernel.TradingStats{
|
||||
TotalTrades: stats.TotalTrades,
|
||||
WinRate: stats.WinRate,
|
||||
ProfitFactor: stats.ProfitFactor,
|
||||
@@ -899,7 +899,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
}
|
||||
|
||||
// 8. Get quantitative data (if enabled in strategy config)
|
||||
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
|
||||
if strategyConfig.Indicators.EnableQuantData {
|
||||
// Collect symbols to query (candidate coins + position coins)
|
||||
symbolsToQuery := make(map[string]bool)
|
||||
for _, coin := range candidateCoins {
|
||||
@@ -929,11 +929,31 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Get NetFlow ranking data (market-wide fund flow)
|
||||
if strategyConfig.Indicators.EnableNetFlowRanking {
|
||||
logger.Infof("💰 [%s] Fetching NetFlow ranking data...", at.name)
|
||||
ctx.NetFlowRankingData = at.strategyEngine.FetchNetFlowRankingData()
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
logger.Infof("💰 [%s] NetFlow ranking data ready: inst_in=%d, inst_out=%d",
|
||||
at.name, len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Get Price ranking data (market-wide gainers/losers)
|
||||
if strategyConfig.Indicators.EnablePriceRanking {
|
||||
logger.Infof("📈 [%s] Fetching Price ranking data...", at.name)
|
||||
ctx.PriceRankingData = at.strategyEngine.FetchPriceRankingData()
|
||||
if ctx.PriceRankingData != nil {
|
||||
logger.Infof("📈 [%s] Price ranking data ready for %d durations",
|
||||
at.name, len(ctx.PriceRankingData.Durations))
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// executeDecisionWithRecord executes AI decision and records detailed information
|
||||
func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
switch decision.Action {
|
||||
case "open_long":
|
||||
return at.executeOpenLongWithRecord(decision, actionRecord)
|
||||
@@ -953,7 +973,7 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
|
||||
|
||||
// ExecuteDecision executes a trading decision from external sources (e.g., debate consensus)
|
||||
// This is a public method that can be called by other modules
|
||||
func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error {
|
||||
func (at *AutoTrader) ExecuteDecision(d *kernel.Decision) error {
|
||||
logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol)
|
||||
|
||||
// Create a minimal action record for tracking
|
||||
@@ -979,7 +999,7 @@ func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error {
|
||||
}
|
||||
|
||||
// executeOpenLongWithRecord executes open long position and records detailed information
|
||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
@@ -1096,7 +1116,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
}
|
||||
|
||||
// executeOpenShortWithRecord executes open short position and records detailed information
|
||||
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
@@ -1213,7 +1233,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
}
|
||||
|
||||
// executeCloseLongWithRecord executes close long position and records detailed information
|
||||
func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
@@ -1277,7 +1297,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
|
||||
}
|
||||
|
||||
// executeCloseShortWithRecord executes close short position and records detailed information
|
||||
func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
@@ -1392,7 +1412,7 @@ func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||||
}
|
||||
|
||||
// saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision)
|
||||
func (at *AutoTrader) saveEquitySnapshot(ctx *decision.Context) {
|
||||
func (at *AutoTrader) saveEquitySnapshot(ctx *kernel.Context) {
|
||||
if at.store == nil || ctx == nil {
|
||||
return
|
||||
}
|
||||
@@ -1624,7 +1644,7 @@ func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
|
||||
|
||||
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
|
||||
// This avoids position stacking overflow when changing positions
|
||||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
||||
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||||
if len(decisions) <= 1 {
|
||||
return decisions
|
||||
}
|
||||
@@ -1644,7 +1664,7 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
||||
}
|
||||
|
||||
// Copy decision list
|
||||
sorted := make([]decision.Decision, len(decisions))
|
||||
sorted := make([]kernel.Decision, len(decisions))
|
||||
copy(sorted, decisions)
|
||||
|
||||
// Sort by priority
|
||||
|
||||
1023
web/src/App.tsx
1023
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -145,43 +145,41 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||
|
||||
return (
|
||||
<div className="binance-card" style={{ background: '#0D1117', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
<div className="nofx-glass rounded-lg border border-white/5 relative z-10 w-full h-[600px] flex flex-col">
|
||||
{/* Clean Professional Toolbar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-1.5"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#161B22' }}
|
||||
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-[#0B0E11]/80 rounded-t-lg"
|
||||
style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
|
||||
>
|
||||
{/* Left: Tab Switcher */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('equity')}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${
|
||||
activeTab === 'equity'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<span>{t('accountEquityCurve', language)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('kline')}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${
|
||||
activeTab === 'kline'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'kline'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-3 h-3" />
|
||||
<CandlestickChart className="w-3.5 h-3.5" />
|
||||
<span>{t('marketChart', language)}</span>
|
||||
</button>
|
||||
|
||||
{/* Market Type Pills - Only when kline active */}
|
||||
{activeTab === 'kline' && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-[#30363D] mx-1" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-px h-4 bg-white/10 mx-2" />
|
||||
<div className="flex items-center gap-1">
|
||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||
const config = MARKET_CONFIG[type]
|
||||
const isActive = marketType === type
|
||||
@@ -189,13 +187,13 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleMarketTypeChange(type)}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-all ${
|
||||
isActive
|
||||
? 'bg-[#21262D] text-white'
|
||||
: 'text-gray-500 hover:text-gray-400'
|
||||
}`}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{config.icon} {language === 'zh' ? config.label.zh : config.label.en}
|
||||
<span className="mr-1 opacity-70">{config.icon}</span>
|
||||
{language === 'zh' ? config.label.zh : config.label.en}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -206,87 +204,89 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
|
||||
{/* Right: Symbol + Interval */}
|
||||
{activeTab === 'kline' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 md:gap-3 w-full md:w-auto min-w-0">
|
||||
{/* Symbol Dropdown */}
|
||||
{marketConfig.hasDropdown ? (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white hover:bg-[#30363D] transition-all"
|
||||
>
|
||||
<span>{chartSymbol}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 w-56 bg-[#161B22] border border-[#30363D] rounded-lg shadow-2xl z-50 max-h-72 overflow-hidden">
|
||||
<div className="p-2 border-b border-[#30363D]">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-[#0D1117] rounded border border-[#30363D]">
|
||||
<Search className="w-3 h-3 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="shrink-0 relative" ref={dropdownRef}>
|
||||
{marketConfig.hasDropdown ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-black/40 border border-white/10 rounded text-[11px] font-bold text-nofx-text-main hover:border-nofx-gold/30 hover:text-nofx-gold transition-all"
|
||||
>
|
||||
<span>{chartSymbol}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-nofx-text-muted transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-2 w-64 bg-[#0B0E11] border border-white/10 rounded-lg shadow-[0_10px_40px_-10px_rgba(0,0,0,0.5)] z-50 overflow-hidden nofx-glass ring-1 ring-white/5">
|
||||
<div className="p-2 border-b border-white/5">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-black/40 rounded border border-white/10 focus-within:border-nofx-gold/50 transition-colors">
|
||||
<Search className="w-3.5 h-3.5 text-nofx-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Search symbol..."
|
||||
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-60 custom-scrollbar">
|
||||
{['crypto', 'stock', 'forex', 'commodity', 'index'].map(category => {
|
||||
const categorySymbols = filteredSymbols.filter(s => s.category === category)
|
||||
if (categorySymbols.length === 0) return null
|
||||
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' }
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="px-3 py-1.5 text-[9px] font-bold text-nofx-text-muted/60 bg-white/5 uppercase tracking-wider">{labels[category]}</div>
|
||||
{categorySymbols.map(s => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => { setChartSymbol(s.symbol); setShowDropdown(false); setSearchFilter('') }}
|
||||
className={`w-full px-3 py-2 text-left text-[11px] font-mono hover:bg-white/5 transition-all flex items-center justify-between ${chartSymbol === s.symbol ? 'bg-nofx-gold/10 text-nofx-gold' : 'text-nofx-text-muted'}`}
|
||||
>
|
||||
<span>{s.symbol}</span>
|
||||
<span className="text-[9px] opacity-40">{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-52">
|
||||
{['crypto', 'stock', 'forex', 'commodity', 'index'].map(category => {
|
||||
const categorySymbols = filteredSymbols.filter(s => s.category === category)
|
||||
if (categorySymbols.length === 0) return null
|
||||
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' }
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="px-3 py-1 text-[9px] font-medium text-gray-500 bg-[#0D1117] uppercase tracking-wider">{labels[category]}</div>
|
||||
{categorySymbols.map(s => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => { setChartSymbol(s.symbol); setShowDropdown(false); setSearchFilter('') }}
|
||||
className={`w-full px-3 py-1.5 text-left text-[11px] hover:bg-[#21262D] transition-all ${chartSymbol === s.symbol ? 'bg-blue-500/20 text-blue-400' : 'text-gray-300'}`}
|
||||
>
|
||||
{s.symbol}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white">{chartSymbol}</span>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="px-2.5 py-1 bg-black/40 border border-white/10 rounded text-[11px] font-bold text-nofx-text-main font-mono">{chartSymbol}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interval Selector */}
|
||||
<div className="flex items-center bg-[#21262D] rounded overflow-hidden">
|
||||
{/* Interval Selector - Allow scrolling if needed */}
|
||||
<div className="flex items-center bg-black/40 rounded border border-white/10 overflow-x-auto no-scrollbar max-w-[200px] md:max-w-none">
|
||||
{INTERVALS.map((int) => (
|
||||
<button
|
||||
key={int.value}
|
||||
onClick={() => setInterval(int.value)}
|
||||
className={`px-2 py-1 text-[10px] font-medium transition-all ${
|
||||
interval === int.value
|
||||
? 'bg-blue-500/30 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-[#30363D]'
|
||||
}`}
|
||||
className={`px-2 py-1 text-[10px] font-medium transition-all ${interval === int.value
|
||||
? 'bg-nofx-gold/20 text-nofx-gold'
|
||||
: 'text-nofx-text-muted hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{int.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Input */}
|
||||
<form onSubmit={handleSymbolSubmit} className="flex items-center">
|
||||
{/* Quick Input - Hidden on mobile, dropdown search is enough */}
|
||||
<form onSubmit={handleSymbolSubmit} className="hidden md:flex items-center shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={symbolInput}
|
||||
onChange={(e) => setSymbolInput(e.target.value)}
|
||||
placeholder="Symbol..."
|
||||
className="w-20 px-2 py-1 bg-[#0D1117] border border-[#30363D] rounded-l text-[10px] text-white placeholder-gray-600 focus:outline-none focus:border-blue-500/50"
|
||||
placeholder="Sym"
|
||||
className="w-16 px-2 py-1 bg-black/40 border border-white/10 rounded-l text-[10px] text-white placeholder-gray-600 focus:outline-none focus:border-nofx-gold/50 font-mono transition-colors"
|
||||
/>
|
||||
<button type="submit" className="px-2 py-1 bg-[#21262D] border border-[#30363D] border-l-0 rounded-r text-[10px] text-gray-400 hover:text-white hover:bg-[#30363D] transition-all">
|
||||
<button type="submit" className="px-2 py-1 bg-white/5 border border-white/10 border-l-0 rounded-r text-[10px] text-nofx-text-muted hover:text-white hover:bg-white/10 transition-all">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
@@ -295,32 +295,33 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="relative overflow-hidden min-h-[400px]">
|
||||
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'equity' ? (
|
||||
<motion.div
|
||||
key="equity"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
className="h-full w-full absolute inset-0"
|
||||
>
|
||||
<EquityChart traderId={traderId} embedded />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`kline-${chartSymbol}-${interval}-${currentExchange}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
className="h-full w-full absolute inset-0"
|
||||
>
|
||||
<AdvancedChart
|
||||
symbol={chartSymbol}
|
||||
interval={interval}
|
||||
traderID={traderId}
|
||||
// Dynamic height to fill container
|
||||
height={550}
|
||||
exchange={currentExchange}
|
||||
onSymbolChange={setChartSymbol}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getTraderColor } from '../utils/traderColors'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
|
||||
export function CompetitionPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -44,83 +45,78 @@ export function CompetitionPage() {
|
||||
|
||||
if (!competition) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="binance-card p-8 animate-pulse">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="skeleton h-8 w-64"></div>
|
||||
<div className="skeleton h-4 w-48"></div>
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<div className="container mx-auto max-w-7xl px-4 md:px-8">
|
||||
<div className="space-y-6">
|
||||
<div className="animate-pulse bg-black/40 border border-white/10 rounded-xl p-8 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="h-8 w-64 bg-white/5 rounded"></div>
|
||||
<div className="h-4 w-48 bg-white/5 rounded"></div>
|
||||
</div>
|
||||
<div className="h-12 w-32 bg-white/5 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/40 border border-white/10 rounded-xl p-6 backdrop-blur-md">
|
||||
<div className="h-6 w-40 mb-4 bg-white/5 rounded"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-20 w-full bg-white/5 rounded"></div>
|
||||
<div className="h-20 w-full bg-white/5 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skeleton h-12 w-32"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="binance-card p-6">
|
||||
<div className="skeleton h-6 w-40 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-20 w-full rounded"></div>
|
||||
<div className="skeleton h-20 w-full rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果有数据返回但没有交易员,显示空状态
|
||||
if (!competition.traders || competition.traders.length === 0) {
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Trophy
|
||||
className="w-6 h-6 md:w-7 md:h-7"
|
||||
style={{ color: '#000' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<div className="container mx-auto max-w-7xl px-4 md:px-8 space-y-8 animate-fade-in">
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center bg-black/60 border border-nofx-gold/30 shadow-[0_0_15px_rgba(240,185,11,0.2)]"
|
||||
>
|
||||
{t('aiCompetition', language)}
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
<Trophy
|
||||
className="w-6 h-6 md:w-7 md:h-7 text-nofx-gold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2 text-white"
|
||||
>
|
||||
0 {t('traders', language)}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('liveBattle', language)}
|
||||
</p>
|
||||
{t('aiCompetition', language)}
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20"
|
||||
>
|
||||
0 {t('traders', language)}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{t('liveBattle', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="binance-card p-8 text-center">
|
||||
<Trophy
|
||||
className="w-16 h-16 mx-auto mb-4 opacity-40"
|
||||
style={{ color: '#848E9C' }}
|
||||
/>
|
||||
<h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('noTraders', language)}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('createFirstTrader', language)}
|
||||
</p>
|
||||
{/* Empty State */}
|
||||
<div className="bg-black/40 border border-white/10 rounded-xl p-16 text-center backdrop-blur-md">
|
||||
<Trophy
|
||||
className="w-16 h-16 mx-auto mb-4 text-zinc-700"
|
||||
/>
|
||||
<h3 className="text-lg font-bold mb-2 text-white">
|
||||
{t('noTraders', language)}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{t('createFirstTrader', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,375 +129,358 @@ export function CompetitionPage() {
|
||||
const leader = sortedTraders[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Trophy
|
||||
className="w-6 h-6 md:w-7 md:h-7"
|
||||
style={{ color: '#000' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center bg-black/60 border border-nofx-gold/30 shadow-[0_0_15px_rgba(240,185,11,0.2)]"
|
||||
>
|
||||
{t('aiCompetition', language)}
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
<Trophy
|
||||
className="w-6 h-6 md:w-7 md:h-7 text-nofx-gold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2 text-white"
|
||||
>
|
||||
{competition.count} {t('traders', language)}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('liveBattle', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left md:text-right w-full md:w-auto">
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('leader', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-base md:text-lg font-bold"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{leader?.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left/Right Split: Performance Chart + Leaderboard */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Left: Performance Comparison Chart */}
|
||||
<div
|
||||
className="binance-card p-5 animate-slide-in"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2
|
||||
className="text-lg font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('performanceComparison', language)}
|
||||
</h2>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('realTimePnL', language)}
|
||||
{t('aiCompetition', language)}
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20"
|
||||
>
|
||||
{competition.count} {t('traders', language)}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{t('liveBattle', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ComparisonChart traders={sortedTraders.slice(0, 10)} />
|
||||
</div>
|
||||
|
||||
{/* Right: Leaderboard */}
|
||||
<div
|
||||
className="binance-card p-5 animate-slide-in"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2
|
||||
className="text-lg font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('leaderboard', language)}
|
||||
</h2>
|
||||
<div className="text-left md:text-right w-full md:w-auto">
|
||||
<div className="text-xs mb-1 text-zinc-400">
|
||||
{t('leader', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs px-2 py-1 rounded"
|
||||
className="text-base md:text-lg font-bold text-nofx-gold"
|
||||
>
|
||||
{leader?.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
color: '#F0B90B',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{t('live', language)}
|
||||
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isLeader = index === 0
|
||||
const traderColor = getTraderColor(
|
||||
sortedTraders,
|
||||
trader.trader_id
|
||||
)
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
onClick={() => handleTraderClick(trader.trader_id)}
|
||||
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
|
||||
style={{
|
||||
background: isLeader
|
||||
? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)'
|
||||
: '#0B0E11',
|
||||
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
|
||||
boxShadow: isLeader
|
||||
? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)'
|
||||
: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Rank & Avatar & Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Rank Badge */}
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: index === 0
|
||||
? 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)'
|
||||
: index === 1
|
||||
? 'linear-gradient(135deg, #C0C0C0 0%, #E8E8E8 100%)'
|
||||
: index === 2
|
||||
? 'linear-gradient(135deg, #CD7F32 0%, #E8A64C 100%)'
|
||||
: '#2B3139',
|
||||
color: index < 3 ? '#000' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
{/* Punk Avatar */}
|
||||
<PunkAvatar
|
||||
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
|
||||
size={36}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="font-bold text-sm"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono font-semibold"
|
||||
style={{ color: traderColor }}
|
||||
>
|
||||
{trader.ai_model.toUpperCase()} +{' '}
|
||||
{trader.exchange.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Left/Right Split: Performance Chart + Leaderboard */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: Performance Comparison Chart */}
|
||||
<div
|
||||
className="bg-black/40 border border-white/10 rounded-xl p-6 backdrop-blur-md animate-slide-in hover:border-white/20 transition-colors"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2
|
||||
className="text-lg font-bold flex items-center gap-2 text-white"
|
||||
>
|
||||
{t('performanceComparison', language)}
|
||||
</h2>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{t('realTimePnL', language)}
|
||||
</div>
|
||||
</div>
|
||||
<ComparisonChart traders={sortedTraders.slice(0, 10)} />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
|
||||
{/* Total Equity */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('equity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Leaderboard */}
|
||||
<div
|
||||
className="bg-black/40 border border-white/10 rounded-xl p-6 backdrop-blur-md animate-slide-in hover:border-white/20 transition-colors"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2
|
||||
className="text-lg font-bold flex items-center gap-2 text-white"
|
||||
>
|
||||
{t('leaderboard', language)}
|
||||
</h2>
|
||||
<div
|
||||
className="text-xs px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_8px_rgba(240,185,11,0.1)]"
|
||||
>
|
||||
{t('live', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isLeader = index === 0
|
||||
const traderColor = getTraderColor(
|
||||
sortedTraders,
|
||||
trader.trader_id
|
||||
)
|
||||
|
||||
{/* P&L */}
|
||||
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pnl', language)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
onClick={() => handleTraderClick(trader.trader_id)}
|
||||
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
|
||||
style={{
|
||||
background: isLeader
|
||||
? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)'
|
||||
: '#0B0E11',
|
||||
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
|
||||
boxShadow: isLeader
|
||||
? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)'
|
||||
: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Rank & Avatar & Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Rank Badge */}
|
||||
<div
|
||||
className="text-base md:text-lg font-bold mono"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0
|
||||
? '#0ECB81'
|
||||
: '#F6465D',
|
||||
background: index === 0
|
||||
? 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)'
|
||||
: index === 1
|
||||
? 'linear-gradient(135deg, #C0C0C0 0%, #E8E8E8 100%)'
|
||||
: index === 2
|
||||
? 'linear-gradient(135deg, #CD7F32 0%, #E8A64C 100%)'
|
||||
: '#2B3139',
|
||||
color: index < 3 ? '#000' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl?.toFixed(2) || '0.00'}
|
||||
{/* Punk Avatar */}
|
||||
<PunkAvatar
|
||||
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
|
||||
size={36}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="font-bold text-sm"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono font-semibold"
|
||||
style={{ color: traderColor }}
|
||||
>
|
||||
{trader.ai_model.toUpperCase()} +{' '}
|
||||
{trader.exchange.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pos', language)}
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
|
||||
{/* Total Equity */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('equity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{trader.margin_used_pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-xs font-bold"
|
||||
style={
|
||||
trader.is_running
|
||||
? {
|
||||
{/* P&L */}
|
||||
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pnl', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-base md:text-lg font-bold mono"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0
|
||||
? '#0ECB81'
|
||||
: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pos', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{trader.margin_used_pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-xs font-bold"
|
||||
style={
|
||||
trader.is_running
|
||||
? {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
: {
|
||||
: {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
}
|
||||
>
|
||||
{trader.is_running ? '●' : '○'}
|
||||
}
|
||||
>
|
||||
{trader.is_running ? '●' : '○'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Head-to-Head Stats */}
|
||||
{competition.traders.length === 2 && (
|
||||
<div
|
||||
className="binance-card p-5 animate-slide-in"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
>
|
||||
<h2
|
||||
className="text-lg font-bold mb-4 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
{/* Head-to-Head Stats */}
|
||||
{competition.traders.length === 2 && (
|
||||
<div
|
||||
className="bg-black/40 border border-white/10 rounded-xl p-6 backdrop-blur-md animate-slide-in"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
>
|
||||
{t('headToHead', language)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isWinning = index === 0
|
||||
const opponent = sortedTraders[1 - index]
|
||||
<h2
|
||||
className="text-lg font-bold mb-6 flex items-center gap-2 text-white"
|
||||
>
|
||||
{t('headToHead', language)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isWinning = index === 0
|
||||
const opponent = sortedTraders[1 - index]
|
||||
|
||||
// Check if both values are valid numbers
|
||||
const hasValidData =
|
||||
trader.total_pnl_pct != null &&
|
||||
opponent.total_pnl_pct != null &&
|
||||
!isNaN(trader.total_pnl_pct) &&
|
||||
!isNaN(opponent.total_pnl_pct)
|
||||
// Check if both values are valid numbers
|
||||
const hasValidData =
|
||||
trader.total_pnl_pct != null &&
|
||||
opponent.total_pnl_pct != null &&
|
||||
!isNaN(trader.total_pnl_pct) &&
|
||||
!isNaN(opponent.total_pnl_pct)
|
||||
|
||||
const gap = hasValidData
|
||||
? trader.total_pnl_pct - opponent.total_pnl_pct
|
||||
: NaN
|
||||
const gap = hasValidData
|
||||
? trader.total_pnl_pct - opponent.total_pnl_pct
|
||||
: NaN
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
|
||||
style={
|
||||
isWinning
|
||||
? {
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
|
||||
style={
|
||||
isWinning
|
||||
? {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
|
||||
border: '2px solid rgba(14, 203, 129, 0.3)',
|
||||
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)',
|
||||
}
|
||||
: {
|
||||
: {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="text-center">
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<PunkAvatar
|
||||
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
|
||||
size={56}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mb-2"
|
||||
style={{
|
||||
color: getTraderColor(sortedTraders, trader.trader_id),
|
||||
}}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-lg md:text-2xl font-bold mono mb-1"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{trader.total_pnl_pct != null &&
|
||||
!isNaN(trader.total_pnl_pct)
|
||||
? `${trader.total_pnl_pct >= 0 ? '+' : ''}${trader.total_pnl_pct.toFixed(2)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
{hasValidData && isWinning && gap > 0 && (
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#0ECB81' }}
|
||||
>
|
||||
{t('leadingBy', language, { gap: gap.toFixed(2) })}
|
||||
}
|
||||
>
|
||||
<div className="text-center">
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<PunkAvatar
|
||||
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
|
||||
size={56}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasValidData && !isWinning && gap < 0 && (
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#F6465D' }}
|
||||
className="text-sm md:text-base font-bold mb-2"
|
||||
style={{
|
||||
color: getTraderColor(sortedTraders, trader.trader_id),
|
||||
}}
|
||||
>
|
||||
{t('behindBy', language, {
|
||||
gap: Math.abs(gap).toFixed(2),
|
||||
})}
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
)}
|
||||
{!hasValidData && (
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#848E9C' }}
|
||||
className="text-lg md:text-2xl font-bold mono mb-1"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
—
|
||||
{trader.total_pnl_pct != null &&
|
||||
!isNaN(trader.total_pnl_pct)
|
||||
? `${trader.total_pnl_pct >= 0 ? '+' : ''}${trader.total_pnl_pct.toFixed(2)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
)}
|
||||
{hasValidData && isWinning && gap > 0 && (
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#0ECB81' }}
|
||||
>
|
||||
{t('leadingBy', language, { gap: gap.toFixed(2) })}
|
||||
</div>
|
||||
)}
|
||||
{hasValidData && !isWinning && gap < 0 && (
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#F6465D' }}
|
||||
>
|
||||
{t('behindBy', language, {
|
||||
gap: Math.abs(gap).toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!hasValidData && (
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Trader Config View Modal */}
|
||||
<TraderConfigViewModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
traderData={selectedTrader}
|
||||
/>
|
||||
</div>
|
||||
{/* Trader Config View Modal */}
|
||||
<TraderConfigViewModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
traderData={selectedTrader}
|
||||
/>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
41
web/src/components/DeepVoidBackground.tsx
Normal file
41
web/src/components/DeepVoidBackground.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
disableAnimation?: boolean
|
||||
}
|
||||
|
||||
export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) {
|
||||
return (
|
||||
<div className={`relative w-full min-h-screen bg-nofx-bg text-nofx-text overflow-hidden flex flex-col ${className}`} {...props}>
|
||||
{/* BACKGROUND LAYERS */}
|
||||
|
||||
{/* 1. Grain/Noise Texture */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none fixed z-0"></div>
|
||||
|
||||
{/* 2. Grid System */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-0">
|
||||
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03]"></div>
|
||||
</div>
|
||||
|
||||
{/* 3. Ambient Glow Spots */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none fixed z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow" style={{ animationDelay: '2s' }}></div>
|
||||
</div>
|
||||
|
||||
{/* 4. CRT/Scanline Overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] bg-[length:100%_4px,3px_100%] pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
{/* Content Layer */}
|
||||
<div className="relative z-10 flex-1 flex flex-col h-full w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,10 +85,7 @@ export default function HeaderBar({
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
<span className="text-lg font-bold text-nofx-gold">
|
||||
NOFX
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,11 +98,11 @@ export default function HeaderBar({
|
||||
{(() => {
|
||||
// Define all navigation tabs
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : '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 },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
|
||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
@@ -128,28 +125,12 @@ export default function HeaderBar({
|
||||
<button
|
||||
key={tab.page}
|
||||
onClick={() => handleNavClick(tab)}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === tab.page ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== tab.page) {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== tab.page) {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.15)', zIndex: -1 }}
|
||||
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
|
||||
/>
|
||||
)}
|
||||
{tab.label}
|
||||
@@ -167,16 +148,7 @@ export default function HeaderBar({
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg transition-all hover:scale-110"
|
||||
style={{ color: '#848E9C' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
@@ -188,16 +160,7 @@ export default function HeaderBar({
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg transition-all hover:scale-110"
|
||||
style={{ color: '#848E9C' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#1DA1F2'
|
||||
e.currentTarget.style.background = 'rgba(29, 161, 242, 0.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#1DA1F2] hover:bg-[#1DA1F2]/10"
|
||||
title="Twitter"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -209,16 +172,7 @@ export default function HeaderBar({
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg transition-all hover:scale-110"
|
||||
style={{ color: '#848E9C' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#0088cc'
|
||||
e.currentTarget.style.background = 'rgba(0, 136, 204, 0.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#0088cc] hover:bg-[#0088cc]/10"
|
||||
title="Telegram"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -237,62 +191,24 @@ export default function HeaderBar({
|
||||
<div className="relative" ref={userDropdownRef}>
|
||||
<button
|
||||
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'var(--panel-bg)')
|
||||
}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors bg-nofx-bg-lighter border border-nofx-gold/20 hover:bg-white/5"
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{user.email}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
/>
|
||||
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
|
||||
</button>
|
||||
|
||||
{userDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 border-b"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50 bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
<div className="px-3 py-2 border-b border-nofx-gold/20">
|
||||
<div className="text-xs text-nofx-text-muted">
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
<div className="text-sm font-medium text-nofx-text-muted">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,11 +218,7 @@ export default function HeaderBar({
|
||||
onLogout()
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center bg-nofx-danger/20 text-nofx-danger"
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
@@ -322,19 +234,14 @@ export default function HeaderBar({
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
{registrationEnabled && (
|
||||
<a
|
||||
href="/register"
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90 bg-nofx-gold text-black"
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
@@ -347,15 +254,7 @@ export default function HeaderBar({
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'transparent')
|
||||
}
|
||||
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' ? '🇨🇳' : '🇺🇸'}
|
||||
@@ -364,28 +263,14 @@ export default function HeaderBar({
|
||||
</button>
|
||||
|
||||
{languageDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50 bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'zh' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background:
|
||||
language === 'zh'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
|
||||
${language === 'zh' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-base">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
@@ -395,16 +280,8 @@ export default function HeaderBar({
|
||||
onLanguageChange?.('en')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'en' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background:
|
||||
language === 'en'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
|
||||
${language === 'en' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
@@ -418,8 +295,7 @@ export default function HeaderBar({
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
className="md:hidden text-nofx-text-muted hover:text-white"
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
@@ -439,21 +315,17 @@ export default function HeaderBar({
|
||||
: { height: 0, opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="md:hidden overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
|
||||
}}
|
||||
className="md:hidden overflow-hidden bg-nofx-bg-lighter border-t border-nofx-gold/10"
|
||||
>
|
||||
<div className="px-4 py-4 space-y-2">
|
||||
{/* Mobile Navigation Tabs - Show all tabs */}
|
||||
{(() => {
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : '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 },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
|
||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
@@ -476,25 +348,17 @@ export default function HeaderBar({
|
||||
<button
|
||||
key={tab.page}
|
||||
onClick={() => handleMobileNavClick(tab)}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === tab.page ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
className={`block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 w-full text-left px-4 py-3 rounded-lg
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.15)', zIndex: -1 }}
|
||||
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
|
||||
/>
|
||||
)}
|
||||
{tab.label}
|
||||
{tab.requiresAuth && !isLoggedIn && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'rgba(240, 185, 11, 0.2)', color: '#F0B90B' }}>
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-nofx-gold/20 text-nofx-gold">
|
||||
{language === 'zh' ? '需登录' : 'Login'}
|
||||
</span>
|
||||
)}
|
||||
@@ -511,21 +375,19 @@ export default function HeaderBar({
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`}
|
||||
className="block text-sm py-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
className="block text-sm py-2 text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Social Links - Mobile */}
|
||||
<div className="py-3 flex items-center gap-3" style={{ borderTop: '1px solid #2B3139' }}>
|
||||
<div className="py-3 flex items-center gap-3 border-t border-nofx-gold/20">
|
||||
<a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg"
|
||||
style={{ color: '#848E9C', background: 'rgba(255, 255, 255, 0.05)' }}
|
||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
@@ -535,8 +397,7 @@ export default function HeaderBar({
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg"
|
||||
style={{ color: '#848E9C', background: 'rgba(255, 255, 255, 0.05)' }}
|
||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#1DA1F2]"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
@@ -546,8 +407,7 @@ export default function HeaderBar({
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg"
|
||||
style={{ color: '#848E9C', background: 'rgba(255, 255, 255, 0.05)' }}
|
||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#0088cc]"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
@@ -558,10 +418,7 @@ export default function HeaderBar({
|
||||
{/* Language Toggle */}
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{t('language', language)}:
|
||||
</span>
|
||||
</div>
|
||||
@@ -571,11 +428,10 @@ export default function HeaderBar({
|
||||
onLanguageChange?.('zh')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'zh'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'zh'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
@@ -585,11 +441,10 @@ export default function HeaderBar({
|
||||
onLanguageChange?.('en')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'en'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'en'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
@@ -600,33 +455,17 @@ export default function HeaderBar({
|
||||
{/* User info and logout for mobile when logged in */}
|
||||
{isLoggedIn && user && (
|
||||
<div
|
||||
className="mt-4 pt-4"
|
||||
style={{ borderTop: '1px solid var(--panel-border)' }}
|
||||
className="mt-4 pt-4 border-t border-nofx-gold/20"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
|
||||
style={{ background: 'var(--panel-bg)' }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded bg-nofx-bg-lighter">
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div className="text-xs text-nofx-text-muted">
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
<div className="text-sm text-nofx-text-muted">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,11 +476,7 @@ export default function HeaderBar({
|
||||
onLogout()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center bg-nofx-danger/20 text-nofx-danger"
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
@@ -656,11 +491,7 @@ export default function HeaderBar({
|
||||
<div className="space-y-2 mt-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '1px solid var(--brand-light-gray)',
|
||||
}}
|
||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors text-nofx-text-muted border border-nofx-text-muted hover:text-white hover:border-white"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
@@ -668,11 +499,7 @@ export default function HeaderBar({
|
||||
{registrationEnabled && (
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors bg-nofx-gold text-black hover:opacity-90"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
// import { Input } from './ui/input' // Removed unused import
|
||||
import { toast } from 'sonner'
|
||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
@@ -102,13 +103,7 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-zinc-300 font-mono relative overflow-hidden flex items-center justify-center py-12">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent pointer-events-none"></div>
|
||||
|
||||
{/* Scanline Effect */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.5)_50%)] bg-[length:100%_4px]"></div>
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
|
||||
<div className="w-full max-w-md relative z-10 px-6">
|
||||
{/* Navigation - Top Bar (Mobile/Desktop Friendly) */}
|
||||
@@ -361,6 +356,6 @@ export function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
interface LoginRequiredOverlayProps {
|
||||
@@ -51,111 +52,114 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/90 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50"
|
||||
>
|
||||
{/* Scanline Effect */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.5)_50%)] bg-[length:100%_4px]"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
|
||||
className="relative max-w-md w-full overflow-hidden bg-black border border-nofx-gold/30 shadow-[0_0_50px_rgba(240,185,11,0.1)] rounded-sm group font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<DeepVoidBackground
|
||||
className="w-full h-full bg-nofx-bg/95 backdrop-blur-md flex items-center justify-center p-4 text-nofx-text"
|
||||
disableAnimation
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Terminal Window Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={12} className="text-nofx-gold" />
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">auth_protocol.exe</span>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
|
||||
className="relative max-w-md w-full overflow-hidden bg-nofx-bg border border-nofx-gold/30 shadow-neon rounded-sm group font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Terminal Window Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={12} className="text-nofx-gold" />
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">auth_protocol.exe</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-nofx-text-muted hover:text-nofx-danger transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-600 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="p-8 relative">
|
||||
{/* Background Grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:14px_14px] pointer-events-none"></div>
|
||||
{/* Main Content */}
|
||||
<div className="p-8 relative">
|
||||
{/* Background Grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:14px_14px] pointer-events-none"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Flashing Access Denied */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<div className="bg-black border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={18} className="animate-pulse" />
|
||||
<span className="font-bold tracking-widest text-sm uppercase">{language === 'zh' ? '访问被拒绝' : 'ACCESS DENIED'}</span>
|
||||
<div className="relative z-10">
|
||||
{/* Flashing Access Denied */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={18} className="animate-pulse" />
|
||||
<span className="font-bold tracking-widest text-sm uppercase">{language === 'zh' ? '访问被拒绝' : 'ACCESS DENIED'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Text */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{t.title}</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{t.subtitle}</p>
|
||||
{/* Terminal Text */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{t.title}</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
|
||||
<p className="text-xs text-nofx-text-muted leading-relaxed font-mono">
|
||||
<span className="text-green-500 mr-2">$</span>
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{t.benefits.map((benefit, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide">
|
||||
<span className="text-nofx-gold">✓</span> {benefit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/50 border-l-2 border-zinc-700 p-3 my-4">
|
||||
<p className="text-xs text-zinc-400 leading-relaxed font-mono">
|
||||
<span className="text-green-500 mr-2">$</span>
|
||||
{t.description}
|
||||
</p>
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>{t.login}</span>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/register"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>{t.register}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{t.benefits.map((benefit, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] text-zinc-500 uppercase tracking-wide">
|
||||
<span className="text-nofx-gold">✓</span> {benefit}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] text-nofx-text-muted hover:text-nofx-danger uppercase tracking-widest hover:underline decoration-red-500/30"
|
||||
>
|
||||
[ {t.later} ]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-[0_0_15px_rgba(240,185,11,0.2)] hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>{t.login}</span>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/register"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 font-bold text-xs uppercase tracking-widest transition-all hover:bg-zinc-900"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>{t.register}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] text-zinc-600 hover:text-red-500 uppercase tracking-widest hover:underline decoration-red-500/30"
|
||||
>
|
||||
[ {t.later} ]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner Accents */}
|
||||
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
|
||||
{/* Corner Accents */}
|
||||
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
|
||||
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DeepVoidBackground>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -148,13 +149,7 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-zinc-300 font-mono relative overflow-hidden flex items-center justify-center py-12">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent pointer-events-none"></div>
|
||||
|
||||
{/* Scanline Effect */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.5)_50%)] bg-[length:100%_4px]"></div>
|
||||
<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) */}
|
||||
@@ -469,6 +464,6 @@ export function RegisterPage() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ export function TraderConfigModal({
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
||||
<div>
|
||||
币种来源: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
|
||||
selectedStrategy.config.coin_source.source_type === 'coinpool' ? 'Coin Pool' :
|
||||
selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' :
|
||||
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white font-mono relative overflow-hidden flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-nofx-bg-deeper text-white font-mono relative overflow-hidden flex items-center justify-center px-4">
|
||||
{/* Background Grid & Scanlines */}
|
||||
<div className="fixed inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"></div>
|
||||
<div className="fixed inset-0 bg-gradient-to-t from-black via-transparent to-transparent pointer-events-none"></div>
|
||||
|
||||
@@ -56,14 +56,11 @@ export function FAQContent({
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id}>
|
||||
<div key={category.id} className="nofx-glass p-8 rounded-xl border border-white/5">
|
||||
{/* Category Header */}
|
||||
<div
|
||||
className="flex items-center gap-3 mb-6 pb-3"
|
||||
style={{ borderBottom: '2px solid #2B3139' }}
|
||||
>
|
||||
<category.icon className="w-7 h-7" style={{ color: '#F0B90B' }} />
|
||||
<h2 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
<div className="flex items-center gap-3 mb-6 pb-3 border-b border-white/10">
|
||||
<category.icon className="w-7 h-7 text-nofx-gold" />
|
||||
<h2 className="text-2xl font-bold text-nofx-text-main">
|
||||
{t(category.titleKey, language)}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -79,21 +76,12 @@ export function FAQContent({
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{/* Question */}
|
||||
<h3
|
||||
className="text-xl font-semibold mb-3"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-3 text-nofx-text-main">
|
||||
{t(item.questionKey, language)}
|
||||
</h3>
|
||||
|
||||
{/* Answer */}
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
style={{
|
||||
color: '#B7BDC6',
|
||||
lineHeight: '1.7',
|
||||
}}
|
||||
>
|
||||
<div className="prose prose-invert max-w-none text-nofx-text-muted leading-relaxed">
|
||||
{item.id === 'github-projects-tasks' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-base">
|
||||
@@ -295,7 +283,7 @@ export function FAQContent({
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
className="text-nofx-gold hover:underline"
|
||||
>
|
||||
CONTRIBUTING.md
|
||||
</a>
|
||||
@@ -304,7 +292,7 @@ export function FAQContent({
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/PR_TITLE_GUIDE.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
className="text-nofx-gold hover:underline"
|
||||
>
|
||||
PR_TITLE_GUIDE.md
|
||||
</a>
|
||||
@@ -383,16 +371,10 @@ export function FAQContent({
|
||||
)}
|
||||
</ol>
|
||||
|
||||
<div
|
||||
className="rounded p-3 mt-3"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.08)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.25)',
|
||||
}}
|
||||
>
|
||||
<div className="rounded p-3 mt-3 bg-nofx-gold/10 border border-nofx-gold/25">
|
||||
{language === 'zh' ? (
|
||||
<div className="text-sm">
|
||||
<strong style={{ color: '#F0B90B' }}>提示:</strong>{' '}
|
||||
<strong className="text-nofx-gold">Note:</strong>{' '}
|
||||
我们为高质量贡献提供激励(Bounty/奖金、荣誉徽章与鸣谢、优先
|
||||
Review/合并与内测资格 等)。 详情可关注带
|
||||
<a
|
||||
@@ -448,7 +430,7 @@ export function FAQContent({
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mt-6 h-px" style={{ background: '#2B3139' }} />
|
||||
<div className="mt-6 h-px bg-white/5" />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { Container } from '../Container'
|
||||
import { DeepVoidBackground } from '../DeepVoidBackground'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { FAQSearchBar } from './FAQSearchBar'
|
||||
import { FAQSidebar } from './FAQSidebar'
|
||||
@@ -58,125 +58,121 @@ export function FAQLayout({ language }: FAQLayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-6 pt-24">
|
||||
{/* Page Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 8px 24px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<HelpCircle className="w-8 h-8" style={{ color: '#0B0E11' }} />
|
||||
<DeepVoidBackground className="py-6 pt-24" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8">
|
||||
{/* Page Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-gradient-to-br from-nofx-gold to-[#FCD535] shadow-[0_8px_24px_rgba(240,185,11,0.4)]">
|
||||
<HelpCircle className="w-8 h-8 text-[#0B0E11]" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4 text-nofx-text-main">
|
||||
{t('faqTitle', language)}
|
||||
</h1>
|
||||
<p className="text-lg mb-8 text-nofx-text-muted">
|
||||
{t('faqSubtitle', language)}
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<FAQSearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
placeholder={
|
||||
language === 'zh' ? '搜索常见问题...' : 'Search FAQ...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4" style={{ color: '#EAECEF' }}>
|
||||
{t('faqTitle', language)}
|
||||
</h1>
|
||||
<p className="text-lg mb-8" style={{ color: '#848E9C' }}>
|
||||
{t('faqSubtitle', language)}
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<FAQSearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
placeholder={
|
||||
language === 'zh' ? '搜索常见问题...' : 'Search FAQ...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar - Hidden on mobile, visible on desktop */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<FAQSidebar
|
||||
categories={filteredCategories}
|
||||
activeItemId={activeItemId}
|
||||
language={language}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 min-w-0">
|
||||
{filteredCategories.length > 0 ? (
|
||||
<FAQContent
|
||||
{/* Main Content */}
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar - Hidden on mobile, visible on desktop */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<FAQSidebar
|
||||
categories={filteredCategories}
|
||||
activeItemId={activeItemId}
|
||||
language={language}
|
||||
onActiveItemChange={setActiveItemId}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '没有找到匹配的问题'
|
||||
: 'No matching questions found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="mt-4 px-6 py-2 rounded-lg font-semibold transition-all hover:opacity-90"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
}}
|
||||
>
|
||||
{language === 'zh' ? '清除搜索' : 'Clear Search'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Contact Section */}
|
||||
<div
|
||||
className="mt-16 p-8 rounded-lg text-center"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(240, 185, 11, 0.1) 0%, rgba(252, 213, 53, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('faqStillHaveQuestions', language)}
|
||||
</h3>
|
||||
<p className="mb-6" style={{ color: '#848E9C' }}>
|
||||
{t('faqContactUs', language)}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/nofx_dev_community"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
}}
|
||||
>
|
||||
{t('community', language)}
|
||||
</a>
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 min-w-0">
|
||||
{filteredCategories.length > 0 ? (
|
||||
<FAQContent
|
||||
categories={filteredCategories}
|
||||
language={language}
|
||||
onActiveItemChange={setActiveItemId}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '没有找到匹配的问题'
|
||||
: 'No matching questions found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="mt-4 px-6 py-2 rounded-lg font-semibold transition-all hover:opacity-90"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
}}
|
||||
>
|
||||
{language === 'zh' ? '清除搜索' : 'Clear Search'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Contact Section */}
|
||||
<div
|
||||
className="mt-16 p-8 rounded-lg text-center"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(240, 185, 11, 0.1) 0%, rgba(252, 213, 53, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('faqStillHaveQuestions', language)}
|
||||
</h3>
|
||||
<p className="mb-6" style={{ color: '#848E9C' }}>
|
||||
{t('faqContactUs', language)}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/nofx_dev_community"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
}}
|
||||
>
|
||||
{t('community', language)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,36 +12,21 @@ export function FAQSearchBar({
|
||||
placeholder = 'Search FAQ...',
|
||||
}: FAQSearchBarProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative group">
|
||||
<Search
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5"
|
||||
style={{ color: '#848E9C' }}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-nofx-text-muted group-focus-within:text-nofx-gold transition-colors"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-12 py-3 rounded-lg text-base transition-all focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#F0B90B'
|
||||
e.target.style.boxShadow = '0 0 0 3px rgba(240, 185, 11, 0.1)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = '#2B3139'
|
||||
e.target.style.boxShadow = 'none'
|
||||
}}
|
||||
className="w-full pl-12 pr-12 py-3 rounded-lg text-base transition-all focus:outline-none bg-black/40 border border-white/10 text-nofx-text-main placeholder-nofx-text-muted/50 focus:border-nofx-gold/50 focus:ring-1 focus:ring-nofx-gold/20 hover:border-nofx-gold/30 font-mono"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 hover:opacity-70 transition-opacity"
|
||||
style={{ color: '#848E9C' }}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-nofx-text-muted hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -24,14 +24,11 @@ export function FAQSidebar({
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id}>
|
||||
<div key={category.id} className="nofx-glass p-4 rounded-xl border border-white/5">
|
||||
{/* Category Title */}
|
||||
<div className="flex items-center gap-2 mb-3 px-3">
|
||||
<category.icon className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3
|
||||
className="text-sm font-bold uppercase tracking-wide"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<category.icon className="w-5 h-5 text-nofx-gold" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-wide text-nofx-gold">
|
||||
{t(category.titleKey, language)}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -44,30 +41,10 @@ export function FAQSidebar({
|
||||
<li key={item.id}>
|
||||
<button
|
||||
onClick={() => onItemClick(category.id, item.id)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-sm transition-all"
|
||||
style={{
|
||||
background: isActive
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
color: isActive ? '#F0B90B' : '#848E9C',
|
||||
borderLeft: isActive
|
||||
? '3px solid #F0B90B'
|
||||
: '3px solid transparent',
|
||||
paddingLeft: isActive ? '9px' : '12px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background =
|
||||
'rgba(240, 185, 11, 0.05)'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-all border-l-[3px] ${isActive
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border-nofx-gold pl-[9px]'
|
||||
: 'bg-transparent text-nofx-text-muted border-transparent pl-3 hover:bg-nofx-gold/5 hover:text-nofx-text-main'
|
||||
}`}
|
||||
>
|
||||
{t(item.questionKey, language)}
|
||||
</button>
|
||||
|
||||
@@ -42,9 +42,9 @@ export default function FooterSection({ language }: FooterSectionProps) {
|
||||
|
||||
return (
|
||||
<footer style={{ background: '#0B0E11', borderTop: '1px solid rgba(255, 255, 255, 0.06)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8 md:py-12">
|
||||
{/* Top Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-10 mb-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-10 mb-8 md:mb-12">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -19,19 +19,27 @@ export default function BrandStats() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="max-w-[1920px] mx-auto px-6 lg:px-16 relative z-10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-12 text-center md:text-left">
|
||||
<div className="max-w-[1920px] mx-auto px-4 lg:px-16 relative z-10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-12 md:text-left">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="relative overflow-hidden group bg-black/40 backdrop-blur-md border border-white/10 p-6 rounded-lg md:bg-transparent md:border-0 md:p-0 md:backdrop-blur-none"
|
||||
>
|
||||
<div className="text-5xl md:text-6xl font-black text-white tracking-tighter mb-2">
|
||||
{/* Mobile Neon Corners */}
|
||||
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-nofx-gold md:hidden opacity-80 shadow-[0_0_10px_rgba(234,179,8,0.5)]"></div>
|
||||
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-nofx-gold md:hidden opacity-80 shadow-[0_0_10px_rgba(234,179,8,0.5)]"></div>
|
||||
|
||||
{/* Mobile Inner Glow */}
|
||||
<div className="absolute inset-0 bg-nofx-gold/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none md:hidden"></div>
|
||||
|
||||
<div className="text-3xl md:text-6xl font-black text-white tracking-tighter mb-2 group-hover:scale-110 transition-transform duration-300 origin-left relative z-10">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm md:text-base font-bold text-black/60 uppercase tracking-widest bg-white/20 inline-block px-2 py-1">
|
||||
<div className="text-[10px] md:text-base font-bold text-zinc-400 md:text-black/60 uppercase tracking-widest bg-white/5 md:bg-white/20 inline-block px-2 py-1 rounded relative z-10">
|
||||
{stat.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
|
||||
const agents = [
|
||||
{
|
||||
name: "ALPHA-1",
|
||||
// ... (rest of agents array remains, but I can't skip lines in replacement content easily without context. Wait, let's just replace the top section)
|
||||
// Actually, I'll use multi_replace for targeted cleanup.
|
||||
class: "SCALPER",
|
||||
desc: "High-frequency microstructure exploitation.",
|
||||
apy: "142%",
|
||||
@@ -41,8 +43,10 @@ const agents = [
|
||||
]
|
||||
|
||||
export default function AgentGrid() {
|
||||
// Simplified State to prevent crash
|
||||
|
||||
return (
|
||||
<section id="market-scanner" className="py-24 bg-nofx-bg relative overflow-hidden">
|
||||
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden">
|
||||
|
||||
{/* Background Details */}
|
||||
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none">
|
||||
@@ -51,7 +55,7 @@ export default function AgentGrid() {
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-10 md:mb-16 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
|
||||
<Crosshair className="w-4 h-4" /> MARKET SELECT
|
||||
@@ -65,17 +69,18 @@ export default function AgentGrid() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Grid Container - Removing scroll tracking for stability test */}
|
||||
<div className="flex flex-row md:grid md:grid-cols-3 gap-4 md:gap-8 overflow-x-auto md:overflow-visible pb-12 md:pb-0 snap-x snap-mandatory -mx-6 px-6 md:mx-0 md:px-0 scrollbar-hide">
|
||||
{agents.map((agent, i) => {
|
||||
const Icon = agent.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className={`group relative bg-black/40 backdrop-blur-sm border ${agent.border} overflow-hidden hover:bg-zinc-900/40 transition-all duration-300 ${agent.bg_glow}`}
|
||||
style={{ clipPath: 'polygon(0 0, 100% 0, 100% 85%, 85% 100%, 0 100%)' }}
|
||||
className={`group relative bg-black/40 backdrop-blur-xl border ${agent.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
|
||||
>
|
||||
{/* Top "Hinge" decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function LiveFeed() {
|
||||
<section className="w-full bg-[#020304] border-y border-zinc-800 py-1 overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-scanlines opacity-10 pointer-events-none"></div>
|
||||
|
||||
<div className="max-w-[1920px] mx-auto px-4 flex flex-col md:flex-row gap-0 md:gap-8 items-stretch h-[320px] md:h-12 text-xs font-mono">
|
||||
<div className="max-w-[1920px] mx-auto px-4 flex flex-col md:flex-row gap-0 md:gap-8 items-stretch h-[240px] md:h-12 text-xs font-mono">
|
||||
|
||||
{/* Left Status Bar (Static) */}
|
||||
<div className="hidden md:flex items-center gap-6 text-zinc-600 border-r border-zinc-900 pr-6 shrink-0">
|
||||
@@ -92,8 +92,8 @@ export default function LiveFeed() {
|
||||
>
|
||||
<span className="text-zinc-600">[{log.time}]</span>
|
||||
<span className={`font-bold w-10 ${log.type === 'LIQ' ? 'text-red-500 bg-red-500/10 px-1 rounded' :
|
||||
log.type === 'ARB' ? 'text-nofx-gold bg-nofx-gold/10 px-1 rounded' :
|
||||
log.type === 'EXE' ? 'text-green-500' : 'text-zinc-500'
|
||||
log.type === 'ARB' ? 'text-nofx-gold bg-nofx-gold/10 px-1 rounded' :
|
||||
log.type === 'EXE' ? 'text-green-500' : 'text-zinc-500'
|
||||
}`}>{log.type}</span>
|
||||
<span className={`${log.color}`}>{log.msg}</span>
|
||||
</motion.div>
|
||||
@@ -106,8 +106,8 @@ export default function LiveFeed() {
|
||||
<div key={log.id} className="flex gap-2 w-full truncate border-b border-zinc-900/50 pb-1 last:border-0">
|
||||
<span className="text-zinc-700 w-16 shrink-0">{log.time.split('.')[0]}</span>
|
||||
<span className={`font-bold w-8 shrink-0 ${log.type === 'LIQ' ? 'text-red-500' :
|
||||
log.type === 'ARB' ? 'text-nofx-gold' :
|
||||
'text-zinc-500'
|
||||
log.type === 'ARB' ? 'text-nofx-gold' :
|
||||
'text-zinc-500'
|
||||
}`}>{log.type}</span>
|
||||
<span className={`${log.color} truncate`}>{log.msg}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Shield, Activity, CircuitBoard, Cpu, Wifi, Globe, Lock, Zap, Star, GitFork, Users, MessageCircle } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { httpClient } from '../../../lib/httpClient'
|
||||
import { useGitHubStats } from '../../../hooks/useGitHubStats'
|
||||
|
||||
export default function TerminalHero() {
|
||||
@@ -27,9 +26,18 @@ export default function TerminalHero() {
|
||||
try {
|
||||
const results = await Promise.all(symbols.map(async (sym) => {
|
||||
try {
|
||||
const res = await httpClient.get(`/api/klines?symbol=${sym}USDT&interval=1m&limit=1`)
|
||||
if (res.success && res.data?.length > 0) {
|
||||
const closePrice = parseFloat(res.data[0].close)
|
||||
// Use native fetch to bypass global error handlers (toasts) in httpClient
|
||||
const response = await fetch(`/api/klines?symbol=${sym}USDT&interval=1m&limit=1`)
|
||||
if (!response.ok) return null
|
||||
|
||||
const res = await response.json()
|
||||
// Check for standard API response structure or direct array
|
||||
const klineData = res.data || res
|
||||
|
||||
if (Array.isArray(klineData) && klineData.length > 0) {
|
||||
const closePrice = parseFloat(klineData[0].close || klineData[0][4]) // Handle object or array format
|
||||
if (isNaN(closePrice)) return null
|
||||
|
||||
// Format price: < 1 use 4 decimals, > 1 use 2
|
||||
const formatted = closePrice < 1
|
||||
? closePrice.toFixed(4)
|
||||
@@ -37,7 +45,7 @@ export default function TerminalHero() {
|
||||
return { symbol: sym, price: formatted }
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore individual failures
|
||||
// Silent failure for background polling
|
||||
}
|
||||
return null
|
||||
}))
|
||||
@@ -64,6 +72,7 @@ export default function TerminalHero() {
|
||||
{/* BACKGROUND LAYERS */}
|
||||
{/* 1. Grid */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none"></div>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none md:hidden" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03] pointer-events-none"></div>
|
||||
|
||||
{/* 2. World Map / Data Viz Background (Abstract) */}
|
||||
@@ -72,9 +81,19 @@ export default function TerminalHero() {
|
||||
<div className="absolute w-[60vw] h-[60vw] rounded-full border border-dashed border-nofx-accent/20 animate-[spin_60s_linear_infinite]"></div>
|
||||
</div>
|
||||
|
||||
{/* 3. Gradient Spots */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] pointer-events-none"></div>
|
||||
{/* 3. Gradient Spots - Intensified for Mobile */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/20 rounded-full blur-[120px] pointer-events-none mix-blend-screen"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/10 rounded-full blur-[120px] pointer-events-none mix-blend-screen"></div>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONTENT GRID */}
|
||||
<div className="relative z-10 flex-1 grid grid-cols-1 lg:grid-cols-12 gap-0 lg:gap-8 max-w-[1800px] mx-auto w-full px-6 h-full pb-20 pt-10 pointer-events-none">
|
||||
@@ -153,15 +172,18 @@ export default function TerminalHero() {
|
||||
</motion.div>
|
||||
|
||||
{/* Main Title - Massive & Impactful */}
|
||||
<h1 className="text-6xl md:text-8xl lg:text-9xl font-black tracking-tighter leading-[0.8] mb-6 select-none bg-clip-text text-transparent bg-gradient-to-b from-white via-white to-zinc-600">
|
||||
AGENTIC<br />
|
||||
<span className="text-stroke-1 text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold via-white to-nofx-gold animate-shimmer bg-[length:200%_auto]">TRADING</span>
|
||||
</h1>
|
||||
{/* Main Title - Massive & Impactful */}
|
||||
<div className="relative z-20 mix-blend-hard-light md:mix-blend-normal">
|
||||
<h1 className="text-6xl sm:text-6xl md:text-8xl lg:text-9xl font-black tracking-tighter leading-[0.9] md:leading-[0.8] mb-6 select-none bg-clip-text text-transparent bg-gradient-to-b from-white via-white to-zinc-600 drop-shadow-2xl">
|
||||
AGENTIC<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold via-white to-nofx-gold animate-shimmer bg-[length:200%_auto] tracking-tight filter drop-shadow-[0_0_15px_rgba(234,179,8,0.3)]">TRADING</span>
|
||||
</h1>
|
||||
|
||||
<p className="max-w-xl text-zinc-400 text-lg mb-6 font-light leading-relaxed">
|
||||
The World's First Open-Source Agentic Trading OS.
|
||||
Deploy autonomous high-frequency trading agents powered by advanced LLMs.
|
||||
</p>
|
||||
<p className="max-w-xl text-zinc-200 md:text-zinc-400 text-lg mb-6 font-light leading-relaxed drop-shadow-md">
|
||||
The World's First Open-Source Agentic Trading OS.
|
||||
Deploy autonomous high-frequency trading agents powered by advanced LLMs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Market Access Strip - Prominent Display */}
|
||||
{/* Market Access Strip - Prominent Display */}
|
||||
@@ -214,26 +236,52 @@ export default function TerminalHero() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: HOLOGRAPHIC DISPLAY - Absolute Overlay for "Far Right" Effect */}
|
||||
<div className="absolute top-0 right-0 h-full w-[45vw] hidden lg:flex pointer-events-none items-center justify-center z-0">
|
||||
{/* 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]">
|
||||
|
||||
{/* Scanning Grid behind Mascot */}
|
||||
<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%)]"></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-90 transition-all duration-500 hover:opacity-100 group flex flex-col justify-end pointer-events-auto">
|
||||
<div className="absolute inset-0 bg-nofx-accent/10 blur-[80px] rounded-full animate-pulse-slow transition-colors duration-500 group-hover:bg-nofx-gold/40"></div>
|
||||
<img
|
||||
src="/images/nofx_mascot.png"
|
||||
alt="Agent NoFX"
|
||||
className="w-full h-full object-contain object-bottom filter drop-shadow-[0_0_25px_rgba(0,240,255,0.2)] contrast-110 saturate-0 group-hover:saturate-100 group-hover:drop-shadow-[0_0_35px_rgba(234,179,8,0.5)] transition-all duration-500"
|
||||
style={{ maskImage: 'linear-gradient(to bottom, black 90%, transparent 100%)' }}
|
||||
/>
|
||||
{/* Holo Scan Line */}
|
||||
<div className="absolute w-full h-2 bg-nofx-accent/30 drop-shadow-[0_0_10px_rgba(0,240,255,0.8)] top-0 animate-scan-fast pointer-events-none"></div>
|
||||
<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>
|
||||
|
||||
@@ -241,7 +289,7 @@ export default function TerminalHero() {
|
||||
<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"
|
||||
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>
|
||||
@@ -249,7 +297,7 @@ export default function TerminalHero() {
|
||||
<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"
|
||||
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>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, List, Link, AlertCircle } from 'lucide-react'
|
||||
import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
|
||||
// Default API URLs for data sources
|
||||
const DEFAULT_COIN_POOL_API_URL = 'http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c'
|
||||
const DEFAULT_OI_TOP_API_URL = 'http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
config: CoinSourceConfig
|
||||
onChange: (config: CoinSourceConfig) => void
|
||||
@@ -20,26 +16,23 @@ export function CoinSourceEditor({
|
||||
language,
|
||||
}: CoinSourceEditorProps) {
|
||||
const [newCoin, setNewCoin] = useState('')
|
||||
const [newExcludedCoin, setNewExcludedCoin] = useState('')
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||
static: { zh: '静态列表', en: 'Static List' },
|
||||
coinpool: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
|
||||
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
|
||||
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||
useCoinPool: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
||||
coinPoolLimit: { zh: '数据源数量上限', en: 'Data Provider Limit' },
|
||||
coinPoolApiUrl: { zh: 'AI500 API URL', en: 'AI500 API URL' },
|
||||
coinPoolApiUrlPlaceholder: { zh: '输入 AI500 数据源 API 地址...', en: 'Enter AI500 data provider API URL...' },
|
||||
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
||||
ai500Limit: { zh: '数量上限', en: 'Limit' },
|
||||
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||
oiTopLimit: { zh: 'OI Top 数量上限', en: 'OI Top Limit' },
|
||||
oiTopApiUrl: { zh: 'OI Top API URL', en: 'OI Top API URL' },
|
||||
oiTopApiUrlPlaceholder: { zh: '输入 OI Top 持仓数据 API 地址...', en: 'Enter OI Top API URL...' },
|
||||
oiTopLimit: { zh: '数量上限', en: 'Limit' },
|
||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||
coinpoolDesc: {
|
||||
ai500Desc: {
|
||||
zh: '使用 AI500 智能筛选的热门币种',
|
||||
en: 'Use AI500 smart-filtered popular coins',
|
||||
},
|
||||
@@ -51,16 +44,18 @@ export function CoinSourceEditor({
|
||||
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
||||
},
|
||||
apiUrlRequired: { zh: '需要填写 API URL 才能获取数据', en: 'API URL required to fetch data' },
|
||||
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||
fillDefault: { zh: '填入默认', en: 'Fill Default' },
|
||||
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
||||
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
||||
addExcludedCoin: { zh: '添加排除', en: 'Add Excluded' },
|
||||
nofxosNote: { zh: '使用 NofxOS API Key(在指标配置中设置)', en: 'Uses NofxOS API Key (set in Indicators config)' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const sourceTypes = [
|
||||
{ value: 'static', icon: List, color: '#848E9C' },
|
||||
{ value: 'coinpool', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||
] as const
|
||||
@@ -115,11 +110,50 @@ export function CoinSourceEditor({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddExcludedCoin = () => {
|
||||
if (!newExcludedCoin.trim()) return
|
||||
const symbol = newExcludedCoin.toUpperCase().trim()
|
||||
|
||||
// For xyz dex assets, use xyz: prefix without USDT
|
||||
let formattedSymbol: string
|
||||
if (isXyzDexAsset(symbol)) {
|
||||
const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '')
|
||||
formattedSymbol = `xyz:${base}`
|
||||
} else {
|
||||
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
}
|
||||
|
||||
const currentExcluded = config.excluded_coins || []
|
||||
if (!currentExcluded.includes(formattedSymbol)) {
|
||||
onChange({
|
||||
...config,
|
||||
excluded_coins: [...currentExcluded, formattedSymbol],
|
||||
})
|
||||
}
|
||||
setNewExcludedCoin('')
|
||||
}
|
||||
|
||||
const handleRemoveExcludedCoin = (coin: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
excluded_coins: (config.excluded_coins || []).filter((c) => c !== coin),
|
||||
})
|
||||
}
|
||||
|
||||
// NofxOS badge component
|
||||
const NofxOSBadge = () => (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30"
|
||||
>
|
||||
NofxOS
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Source Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{t('sourceType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@@ -131,24 +165,16 @@ export function CoinSourceEditor({
|
||||
onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
config.source_type === value
|
||||
? 'ring-2 ring-yellow-500'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
config.source_type === value
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: '#0B0E11',
|
||||
borderColor: '#2B3139',
|
||||
}}
|
||||
className={`p-4 rounded-lg border transition-all ${config.source_type === value
|
||||
? 'ring-2 ring-nofx-gold bg-nofx-gold/10'
|
||||
: 'hover:bg-white/5 bg-nofx-bg'
|
||||
} border-nofx-gold/20`}
|
||||
>
|
||||
<Icon className="w-6 h-6 mx-auto mb-2" style={{ color }} />
|
||||
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
<div className="text-sm font-medium text-nofx-text">
|
||||
{t(value)}
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
<div className="text-xs mt-1 text-nofx-text-muted">
|
||||
{t(`${value}Desc`)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -159,15 +185,14 @@ export function CoinSourceEditor({
|
||||
{/* Static Coins */}
|
||||
{(config.source_type === 'static' || config.source_type === 'mixed') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{t('staticCoins')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(config.static_coins || []).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm"
|
||||
style={{ background: '#2B3139', color: '#EAECEF' }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-bg-lighter text-nofx-text"
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
@@ -189,17 +214,11 @@ export function CoinSourceEditor({
|
||||
onChange={(e) => setNewCoin(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()}
|
||||
placeholder="BTC, ETH, SOL..."
|
||||
className="flex-1 px-4 py-2 rounded-lg"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddCoin}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors bg-nofx-gold text-black hover:bg-yellow-500"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('addCoin')}
|
||||
@@ -209,195 +228,172 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coin Pool Options */}
|
||||
{(config.source_type === 'coinpool' || config.source_type === 'mixed') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('dataSourceConfig')} - AI500
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_coin_pool}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_coin_pool: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-yellow-500"
|
||||
/>
|
||||
<span style={{ color: '#EAECEF' }}>{t('useCoinPool')}</span>
|
||||
</label>
|
||||
{config.use_coin_pool && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolLimit')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.coin_pool_limit || 10}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-20 px-3 py-1.5 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Excluded Coins */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ban className="w-4 h-4 text-nofx-danger" />
|
||||
<label className="text-sm font-medium text-nofx-text">
|
||||
{t('excludedCoins')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs mb-3 text-nofx-text-muted">
|
||||
{t('excludedCoinsDesc')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(config.excluded_coins || []).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-danger/15 text-nofx-danger"
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => handleRemoveExcludedCoin(coin)}
|
||||
className="ml-1 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(config.excluded_coins || []).length === 0 && (
|
||||
<span className="text-xs italic text-nofx-text-muted">
|
||||
{language === 'zh' ? '无' : 'None'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newExcludedCoin}
|
||||
onChange={(e) => setNewExcludedCoin(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddExcludedCoin()}
|
||||
placeholder="BTC, ETH, DOGE..."
|
||||
className="flex-1 px-4 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddExcludedCoin}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors text-sm bg-nofx-danger text-white hover:bg-red-600"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
{t('addExcludedCoin')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI500 Options */}
|
||||
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-nofx-gold" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
AI500 {t('dataSourceConfig')}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_coin_pool && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolApiUrl')}
|
||||
</label>
|
||||
{!disabled && !config.coin_pool_api_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, coin_pool_api_url: DEFAULT_COIN_POOL_API_URL })}
|
||||
className="text-xs px-2 py-1 rounded"
|
||||
style={{ background: '#F0B90B20', color: '#F0B90B' }}
|
||||
>
|
||||
{t('fillDefault')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="url"
|
||||
value={config.coin_pool_api_url || ''}
|
||||
type="checkbox"
|
||||
checked={config.use_ai500}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, coin_pool_api_url: e.target.value })
|
||||
!disabled && onChange({ ...config, use_ai500: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={t('coinPoolApiUrlPlaceholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
className="w-5 h-5 rounded accent-nofx-gold"
|
||||
/>
|
||||
{!config.coin_pool_api_url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{t('apiUrlRequired')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-nofx-text">{t('useAI500')}</span>
|
||||
</label>
|
||||
|
||||
{config.use_ai500 && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{t('ai500Limit')}:
|
||||
</span>
|
||||
<select
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{t('nofxosNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Top Options */}
|
||||
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('dataSourceConfig')} - OI Top
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-yellow-500"
|
||||
/>
|
||||
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
|
||||
</label>
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopLimit')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.oi_top_limit || 20}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-20 px-3 py-1.5 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
OI Top {t('dataSourceConfig')}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_oi_top && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopApiUrl')}
|
||||
</label>
|
||||
{!disabled && !config.oi_top_api_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, oi_top_api_url: DEFAULT_OI_TOP_API_URL })}
|
||||
className="text-xs px-2 py-1 rounded"
|
||||
style={{ background: '#0ECB8120', color: '#0ECB81' }}
|
||||
>
|
||||
{t('fillDefault')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="url"
|
||||
value={config.oi_top_api_url || ''}
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, oi_top_api_url: e.target.value })
|
||||
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={t('oiTopApiUrlPlaceholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
className="w-5 h-5 rounded accent-nofx-success"
|
||||
/>
|
||||
{!config.oi_top_api_url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{t('apiUrlRequired')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-nofx-text">{t('useOITop')}</span>
|
||||
</label>
|
||||
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{t('oiTopLimit')}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 20}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{t('nofxosNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Clock, Activity, Database, TrendingUp, BarChart2, Info, Lock, LineChart } from 'lucide-react'
|
||||
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
||||
import type { IndicatorConfig } from '../../types'
|
||||
|
||||
// Default API URL for quant data (must contain {symbol} placeholder)
|
||||
const DEFAULT_QUANT_DATA_API_URL = 'http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c'
|
||||
// Default API base URL for OI ranking data
|
||||
const DEFAULT_OI_RANKING_API_URL = 'http://nofxaios.com:30006'
|
||||
// Default NofxOS API Key
|
||||
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
||||
|
||||
interface IndicatorEditorProps {
|
||||
config: IndicatorConfig
|
||||
@@ -47,7 +45,7 @@ export function IndicatorEditor({
|
||||
marketSentiment: { zh: '市场情绪', en: 'Market Sentiment' },
|
||||
marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data' },
|
||||
quantData: { zh: '量化数据', en: 'Quant Data' },
|
||||
quantDataDesc: { zh: '第三方数据源:资金流向、大户动向', en: 'Third-party: netflow, whale movements' },
|
||||
quantDataDesc: { zh: '资金流向、大户动向', en: 'Netflow, whale movements' },
|
||||
|
||||
// Timeframes
|
||||
timeframes: { zh: '时间周期', en: 'Timeframes' },
|
||||
@@ -81,20 +79,40 @@ export function IndicatorEditor({
|
||||
fundingRate: { zh: '资金费率', en: 'Funding Rate' },
|
||||
fundingRateDesc: { zh: '永续合约资金费率', en: 'Perpetual funding rate' },
|
||||
|
||||
// Quant data
|
||||
quantDataUrl: { zh: '数据接口 URL', en: 'Data API URL' },
|
||||
fillDefault: { zh: '填入默认', en: 'Fill Default' },
|
||||
symbolPlaceholder: { zh: '{symbol} 会被替换为币种', en: '{symbol} will be replaced with coin' },
|
||||
|
||||
// OI Ranking
|
||||
oiRanking: { zh: 'OI 排行数据', en: 'OI Ranking Data' },
|
||||
oiRankingDesc: { zh: '市场持仓量增减排行,反映资金流向', en: 'Market-wide OI changes, reflects capital flow' },
|
||||
oiRankingDuration: { zh: '时间周期', en: 'Duration' },
|
||||
oiRankingLimit: { zh: '排行数量', en: 'Top N' },
|
||||
oiRanking: { zh: 'OI 排行', en: 'OI Ranking' },
|
||||
oiRankingDesc: { zh: '持仓量增减排行', en: 'OI change ranking' },
|
||||
oiRankingNote: { zh: '显示持仓量增加/减少的币种排行,帮助发现资金流向', en: 'Shows coins with OI increase/decrease, helps identify capital flow' },
|
||||
|
||||
// NetFlow Ranking
|
||||
netflowRanking: { zh: '资金流向', en: 'NetFlow' },
|
||||
netflowRankingDesc: { zh: '机构/散户资金流向', en: 'Institution/retail fund flow' },
|
||||
netflowRankingNote: { zh: '显示机构资金流入/流出排行,散户动向对比,发现聪明钱信号', en: 'Shows institution inflow/outflow ranking, retail flow comparison, Smart Money signals' },
|
||||
|
||||
// Price Ranking
|
||||
priceRanking: { zh: '涨跌幅排行', en: 'Price Ranking' },
|
||||
priceRankingDesc: { zh: '涨跌幅排行榜', en: 'Gainers/losers ranking' },
|
||||
priceRankingNote: { zh: '显示涨幅/跌幅排行,结合资金流和持仓变化分析趋势强度', en: 'Shows top gainers/losers, combined with fund flow and OI for trend analysis' },
|
||||
priceRankingMulti: { zh: '多周期', en: 'Multi-period' },
|
||||
|
||||
// Common settings
|
||||
duration: { zh: '周期', en: 'Duration' },
|
||||
limit: { zh: '数量', en: 'Limit' },
|
||||
|
||||
// Tips
|
||||
aiCanCalculate: { zh: '💡 提示:AI 可自行计算这些指标,开启可减少 AI 计算量', en: '💡 Tip: AI can calculate these, enabling reduces AI workload' },
|
||||
|
||||
// NofxOS Data Provider
|
||||
nofxosTitle: { zh: 'NofxOS 量化数据源', en: 'NofxOS Data Provider' },
|
||||
nofxosDesc: { zh: '专业加密货币量化数据服务', en: 'Professional crypto quant data service' },
|
||||
nofxosFeatures: { zh: 'AI500 · OI排行 · 资金流向 · 涨跌榜', en: 'AI500 · OI Ranking · Fund Flow · Price Ranking' },
|
||||
viewApiDocs: { zh: 'API 文档', en: 'API Docs' },
|
||||
apiKey: { zh: 'API Key', en: 'API Key' },
|
||||
apiKeyPlaceholder: { zh: '输入 NofxOS API Key', en: 'Enter NofxOS API Key' },
|
||||
fillDefault: { zh: '填入默认', en: 'Fill Default' },
|
||||
connected: { zh: '已配置', en: 'Configured' },
|
||||
notConfigured: { zh: '未配置', en: 'Not Configured' },
|
||||
nofxosDataSources: { zh: 'NofxOS 数据源', en: 'NofxOS Data Sources' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
@@ -166,9 +184,364 @@ export function IndicatorEditor({
|
||||
ensureRawKlines()
|
||||
}
|
||||
|
||||
// Check if any NofxOS feature is enabled
|
||||
const hasNofxosEnabled = config.enable_quant_data || config.enable_oi_ranking || config.enable_netflow_ranking || config.enable_price_ranking
|
||||
const hasApiKey = !!config.nofxos_api_key
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Section 1: Market Data (Required) */}
|
||||
{/* ============================================ */}
|
||||
{/* NofxOS Data Provider - Top Configuration */}
|
||||
{/* ============================================ */}
|
||||
<div
|
||||
className="rounded-lg overflow-hidden relative"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(168, 85, 247, 0.08) 50%, rgba(236, 72, 153, 0.08) 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Decorative gradient line at top */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ background: 'linear-gradient(90deg, #6366f1, #a855f7, #ec4899)' }}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #a855f7)' }}
|
||||
>
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{t('nofxosTitle')}
|
||||
</h3>
|
||||
<span className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{t('nofxosFeatures')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & API Docs */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasApiKey ? (
|
||||
<span className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
|
||||
<Check className="w-3 h-3" />
|
||||
{t('connected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full" style={{ background: 'rgba(246, 70, 93, 0.15)', color: '#F6465D' }}>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{t('notConfigured')}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="https://nofxos.ai/api-docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-all hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'rgba(139, 92, 246, 0.2)',
|
||||
color: '#a855f7',
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t('viewApiDocs')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={config.nofxos_api_key || ''}
|
||||
onChange={(e) => !disabled && onChange({ ...config, nofxos_api_key: e.target.value })}
|
||||
disabled={disabled}
|
||||
placeholder={t('apiKeyPlaceholder')}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono"
|
||||
style={{
|
||||
background: 'rgba(30, 35, 41, 0.8)',
|
||||
border: hasApiKey ? '1px solid rgba(14, 203, 129, 0.3)' : '1px solid rgba(139, 92, 246, 0.3)',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!disabled && !config.nofxos_api_key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, nofxos_api_key: DEFAULT_NOFXOS_API_KEY })}
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium transition-all hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{t('fillDefault')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NofxOS Data Sources Grid */}
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] font-medium mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('nofxosDataSources')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Quant Data */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_quant_data ? 'rgba(96, 165, 250, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_quant_data ? '1px solid rgba(96, 165, 250, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !disabled && onChange({ ...config, enable_quant_data: !config.enable_quant_data })}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#60a5fa' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_data || false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_data: e.target.checked }) }}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('quantDataDesc')}</p>
|
||||
{config.enable_quant_data && (
|
||||
<div className="flex gap-3 mt-2">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_oi !== false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_oi: e.target.checked }) }}
|
||||
disabled={disabled}
|
||||
className="w-3 h-3 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: '#EAECEF' }}>OI</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_netflow !== false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked }) }}
|
||||
disabled={disabled}
|
||||
className="w-3 h-3 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: '#EAECEF' }}>Netflow</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OI Ranking */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_oi_ranking ? 'rgba(34, 197, 94, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_oi_ranking ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !disabled && onChange({
|
||||
...config,
|
||||
enable_oi_ranking: !config.enable_oi_ranking,
|
||||
...(!config.enable_oi_ranking && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}),
|
||||
...(!config.enable_oi_ranking && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_oi_ranking || false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({
|
||||
...config,
|
||||
enable_oi_ranking: e.target.checked,
|
||||
...(e.target.checked && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}),
|
||||
...(e.target.checked && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}),
|
||||
}) }}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('oiRankingDesc')}</p>
|
||||
{config.enable_oi_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
value={config.oi_ranking_duration || '1h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
<select
|
||||
value={config.oi_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
|
||||
disabled={disabled}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NetFlow Ranking */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_netflow_ranking ? 'rgba(245, 158, 11, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_netflow_ranking ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !disabled && onChange({
|
||||
...config,
|
||||
enable_netflow_ranking: !config.enable_netflow_ranking,
|
||||
...(!config.enable_netflow_ranking && !config.netflow_ranking_duration ? { netflow_ranking_duration: '1h' } : {}),
|
||||
...(!config.enable_netflow_ranking && !config.netflow_ranking_limit ? { netflow_ranking_limit: 10 } : {}),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#f59e0b' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('netflowRanking')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_netflow_ranking || false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({
|
||||
...config,
|
||||
enable_netflow_ranking: e.target.checked,
|
||||
...(e.target.checked && !config.netflow_ranking_duration ? { netflow_ranking_duration: '1h' } : {}),
|
||||
...(e.target.checked && !config.netflow_ranking_limit ? { netflow_ranking_limit: 10 } : {}),
|
||||
}) }}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('netflowRankingDesc')}</p>
|
||||
{config.enable_netflow_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
value={config.netflow_ranking_duration || '1h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_duration: e.target.value })}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
<select
|
||||
value={config.netflow_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
|
||||
disabled={disabled}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price Ranking */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_price_ranking ? 'rgba(236, 72, 153, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_price_ranking ? '1px solid rgba(236, 72, 153, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !disabled && onChange({
|
||||
...config,
|
||||
enable_price_ranking: !config.enable_price_ranking,
|
||||
...(!config.enable_price_ranking && !config.price_ranking_duration ? { price_ranking_duration: '1h,4h,24h' } : {}),
|
||||
...(!config.enable_price_ranking && !config.price_ranking_limit ? { price_ranking_limit: 10 } : {}),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#ec4899' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('priceRanking')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_price_ranking || false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({
|
||||
...config,
|
||||
enable_price_ranking: e.target.checked,
|
||||
...(e.target.checked && !config.price_ranking_duration ? { price_ranking_duration: '1h,4h,24h' } : {}),
|
||||
...(e.target.checked && !config.price_ranking_limit ? { price_ranking_limit: 10 } : {}),
|
||||
}) }}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-pink-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('priceRankingDesc')}</p>
|
||||
{config.enable_price_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
value={config.price_ranking_duration || '1h,4h,24h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_duration: e.target.value })}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="1h,4h,24h">{t('priceRankingMulti')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={config.price_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
|
||||
disabled={disabled}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning if features enabled but no API key */}
|
||||
{hasNofxosEnabled && !hasApiKey && (
|
||||
<div className="flex items-center gap-2 mt-3 p-2 rounded-lg" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" style={{ color: '#F6465D' }} />
|
||||
<span className="text-[10px]" style={{ color: '#F6465D' }}>
|
||||
{language === 'zh' ? '请配置 API Key 以启用 NofxOS 数据源' : 'Please configure API Key to enable NofxOS data sources'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* Section 1: Market Data (Required) */}
|
||||
{/* ============================================ */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<BarChart2 className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
@@ -275,7 +648,9 @@ export function IndicatorEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Technical Indicators (Optional) */}
|
||||
{/* ============================================ */}
|
||||
{/* Section 2: Technical Indicators (Optional) */}
|
||||
{/* ============================================ */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
@@ -345,7 +720,9 @@ export function IndicatorEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Market Sentiment */}
|
||||
{/* ============================================ */}
|
||||
{/* Section 3: Market Sentiment */}
|
||||
{/* ============================================ */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<TrendingUp className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||
@@ -387,163 +764,6 @@ export function IndicatorEditor({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Quant Data (External API) */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<Database className="w-4 h-4" style={{ color: '#60a5fa' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>- {t('quantDataDesc')}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#60a5fa' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_data || false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, enable_quant_data: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
{config.enable_quant_data && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{t('quantDataUrl')}
|
||||
</label>
|
||||
{!disabled && !config.quant_data_api_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, quant_data_api_url: DEFAULT_QUANT_DATA_API_URL })}
|
||||
className="text-[10px] px-2 py-0.5 rounded"
|
||||
style={{ background: '#60a5fa20', color: '#60a5fa' }}
|
||||
>
|
||||
{t('fillDefault')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={config.quant_data_api_url || ''}
|
||||
onChange={(e) => !disabled && onChange({ ...config, quant_data_api_url: e.target.value })}
|
||||
disabled={disabled}
|
||||
placeholder="http://example.com/api/coin/{symbol}?include=netflow,oi"
|
||||
className="w-full px-2 py-1.5 rounded text-xs font-mono"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('symbolPlaceholder')}</p>
|
||||
|
||||
{/* OI and Netflow toggles */}
|
||||
<div className="flex gap-4 mt-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_oi !== false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, enable_quant_oi: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: '#EAECEF' }}>OI</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_netflow !== false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: '#EAECEF' }}>Netflow</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 5: OI Ranking Data (Market-wide) */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<LineChart className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>- {t('oiRankingDesc')}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_oi_ranking || false}
|
||||
onChange={(e) => !disabled && onChange({
|
||||
...config,
|
||||
enable_oi_ranking: e.target.checked,
|
||||
// Set defaults when enabling
|
||||
...(e.target.checked && !config.oi_ranking_api_url ? { oi_ranking_api_url: DEFAULT_OI_RANKING_API_URL } : {}),
|
||||
...(e.target.checked && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}),
|
||||
...(e.target.checked && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}),
|
||||
})}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
{config.enable_oi_ranking && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Duration */}
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
|
||||
{t('oiRankingDuration')}
|
||||
</label>
|
||||
<select
|
||||
value={config.oi_ranking_duration || '1h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
|
||||
disabled={disabled}
|
||||
className="w-full px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">{language === 'zh' ? '1小时' : '1 Hour'}</option>
|
||||
<option value="4h">{language === 'zh' ? '4小时' : '4 Hours'}</option>
|
||||
<option value="24h">{language === 'zh' ? '24小时' : '24 Hours'}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Limit */}
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
|
||||
{t('oiRankingLimit')}
|
||||
</label>
|
||||
<select
|
||||
value={config.oi_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
|
||||
disabled={disabled}
|
||||
className="w-full px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px]" style={{ color: '#5E6673' }}>{t('oiRankingNote')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
197
web/src/components/strategy/PublishSettingsEditor.tsx
Normal file
197
web/src/components/strategy/PublishSettingsEditor.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Globe, Lock, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
interface PublishSettingsEditorProps {
|
||||
isPublic: boolean
|
||||
configVisible: boolean
|
||||
onIsPublicChange: (value: boolean) => void
|
||||
onConfigVisibleChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
export function PublishSettingsEditor({
|
||||
isPublic,
|
||||
configVisible,
|
||||
onIsPublicChange,
|
||||
onConfigVisibleChange,
|
||||
disabled = false,
|
||||
language,
|
||||
}: PublishSettingsEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
publishToMarket: { zh: '发布到策略市场', en: 'Publish to Market' },
|
||||
publishDesc: { zh: '策略将在市场公开展示,其他用户可发现并使用', en: 'Strategy will be publicly visible in the marketplace' },
|
||||
showConfig: { zh: '公开配置参数', en: 'Show Config' },
|
||||
showConfigDesc: { zh: '允许他人查看和复制详细配置', en: 'Allow others to view and clone config details' },
|
||||
private: { zh: '私有', en: 'PRIVATE' },
|
||||
public: { zh: '公开', en: 'PUBLIC' },
|
||||
hidden: { zh: '隐藏', en: 'HIDDEN' },
|
||||
visible: { zh: '可见', en: 'VISIBLE' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 发布开关 */}
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
style={{
|
||||
background: isPublic
|
||||
? 'linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.05) 100%)'
|
||||
: 'linear-gradient(135deg, #1E2329 0%, #0B0E11 100%)',
|
||||
border: isPublic ? '1px solid rgba(14, 203, 129, 0.4)' : '1px solid #2B3139',
|
||||
boxShadow: isPublic ? '0 0 20px rgba(14, 203, 129, 0.1)' : 'none',
|
||||
}}
|
||||
onClick={() => !disabled && onIsPublicChange(!isPublic)}
|
||||
>
|
||||
{/* Top glow line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-[1px] transition-opacity duration-300"
|
||||
style={{
|
||||
background: isPublic
|
||||
? 'linear-gradient(90deg, transparent, #0ECB81, transparent)'
|
||||
: 'linear-gradient(90deg, transparent, #2B3139, transparent)',
|
||||
opacity: isPublic ? 1 : 0.5
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all duration-300"
|
||||
style={{
|
||||
background: isPublic ? 'rgba(14, 203, 129, 0.2)' : '#0B0E11',
|
||||
border: isPublic ? '1px solid rgba(14, 203, 129, 0.3)' : '1px solid #2B3139'
|
||||
}}
|
||||
>
|
||||
{isPublic ? (
|
||||
<Globe className="w-5 h-5" style={{ color: '#0ECB81' }} />
|
||||
) : (
|
||||
<Lock className="w-5 h-5" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('publishToMarket')}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('publishDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle with status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-[10px] font-mono font-bold tracking-wider"
|
||||
style={{ color: isPublic ? '#0ECB81' : '#848E9C' }}
|
||||
>
|
||||
{isPublic ? t('public') : t('private')}
|
||||
</span>
|
||||
<div
|
||||
className="relative w-12 h-6 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: isPublic
|
||||
? 'linear-gradient(90deg, #0ECB81, #4ade80)'
|
||||
: '#2B3139',
|
||||
boxShadow: isPublic ? '0 0 10px rgba(14, 203, 129, 0.4)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 w-4 h-4 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: '#EAECEF',
|
||||
left: isPublic ? '28px' : '4px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置可见性开关 - 仅在公开时显示 */}
|
||||
{isPublic && (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
style={{
|
||||
background: configVisible
|
||||
? 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(168, 85, 247, 0.05) 100%)'
|
||||
: 'linear-gradient(135deg, #1E2329 0%, #0B0E11 100%)',
|
||||
border: configVisible ? '1px solid rgba(168, 85, 247, 0.4)' : '1px solid #2B3139',
|
||||
boxShadow: configVisible ? '0 0 20px rgba(168, 85, 247, 0.1)' : 'none',
|
||||
}}
|
||||
onClick={() => !disabled && onConfigVisibleChange(!configVisible)}
|
||||
>
|
||||
{/* Top glow line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-[1px] transition-opacity duration-300"
|
||||
style={{
|
||||
background: configVisible
|
||||
? 'linear-gradient(90deg, transparent, #a855f7, transparent)'
|
||||
: 'linear-gradient(90deg, transparent, #2B3139, transparent)',
|
||||
opacity: configVisible ? 1 : 0.5
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all duration-300"
|
||||
style={{
|
||||
background: configVisible ? 'rgba(168, 85, 247, 0.2)' : '#0B0E11',
|
||||
border: configVisible ? '1px solid rgba(168, 85, 247, 0.3)' : '1px solid #2B3139'
|
||||
}}
|
||||
>
|
||||
{configVisible ? (
|
||||
<Eye className="w-5 h-5" style={{ color: '#a855f7' }} />
|
||||
) : (
|
||||
<EyeOff className="w-5 h-5" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('showConfig')}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('showConfigDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle with status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-[10px] font-mono font-bold tracking-wider"
|
||||
style={{ color: configVisible ? '#a855f7' : '#848E9C' }}
|
||||
>
|
||||
{configVisible ? t('visible') : t('hidden')}
|
||||
</span>
|
||||
<div
|
||||
className="relative w-12 h-6 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: configVisible
|
||||
? 'linear-gradient(90deg, #a855f7, #c084fc)'
|
||||
: '#2B3139',
|
||||
boxShadow: configVisible ? '0 0 10px rgba(168, 85, 247, 0.4)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 w-4 h-4 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: '#EAECEF',
|
||||
left: configVisible ? '28px' : '4px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishSettingsEditor
|
||||
@@ -18,11 +18,11 @@ export const translations = {
|
||||
view: 'View',
|
||||
|
||||
// Navigation
|
||||
realtimeNav: 'Live',
|
||||
realtimeNav: 'Leaderboard',
|
||||
configNav: 'Config',
|
||||
dashboardNav: 'Dashboard',
|
||||
strategyNav: 'Strategy',
|
||||
debateNav: 'Debate Arena',
|
||||
debateNav: 'Arena',
|
||||
faqNav: 'FAQ',
|
||||
|
||||
// Footer
|
||||
@@ -504,7 +504,7 @@ export const translations = {
|
||||
noExchangesConfigured: 'No configured exchanges',
|
||||
signalSource: 'Signal Source',
|
||||
signalSourceConfig: 'Signal Source Configuration',
|
||||
coinPoolDescription:
|
||||
ai500Description:
|
||||
'API endpoint for AI500 data provider, leave blank to disable this signal source',
|
||||
oiTopDescription:
|
||||
'API endpoint for open interest rankings, leave blank to disable this signal source',
|
||||
@@ -784,7 +784,7 @@ export const translations = {
|
||||
candidateCoins: 'Candidate Coins',
|
||||
candidateCoinsZeroWarning: 'Candidate Coins Count is 0',
|
||||
possibleReasons: 'Possible Reasons:',
|
||||
coinPoolApiNotConfigured:
|
||||
ai500ApiNotConfigured:
|
||||
'AI500 data provider API not configured or inaccessible (check signal source settings)',
|
||||
apiConnectionTimeout: 'API connection timeout or returned empty data',
|
||||
noCustomCoinsAndApiFailed:
|
||||
@@ -792,7 +792,7 @@ export const translations = {
|
||||
solutions: 'Solutions:',
|
||||
setCustomCoinsInConfig: 'Set custom coin list in trader configuration',
|
||||
orConfigureCorrectApiUrl: 'Or configure correct data provider API address',
|
||||
orDisableCoinPoolOptions:
|
||||
orDisableAI500Options:
|
||||
'Or disable "Use AI500 Data Provider" and "Use OI Top" options',
|
||||
signalSourceNotConfigured: 'Signal Source Not Configured',
|
||||
signalSourceWarningMessage:
|
||||
@@ -1226,11 +1226,11 @@ export const translations = {
|
||||
view: '查看',
|
||||
|
||||
// Navigation
|
||||
realtimeNav: '实时',
|
||||
realtimeNav: '排行榜',
|
||||
configNav: '配置',
|
||||
dashboardNav: '看板',
|
||||
strategyNav: '策略',
|
||||
debateNav: '行情辩论',
|
||||
debateNav: '竞技场',
|
||||
faqNav: '常见问题',
|
||||
|
||||
// Footer
|
||||
@@ -1691,7 +1691,7 @@ export const translations = {
|
||||
noExchangesConfigured: '暂无已配置的交易所',
|
||||
signalSource: '信号源',
|
||||
signalSourceConfig: '信号源配置',
|
||||
coinPoolDescription:
|
||||
ai500Description:
|
||||
'用于获取 AI500 数据源的 API 地址,留空则不使用此数据源',
|
||||
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
|
||||
information: '说明',
|
||||
@@ -1939,14 +1939,14 @@ export const translations = {
|
||||
candidateCoins: '候选币种',
|
||||
candidateCoinsZeroWarning: '候选币种数量为 0',
|
||||
possibleReasons: '可能原因:',
|
||||
coinPoolApiNotConfigured:
|
||||
ai500ApiNotConfigured:
|
||||
'AI500 数据源 API 未配置或无法访问(请检查信号源设置)',
|
||||
apiConnectionTimeout: 'API连接超时或返回数据为空',
|
||||
noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败',
|
||||
solutions: '解决方案:',
|
||||
setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表',
|
||||
orConfigureCorrectApiUrl: '或者配置正确的数据源 API 地址',
|
||||
orDisableCoinPoolOptions: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项',
|
||||
orDisableAI500Options: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项',
|
||||
signalSourceNotConfigured: '信号源未配置',
|
||||
signalSourceWarningMessage:
|
||||
'您有交易员启用了"使用 AI500 数据源"或"使用 OI Top",但尚未配置信号源 API 地址。这将导致候选币种数量为 0,交易员无法正常工作。',
|
||||
|
||||
@@ -439,11 +439,15 @@ button:disabled {
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Premium Input Styles */
|
||||
/* Premium Input Styles */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color: var(--panel-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
@@ -452,6 +456,35 @@ textarea:focus {
|
||||
border-color: var(--binance-yellow);
|
||||
box-shadow: 0 0 0 2px rgba(252, 213, 53, 0.15);
|
||||
outline: none;
|
||||
background-color: var(--panel-bg-hover);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Specific Premium Input Utility */
|
||||
.premium-input {
|
||||
background: rgba(11, 14, 17, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.premium-input:hover {
|
||||
border-color: rgba(240, 185, 11, 0.3);
|
||||
background: rgba(11, 14, 17, 0.8);
|
||||
}
|
||||
|
||||
.premium-input:focus {
|
||||
border-color: var(--binance-yellow);
|
||||
box-shadow: 0 0 0 1px rgba(240, 185, 11, 0.2), 0 0 15px rgba(240, 185, 11, 0.1);
|
||||
background: rgba(11, 14, 17, 0.9);
|
||||
}
|
||||
|
||||
/* Binance Card - Premium Polish */
|
||||
@@ -846,4 +879,103 @@ tr:hover {
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Holographic Character Effects - Premium Polish */
|
||||
@keyframes holo-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: hue-rotate(0deg) contrast(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: hue-rotate(15deg) contrast(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: hue-rotate(0deg) contrast(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes holo-flicker {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
5% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-holo {
|
||||
background-size: 200% 200%;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
.holo-overlay {
|
||||
/* Complex gradient + noise for texture */
|
||||
background:
|
||||
url('https://grainy-gradients.vercel.app/noise.svg'),
|
||||
conic-gradient(from 0deg at 50% 50%,
|
||||
rgba(240, 185, 11, 0.1) 0deg,
|
||||
rgba(0, 240, 255, 0.1) 120deg,
|
||||
rgba(240, 185, 11, 0.1) 240deg,
|
||||
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));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../components/DeepVoidBackground'
|
||||
|
||||
// Translations
|
||||
const T: Record<string, Record<string, string>> = {
|
||||
@@ -144,7 +145,7 @@ function MessageCard({ msg }: { msg: DebateMessage }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded-lg hover:bg-white/5 transition-all border border-white/5"
|
||||
className="p-3 rounded-lg hover:bg-nofx-bg-lighter/60 transition-all border border-nofx-gold/20 backdrop-blur-sm bg-nofx-bg-lighter/20"
|
||||
style={{ borderLeft: `3px solid ${p.color}` }}
|
||||
>
|
||||
{/* Header - Always visible */}
|
||||
@@ -153,16 +154,16 @@ function MessageCard({ msg }: { msg: DebateMessage }) {
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<AIAvatar name={msg.ai_model_name} size={24} />
|
||||
<span className="text-sm text-white font-medium">{msg.ai_model_name}</span>
|
||||
<span className="text-xs text-gray-500">{p.nameEn}</span>
|
||||
<span className="text-sm text-nofx-text font-medium">{msg.ai_model_name}</span>
|
||||
<span className="text-xs text-nofx-text-muted">{p.nameEn}</span>
|
||||
<div className="flex-1" />
|
||||
{msg.decision && (
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${a.bg} ${a.color}`}>
|
||||
{a.icon} {msg.decision.symbol || ''} {a.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-yellow-400 font-medium">{msg.decision?.confidence || msg.confidence}%</span>
|
||||
{open ? <ChevronUp size={14} className="text-gray-500" /> : <ChevronDown size={14} className="text-gray-500" />}
|
||||
<span className="text-xs text-nofx-gold font-medium">{msg.decision?.confidence || msg.confidence}%</span>
|
||||
{open ? <ChevronUp size={14} className="text-nofx-text-muted" /> : <ChevronDown size={14} className="text-nofx-text-muted" />}
|
||||
</div>
|
||||
|
||||
{/* Preview when collapsed */}
|
||||
@@ -277,13 +278,13 @@ function VoteCard({ vote }: { vote: { ai_model_name: string; action: string; sym
|
||||
const a = ACT[vote.action] || ACT.wait
|
||||
const confColor = vote.confidence >= 70 ? 'bg-green-500' : vote.confidence >= 50 ? 'bg-yellow-500' : 'bg-gray-500'
|
||||
return (
|
||||
<div className="bg-[#1a1f2e] rounded-xl p-4 border border-white/10 hover:border-white/20 transition-all">
|
||||
<div className="bg-nofx-bg-lighter/40 backdrop-blur-md rounded-xl p-4 border border-nofx-gold/20 hover:border-nofx-gold/50 transition-all shadow-lg hover:shadow-[0_0_20px_rgba(240,185,11,0.1)]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AIAvatar name={vote.ai_model_name} size={28} />
|
||||
<div>
|
||||
<span className="text-white font-semibold block">{vote.ai_model_name}</span>
|
||||
{vote.symbol && <span className="text-xs text-gray-400">{vote.symbol}</span>}
|
||||
<span className="text-nofx-text font-semibold block">{vote.ai_model_name}</span>
|
||||
{vote.symbol && <span className="text-xs text-nofx-text-muted">{vote.symbol}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold ${a.bg} ${a.color}`}>
|
||||
@@ -300,13 +301,13 @@ function VoteCard({ vote }: { vote: { ai_model_name: string; action: string; sym
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Leverage</span><span className="text-white font-semibold">{vote.leverage || '-'}x</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Position</span><span className="text-white font-semibold">{vote.position_pct ? `${(vote.position_pct * 100).toFixed(0)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">SL</span><span className="text-red-400 font-semibold">{vote.stop_loss_pct ? `${(vote.stop_loss_pct * 100).toFixed(1)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">TP</span><span className="text-green-400 font-semibold">{vote.take_profit_pct ? `${(vote.take_profit_pct * 100).toFixed(1)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-nofx-text-muted">Leverage</span><span className="text-nofx-text font-semibold">{vote.leverage || '-'}x</span></div>
|
||||
<div className="flex justify-between"><span className="text-nofx-text-muted">Position</span><span className="text-nofx-text font-semibold">{vote.position_pct ? `${(vote.position_pct * 100).toFixed(0)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-nofx-text-muted">SL</span><span className="text-red-400 font-semibold">{vote.stop_loss_pct ? `${(vote.stop_loss_pct * 100).toFixed(1)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-nofx-text-muted">TP</span><span className="text-green-400 font-semibold">{vote.take_profit_pct ? `${(vote.take_profit_pct * 100).toFixed(1)}%` : '-'}</span></div>
|
||||
</div>
|
||||
{vote.reasoning && (
|
||||
<p className="mt-3 text-xs text-gray-400 leading-relaxed line-clamp-2 border-t border-white/5 pt-2">{vote.reasoning}</p>
|
||||
<p className="mt-3 text-xs text-nofx-text-muted leading-relaxed line-clamp-2 border-t border-nofx-gold/10 pt-2">{vote.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -386,22 +387,22 @@ function CreateModal({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div className="bg-[#1a1d24] rounded-xl w-full max-w-md p-4 border border-white/10">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="bg-nofx-bg-lighter/90 backdrop-blur-xl rounded-xl w-full max-w-md p-6 border border-nofx-gold/30 shadow-2xl shadow-nofx-gold/10">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold text-white">{t('createDebate', language)}</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
<h3 className="text-lg font-bold text-nofx-text">{t('createDebate', language)}</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-nofx-text-muted" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder={t('debateName', language)} className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm"
|
||||
placeholder={t('debateName', language)} className="w-full px-3 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text text-sm outline-none focus:border-nofx-gold"
|
||||
/>
|
||||
|
||||
{/* Strategy selector - moved up */}
|
||||
<select value={strategyId} onChange={e => setStrategyId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
|
||||
className="w-full px-3 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text text-sm outline-none focus:border-nofx-gold">
|
||||
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -409,17 +410,17 @@ function CreateModal({
|
||||
{/* Show dropdown only for static type with coins defined */}
|
||||
{isStaticWithCoins ? (
|
||||
<select value={symbol} onChange={e => setSymbol(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text text-sm outline-none focus:border-nofx-gold">
|
||||
{staticCoins.map(coin => <option key={coin} value={coin}>{coin}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-gray-400 text-sm">
|
||||
<div className="flex-1 px-3 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text-muted text-sm">
|
||||
{language === 'zh' ? '根据策略规则自动选择' : 'Auto-selected by strategy'}
|
||||
</div>
|
||||
)}
|
||||
<select value={maxRounds} onChange={e => setMaxRounds(+e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
|
||||
{[2,3,4,5].map(n => <option key={n} value={n}>{n} {language === 'zh' ? '轮' : 'rounds'}</option>)}
|
||||
className="px-3 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text text-sm outline-none focus:border-nofx-gold">
|
||||
{[2, 3, 4, 5].map(n => <option key={n} value={n}>{n} {language === 'zh' ? '轮' : 'rounds'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -431,7 +432,7 @@ function CreateModal({
|
||||
{/* Personality selector */}
|
||||
<select value={p.personality} onChange={e => {
|
||||
const up = [...participants]; up[i].personality = e.target.value as DebatePersonality; setParticipants(up)
|
||||
}} className="bg-transparent text-white text-xs border-0 outline-none cursor-pointer">
|
||||
}} className="bg-transparent text-nofx-text text-xs border-0 outline-none cursor-pointer">
|
||||
{Object.entries(PERS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.emoji} {language === 'zh' ? v.name : v.nameEn}</option>
|
||||
))}
|
||||
@@ -439,23 +440,23 @@ function CreateModal({
|
||||
{/* AI model selector */}
|
||||
<select value={p.ai_model_id} onChange={e => {
|
||||
const up = [...participants]; up[i].ai_model_id = e.target.value; setParticipants(up)
|
||||
}} className="bg-transparent text-white text-xs border-0 outline-none">
|
||||
}} className="bg-transparent text-nofx-text text-xs border-0 outline-none">
|
||||
{aiModels.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
<button onClick={() => setParticipants(participants.filter((_, j) => j !== i))}
|
||||
className="text-red-400 hover:text-red-300"><X size={12} /></button>
|
||||
className="text-nofx-danger hover:text-red-300"><X size={12} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addP} className="px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-500/10 rounded">
|
||||
<button onClick={addP} className="px-2 py-1 text-xs text-nofx-gold hover:bg-nofx-gold/10 rounded">
|
||||
+ {t('addAI', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={onClose} className="flex-1 py-2 rounded-lg bg-white/5 text-white text-sm">{t('cancel', language)}</button>
|
||||
<button onClick={onClose} className="flex-1 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text text-sm hover:bg-nofx-bg-lighter transition-colors">{t('cancel', language)}</button>
|
||||
<button onClick={submit} disabled={creating}
|
||||
className="flex-1 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm disabled:opacity-50">
|
||||
className="flex-1 py-2 rounded-lg bg-nofx-gold text-black font-semibold text-sm disabled:opacity-50 hover:bg-yellow-500 transition-colors">
|
||||
{creating ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('create', language)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -536,26 +537,27 @@ export function DebateArenaPage() {
|
||||
const voteSum = votes.reduce((a, v) => { a[v.action] = (a[v.action] || 0) + 1; return a }, {} as Record<string, number>)
|
||||
|
||||
return (
|
||||
<div className="h-full bg-[#0a0c10] flex overflow-hidden">
|
||||
<DeepVoidBackground className="h-full flex overflow-hidden relative" disableAnimation>
|
||||
|
||||
{/* Left - Debate List + Online Traders */}
|
||||
<div className="w-56 flex-shrink-0 bg-[#0d1017] border-r border-white/5 flex flex-col">
|
||||
<div className="w-56 flex-shrink-0 bg-nofx-bg/80 backdrop-blur-md border-r border-nofx-gold/20 flex flex-col z-10">
|
||||
{/* New Debate Button */}
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="m-2 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm flex items-center justify-center gap-1">
|
||||
className="m-2 py-2 rounded-lg bg-nofx-gold text-black font-semibold text-sm flex items-center justify-center gap-1 hover:bg-yellow-500 transition-colors">
|
||||
<Plus size={16} /> {t('newDebate', language)}
|
||||
</button>
|
||||
|
||||
{/* Debate List */}
|
||||
<div className="px-2 py-1 text-xs text-gray-500 font-semibold">{t('debateSessions', language)}</div>
|
||||
<div className="px-2 py-1 text-xs text-nofx-text-muted font-semibold">{t('debateSessions', language)}</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: '30%' }}>
|
||||
{debates?.map(d => (
|
||||
<div key={d.id} onClick={() => setSelectedId(d.id)}
|
||||
className={`p-2 cursor-pointer border-l-2 ${selectedId === d.id ? 'bg-yellow-500/10 border-yellow-500' : 'border-transparent hover:bg-white/5'}`}>
|
||||
className={`p-2 cursor-pointer border-l-2 transition-all ${selectedId === d.id ? 'bg-nofx-gold/10 border-nofx-gold shadow-[inset_10px_0_20px_-10px_rgba(240,185,11,0.2)]' : 'border-transparent hover:bg-nofx-bg-lighter/50'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${STATUS_COLOR[d.status]}`} />
|
||||
<span className="text-sm text-white truncate flex-1">{d.name}</span>
|
||||
<span className="text-sm text-nofx-text truncate flex-1">{d.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{d.symbol} · R{d.current_round}/{d.max_rounds}</div>
|
||||
<div className="text-xs text-nofx-text-muted mt-1">{d.symbol} · R{d.current_round}/{d.max_rounds}</div>
|
||||
{d.status === 'pending' && selectedId === d.id && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button onClick={e => { e.stopPropagation(); onStart(d.id) }}
|
||||
@@ -569,41 +571,41 @@ export function DebateArenaPage() {
|
||||
</div>
|
||||
|
||||
{/* Online Traders Section */}
|
||||
<div className="flex-1 border-t border-white/5 mt-2 overflow-hidden flex flex-col">
|
||||
<div className="px-2 py-2 text-xs text-gray-500 font-semibold flex items-center gap-1">
|
||||
<Zap size={12} className="text-green-400" />
|
||||
<div className="flex-1 border-t border-nofx-gold/20 mt-2 overflow-hidden flex flex-col">
|
||||
<div className="px-2 py-2 text-xs text-nofx-text-muted font-semibold flex items-center gap-1">
|
||||
<Zap size={12} className="text-nofx-success" />
|
||||
{t('onlineTraders', language)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 space-y-2">
|
||||
{traders?.filter(tr => tr.is_running).map(tr => (
|
||||
<div key={tr.trader_id}
|
||||
onClick={() => { setTraderId(tr.trader_id); if (decision && !decision.executed) setExecId(detail?.id || null) }}
|
||||
className={`p-2 rounded-lg cursor-pointer transition-all ${traderId === tr.trader_id ? 'bg-green-500/20 ring-1 ring-green-500' : 'bg-white/5 hover:bg-white/10'}`}>
|
||||
className={`p-2 rounded-lg cursor-pointer transition-all ${traderId === tr.trader_id ? 'bg-nofx-success/20 ring-1 ring-nofx-success' : 'bg-nofx-bg-lighter hover:bg-nofx-bg-light'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{tr.ai_model}</div>
|
||||
<div className="text-sm text-nofx-text font-medium truncate">{tr.trader_name}</div>
|
||||
<div className="text-xs text-nofx-text-muted truncate">{tr.ai_model}</div>
|
||||
</div>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="w-2 h-2 rounded-full bg-nofx-success animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{traders?.filter(tr => !tr.is_running).slice(0, 3).map(tr => (
|
||||
<div key={tr.trader_id} className="p-2 rounded-lg bg-white/5 opacity-50">
|
||||
<div key={tr.trader_id} className="p-2 rounded-lg bg-nofx-bg-lighter opacity-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grayscale">
|
||||
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div>
|
||||
<div className="text-xs text-gray-500">{t('offline', language)}</div>
|
||||
<div className="text-sm text-nofx-text font-medium truncate">{tr.trader_name}</div>
|
||||
<div className="text-xs text-nofx-text-muted">{t('offline', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!traders || traders.length === 0) && (
|
||||
<div className="text-xs text-gray-500 text-center py-4">{t('noTraders', language)}</div>
|
||||
<div className="text-xs text-nofx-text-muted text-center py-4">{t('noTraders', language)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -614,12 +616,12 @@ export function DebateArenaPage() {
|
||||
{detail ? (
|
||||
<>
|
||||
{/* Header Bar - Compact */}
|
||||
<div className="px-3 py-2 border-b border-white/5 bg-[#0d1017]/50 flex items-center gap-3 flex-shrink-0">
|
||||
<div className="px-3 py-2 border-b border-nofx-gold/20 bg-nofx-bg/60 backdrop-blur-md flex items-center gap-3 flex-shrink-0 shadow-sm">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_COLOR[detail.status]}`} />
|
||||
<span className="font-bold text-white truncate">{detail.name}</span>
|
||||
<span className="text-yellow-400 font-semibold">{detail.symbol}</span>
|
||||
<span className="font-bold text-nofx-text truncate">{detail.name}</span>
|
||||
<span className="text-nofx-gold font-semibold">{detail.symbol}</span>
|
||||
{strategyName && <span className="text-xs px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{strategyName}</span>}
|
||||
<span className="text-xs text-gray-500">R{detail.current_round}/{detail.max_rounds}</span>
|
||||
<span className="text-xs text-nofx-text-muted">R{detail.current_round}/{detail.max_rounds}</span>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex gap-1 ml-2">
|
||||
@@ -627,7 +629,7 @@ export function DebateArenaPage() {
|
||||
const vote = votes.find(v => v.ai_model_id === p.ai_model_id)
|
||||
const act = vote ? (ACT[vote.action] || ACT.wait) : null
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-1 px-1 py-0.5 rounded bg-white/5 text-xs">
|
||||
<div key={p.id} className="flex items-center gap-1 px-1 py-0.5 rounded bg-nofx-bg-lighter text-xs">
|
||||
<AIAvatar name={p.ai_model_name} size={14} />
|
||||
{act && <span className={`${act.color}`}>{act.icon}</span>}
|
||||
</div>
|
||||
@@ -662,8 +664,8 @@ export function DebateArenaPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Left - Rounds */}
|
||||
<div className="flex-1 overflow-y-auto p-4 border-r border-white/5">
|
||||
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2">
|
||||
<div className="flex-1 overflow-y-auto p-4 border-r border-nofx-gold/20">
|
||||
<div className="text-sm text-nofx-text-muted font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
{t('discussionRecords', language)}
|
||||
</div>
|
||||
@@ -681,9 +683,9 @@ export function DebateArenaPage() {
|
||||
|
||||
{/* Right - Votes */}
|
||||
{votes.length > 0 && (
|
||||
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 bg-[#0d1017]/50">
|
||||
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2">
|
||||
<Trophy size={16} className="text-yellow-400" />
|
||||
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 bg-nofx-bg/30 backdrop-blur-sm">
|
||||
<div className="text-sm text-nofx-text-muted font-semibold mb-3 flex items-center gap-2">
|
||||
<Trophy size={16} className="text-nofx-gold" />
|
||||
{t('finalVotes', language)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -709,37 +711,37 @@ export function DebateArenaPage() {
|
||||
|
||||
{/* Consensus Bar - Show when votes exist */}
|
||||
{(decision || votes.length > 0) && (
|
||||
<div className="p-3 border-t border-white/5 bg-gradient-to-r from-yellow-500/10 to-orange-500/10 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="p-3 border-t border-nofx-gold/20 bg-gradient-to-r from-nofx-gold/10 via-nofx-bg-lighter/50 to-orange-500/10 backdrop-blur-md flex items-center gap-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={20} className="text-yellow-400" />
|
||||
<span className="text-sm text-gray-400">{t('consensus', language)}:</span>
|
||||
<Trophy size={20} className="text-nofx-gold" />
|
||||
<span className="text-sm text-nofx-text-muted">{t('consensus', language)}:</span>
|
||||
{decision ? (
|
||||
<>
|
||||
{decision.symbol && <span className="text-yellow-400 font-bold mr-1">{decision.symbol}</span>}
|
||||
{decision.symbol && <span className="text-nofx-gold font-bold mr-1">{decision.symbol}</span>}
|
||||
<span className={`flex items-center gap-1 px-2 py-1 rounded font-bold ${(ACT[decision.action] || ACT.wait).bg} ${(ACT[decision.action] || ACT.wait).color}`}>
|
||||
{(ACT[decision.action] || ACT.wait).icon}
|
||||
{decision.action.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded font-bold bg-gray-500/20 text-gray-400">
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded font-bold bg-nofx-text-muted/20 text-nofx-text-muted">
|
||||
<Clock size={14} /> VOTING...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{decision && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span><span className="text-gray-500">{t('confidence', language)}</span> <span className="text-yellow-400 font-bold">{decision.confidence || 0}%</span></span>
|
||||
{(decision.leverage ?? 0) > 0 && <span><span className="text-gray-500">{t('leverage', language)}</span> <span className="text-white font-bold">{decision.leverage}x</span></span>}
|
||||
{(decision.position_pct ?? 0) > 0 && <span><span className="text-gray-500">{t('position', language)}</span> <span className="text-white font-bold">{((decision.position_pct ?? 0) * 100).toFixed(0)}%</span></span>}
|
||||
{(decision.stop_loss ?? 0) > 0 && <span><span className="text-gray-500">SL</span> <span className="text-red-400 font-bold">{((decision.stop_loss ?? 0) * 100).toFixed(1)}%</span></span>}
|
||||
{(decision.take_profit ?? 0) > 0 && <span><span className="text-gray-500">TP</span> <span className="text-green-400 font-bold">{((decision.take_profit ?? 0) * 100).toFixed(1)}%</span></span>}
|
||||
<span><span className="text-nofx-text-muted">{t('confidence', language)}</span> <span className="text-nofx-gold font-bold">{decision.confidence || 0}%</span></span>
|
||||
{(decision.leverage ?? 0) > 0 && <span><span className="text-nofx-text-muted">{t('leverage', language)}</span> <span className="text-nofx-text font-bold">{decision.leverage}x</span></span>}
|
||||
{(decision.position_pct ?? 0) > 0 && <span><span className="text-nofx-text-muted">{t('position', language)}</span> <span className="text-nofx-text font-bold">{((decision.position_pct ?? 0) * 100).toFixed(0)}%</span></span>}
|
||||
{(decision.stop_loss ?? 0) > 0 && <span><span className="text-nofx-text-muted">SL</span> <span className="text-red-400 font-bold">{((decision.stop_loss ?? 0) * 100).toFixed(1)}%</span></span>}
|
||||
{(decision.take_profit ?? 0) > 0 && <span><span className="text-nofx-text-muted">TP</span> <span className="text-green-400 font-bold">{((decision.take_profit ?? 0) * 100).toFixed(1)}%</span></span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{decision && !decision.executed && (decision.action === 'open_long' || decision.action === 'open_short') && (
|
||||
<button onClick={() => setExecId(detail.id)}
|
||||
className="px-4 py-1.5 rounded-lg bg-yellow-500 text-black font-semibold text-sm flex items-center gap-1">
|
||||
className="px-4 py-1.5 rounded-lg bg-nofx-gold text-black font-semibold text-sm flex items-center gap-1 hover:bg-yellow-500 transition-colors">
|
||||
<Zap size={14} /> {t('execute', language)}
|
||||
</button>
|
||||
)}
|
||||
@@ -748,7 +750,7 @@ export function DebateArenaPage() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="flex-1 flex items-center justify-center text-nofx-text-muted">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">🗳️</div>
|
||||
<div>{t('selectOrCreate', language)}</div>
|
||||
@@ -763,13 +765,13 @@ export function DebateArenaPage() {
|
||||
|
||||
{/* Execute Modal */}
|
||||
{execId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div className="bg-[#1a1d24] rounded-xl w-full max-w-sm p-4 border border-white/10">
|
||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="text-yellow-400" /> {t('executeTitle', language)}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="bg-nofx-bg-lighter/90 backdrop-blur-xl rounded-xl w-full max-w-sm p-6 border border-nofx-gold/30 shadow-2xl shadow-nofx-gold/10">
|
||||
<h3 className="text-lg font-bold text-nofx-text mb-4 flex items-center gap-2">
|
||||
<Zap className="text-nofx-gold" /> {t('executeTitle', language)}
|
||||
</h3>
|
||||
<select value={traderId} onChange={e => setTraderId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm mb-3">
|
||||
className="w-full px-3 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text text-sm mb-3">
|
||||
<option value="">{t('selectTrader', language)}...</option>
|
||||
{traders?.filter(tr => tr.is_running).map(tr => (
|
||||
<option key={tr.trader_id} value={tr.trader_id}>✅ {tr.trader_name}</option>
|
||||
@@ -778,20 +780,20 @@ export function DebateArenaPage() {
|
||||
<option key={tr.trader_id} value={tr.trader_id} disabled>⏹ {tr.trader_name} ({t('offline', language)})</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-yellow-300 bg-yellow-500/10 p-2 rounded mb-3">
|
||||
<div className="text-xs text-yellow-300 bg-nofx-gold/10 p-2 rounded mb-3">
|
||||
⚠️ {language === 'zh' ? '将使用账户余额执行真实交易' : 'Will execute real trade with account balance'}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setExecId(null); setTraderId('') }}
|
||||
className="flex-1 py-2 rounded-lg bg-white/5 text-white text-sm">{t('cancel', language)}</button>
|
||||
className="flex-1 py-2 rounded-lg bg-nofx-bg text-nofx-text text-sm hover:bg-nofx-bg-light transition-colors">{t('cancel', language)}</button>
|
||||
<button onClick={onExecute} disabled={!traderId || executing || !traders?.find(tr => tr.trader_id === traderId)?.is_running}
|
||||
className="flex-1 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm disabled:opacity-50">
|
||||
className="flex-1 py-2 rounded-lg bg-nofx-gold text-black font-semibold text-sm disabled:opacity-50 hover:bg-yellow-500 transition-colors">
|
||||
{executing ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('execute', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
43
web/src/pages/PageNotFound.tsx
Normal file
43
web/src/pages/PageNotFound.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DeepVoidBackground } from '../components/DeepVoidBackground'
|
||||
import { AlertCircle, Home } from 'lucide-react'
|
||||
|
||||
export function PageNotFound() {
|
||||
return (
|
||||
<DeepVoidBackground className="flex items-center justify-center text-center p-4">
|
||||
<div className="bg-nofx-bg border border-nofx-gold/20 p-8 rounded-lg max-w-md w-full relative overflow-hidden group">
|
||||
|
||||
{/* Background Grid inside Card */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px] pointer-events-none"></div>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<AlertCircle size={64} className="text-nofx-danger relative z-10" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold font-mono tracking-tighter text-white">
|
||||
404
|
||||
</h1>
|
||||
<div className="text-xs uppercase tracking-[0.3em] text-nofx-danger font-mono border-b border-nofx-danger/30 pb-2 inline-block">
|
||||
SIGNAL_LOST
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-nofx-text-muted font-mono leading-relaxed">
|
||||
The requested coordinates do not exist in the current sector. The page may have been moved, deleted, or never existed in this timeline.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-nofx-gold text-black font-bold text-sm uppercase tracking-widest rounded hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_20px_rgba(240,185,11,0.4)] group mt-4"
|
||||
>
|
||||
<Home size={16} />
|
||||
<span>RETURN_BASE</span>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { toast } from 'sonner' // Ensure sonner is installed or stick to custom toast if preferred
|
||||
import { toast } from 'sonner'
|
||||
import { DeepVoidBackground } from '../components/DeepVoidBackground'
|
||||
|
||||
interface PublicStrategy {
|
||||
id: string
|
||||
@@ -225,291 +226,289 @@ export function StrategyMarketPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white font-mono relative overflow-hidden flex flex-col items-center py-12">
|
||||
{/* Background Grid & Scanlines */}
|
||||
<div className="fixed inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"></div>
|
||||
<div className="fixed inset-0 bg-gradient-to-t from-black via-transparent to-transparent pointer-events-none"></div>
|
||||
<div className="fixed inset-0 pointer-events-none opacity-[0.03] bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.5)_50%)] bg-[length:100%_4px]"></div>
|
||||
<DeepVoidBackground className="min-h-screen text-white font-mono py-12">
|
||||
<div className="w-full px-4 md:px-8 space-y-8">
|
||||
|
||||
<div className="w-full max-w-7xl relative z-10 px-6">
|
||||
<div className="w-full relative z-10">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="mb-12 border-b border-zinc-800 pb-8 relative">
|
||||
<div className="absolute top-0 right-0 p-2 border border-zinc-800 rounded bg-black/50 text-xs text-zinc-500 font-mono hidden md:block">
|
||||
SYSTEM_STATUS: <span className="text-emerald-500 animate-pulse">ONLINE</span>
|
||||
<br />
|
||||
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-zinc-900 border border-zinc-700 p-3 rounded-none relative group overflow-hidden">
|
||||
<div className="absolute inset-0 bg-nofx-gold/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<Database className="w-8 h-8 text-nofx-gold relative z-10" />
|
||||
{/* Header Section */}
|
||||
<div className="mb-12 border-b border-zinc-800 pb-8 relative">
|
||||
<div className="absolute top-0 right-0 p-2 border border-zinc-800 rounded bg-black/50 text-xs text-zinc-500 font-mono hidden md:block">
|
||||
SYSTEM_STATUS: <span className="text-emerald-500 animate-pulse">ONLINE</span>
|
||||
<br />
|
||||
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text" data-text={t.title}>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1">
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-zinc-900 border border-zinc-700 p-3 rounded-none relative group overflow-hidden">
|
||||
<div className="absolute inset-0 bg-nofx-gold/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<Database className="w-8 h-8 text-nofx-gold relative z-10" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text" data-text={t.title}>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1">
|
||||
// {t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 max-w-2xl border-l-2 border-zinc-800 pl-4">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 group">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-nofx-gold/20 to-zinc-800/20 rounded opacity-0 group-hover:opacity-100 transition duration-500 blur"></div>
|
||||
<div className="relative bg-black flex items-center border border-zinc-800 group-hover:border-nofx-gold/50 transition-colors">
|
||||
<div className="pl-4 pr-3 text-zinc-500 group-hover:text-nofx-gold transition-colors">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent py-3 text-sm focus:outline-none placeholder-zinc-700 text-nofx-gold font-mono"
|
||||
/>
|
||||
<div className="pr-4">
|
||||
<div className="w-2 h-4 bg-nofx-gold animate-pulse"></div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 max-w-2xl border-l-2 border-zinc-800 pl-4">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex gap-2 bg-zinc-900/50 p-1 border border-zinc-800">
|
||||
{['all', 'popular', 'recent'].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat
|
||||
? 'text-black font-bold'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{selectedCategory === cat && (
|
||||
<motion.div
|
||||
layoutId="filter-highlight"
|
||||
className="absolute inset-0 bg-nofx-gold"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{t[cat as keyof typeof t]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-32 space-y-4">
|
||||
<div className="relative w-16 h-16">
|
||||
<div className="absolute inset-0 border-2 border-zinc-800 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-2 border-nofx-gold rounded-full border-t-transparent animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Cpu size={24} className="text-nofx-gold/50" />
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 group">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-nofx-gold/20 to-zinc-800/20 rounded opacity-0 group-hover:opacity-100 transition duration-500 blur"></div>
|
||||
<div className="relative bg-black flex items-center border border-zinc-800 group-hover:border-nofx-gold/50 transition-colors">
|
||||
<div className="pl-4 pr-3 text-zinc-500 group-hover:text-nofx-gold transition-colors">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent py-3 text-sm focus:outline-none placeholder-zinc-700 text-nofx-gold font-mono"
|
||||
/>
|
||||
<div className="pr-4">
|
||||
<div className="w-2 h-4 bg-nofx-gold animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">{t.loading}</p>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex gap-2 bg-zinc-900/50 p-1 border border-zinc-800">
|
||||
{['all', 'popular', 'recent'].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat
|
||||
? 'text-black font-bold'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{selectedCategory === cat && (
|
||||
<motion.div
|
||||
layoutId="filter-highlight"
|
||||
className="absolute inset-0 bg-nofx-gold"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{t[cat as keyof typeof t]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredStrategies.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-32 border border-zinc-800 border-dashed bg-zinc-900/20 rounded">
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute -inset-4 bg-red-500/10 rounded-full blur-xl animate-pulse"></div>
|
||||
<Activity className="w-16 h-16 text-zinc-700 relative z-10" />
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-32 space-y-4">
|
||||
<div className="relative w-16 h-16">
|
||||
<div className="absolute inset-0 border-2 border-zinc-800 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-2 border-nofx-gold rounded-full border-t-transparent animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Cpu size={24} className="text-nofx-gold/50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">{t.loading}</p>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
|
||||
[{t.noStrategies}]
|
||||
</h3>
|
||||
<p className="text-zinc-600 text-xs tracking-wide uppercase">{t.noStrategiesDesc}</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Strategy Grid */}
|
||||
{!isLoading && filteredStrategies.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredStrategies.map((strategy, i) => {
|
||||
const style = getStrategyStyle(strategy.name)
|
||||
const Icon = style.icon
|
||||
const indicators = strategy.config_visible && strategy.config
|
||||
? getIndicatorList(strategy.config)
|
||||
: []
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredStrategies.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-32 border border-zinc-800 border-dashed bg-zinc-900/20 rounded">
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute -inset-4 bg-red-500/10 rounded-full blur-xl animate-pulse"></div>
|
||||
<Activity className="w-16 h-16 text-zinc-700 relative z-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
|
||||
[{t.noStrategies}]
|
||||
</h3>
|
||||
<p className="text-zinc-600 text-xs tracking-wide uppercase">{t.noStrategiesDesc}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={strategy.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`}
|
||||
>
|
||||
{/* Holographic Border Highlight */}
|
||||
<div className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
<div className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
{/* Strategy Grid */}
|
||||
{!isLoading && filteredStrategies.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredStrategies.map((strategy, i) => {
|
||||
const style = getStrategyStyle(strategy.name)
|
||||
const Icon = style.icon
|
||||
const indicators = strategy.config_visible && strategy.config
|
||||
? getIndicatorList(strategy.config)
|
||||
: []
|
||||
|
||||
{/* Category Side Strip */}
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div>
|
||||
return (
|
||||
<motion.div
|
||||
key={strategy.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`}
|
||||
>
|
||||
{/* Holographic Border Highlight */}
|
||||
<div className={`absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
<div className={`absolute bottom-0 right-0 w-full h-[1px] bg-gradient-to-r from-transparent via-${style.color.split('-')[1]}-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
|
||||
<div className="p-6 relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-2 rounded-none border ${style.border} ${style.bg}`}>
|
||||
<Icon className={`w-5 h-5 ${style.color}`} />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono">
|
||||
{strategy.config_visible ? (
|
||||
<div className="flex items-center gap-1.5 text-emerald-500 border border-emerald-500/20 bg-emerald-500/10 px-2 py-1">
|
||||
<Eye size={10} />
|
||||
PUBLIC_ACCESS
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-zinc-500 border border-zinc-800 bg-zinc-900 px-2 py-1">
|
||||
<EyeOff size={10} />
|
||||
RESTRICTED
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Category Side Strip */}
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div>
|
||||
|
||||
{/* Name and Description */}
|
||||
<h3 className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}>
|
||||
{strategy.name}
|
||||
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span>
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 mb-6 line-clamp-2 h-8 leading-relaxed font-sans">
|
||||
{strategy.description || 'NO_DESCRIPTION_AVAILABLE'}
|
||||
</p>
|
||||
|
||||
{/* Meta Data */}
|
||||
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-700 uppercase">{t.author}</span>
|
||||
<span className="text-zinc-400 group-hover:text-white transition-colors">@{strategy.author_email?.split('@')[0] || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-zinc-700 uppercase">{t.createdAt}</span>
|
||||
<span className="text-zinc-400">{formatDate(strategy.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config / Indicators */}
|
||||
<div className="bg-zinc-900/30 border border-zinc-800/50 p-3 mb-4 backdrop-blur-sm min-h-[90px]">
|
||||
{strategy.config_visible && strategy.config ? (
|
||||
<div className="space-y-3">
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
{indicators.length > 0 ? indicators.map((ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
|
||||
>
|
||||
{ind}
|
||||
</span>
|
||||
)) : <span className="text-[9px] text-zinc-600">NO_INDICATORS</span>}
|
||||
</div>
|
||||
|
||||
{/* Risk Control */}
|
||||
{strategy.config.risk_control && (
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">LEV</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.btc_eth_max_leverage || '-'}x</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">POS</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.max_positions || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Activity size={12} className="text-zinc-700" />
|
||||
<div className="p-6 relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-2 rounded-none border ${style.border} ${style.bg}`}>
|
||||
<Icon className={`w-5 h-5 ${style.color}`} />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono">
|
||||
{strategy.config_visible ? (
|
||||
<div className="flex items-center gap-1.5 text-emerald-500 border border-emerald-500/20 bg-emerald-500/10 px-2 py-1">
|
||||
<Eye size={10} />
|
||||
PUBLIC_ACCESS
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-zinc-500 border border-zinc-800 bg-zinc-900 px-2 py-1">
|
||||
<EyeOff size={10} />
|
||||
RESTRICTED
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
|
||||
<EyeOff size={16} className="mb-1 opacity-50" />
|
||||
<span className="text-[9px] uppercase tracking-widest">{t.configHiddenDesc}</span>
|
||||
</div>
|
||||
|
||||
{/* Name and Description */}
|
||||
<h3 className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}>
|
||||
{strategy.name}
|
||||
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span>
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 mb-6 line-clamp-2 h-8 leading-relaxed font-sans">
|
||||
{strategy.description || 'NO_DESCRIPTION_AVAILABLE'}
|
||||
</p>
|
||||
|
||||
{/* Meta Data */}
|
||||
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-700 uppercase">{t.author}</span>
|
||||
<span className="text-zinc-400 group-hover:text-white transition-colors">@{strategy.author_email?.split('@')[0] || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-zinc-700 uppercase">{t.createdAt}</span>
|
||||
<span className="text-zinc-400">{formatDate(strategy.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config / Indicators */}
|
||||
<div className="bg-zinc-900/30 border border-zinc-800/50 p-3 mb-4 backdrop-blur-sm min-h-[90px]">
|
||||
{strategy.config_visible && strategy.config ? (
|
||||
<div className="space-y-3">
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
{indicators.length > 0 ? indicators.map((ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className="px-1.5 py-0.5 border border-zinc-700 bg-zinc-800 text-[9px] text-zinc-300 font-mono whitespace-nowrap"
|
||||
>
|
||||
{ind}
|
||||
</span>
|
||||
)) : <span className="text-[9px] text-zinc-600">NO_INDICATORS</span>}
|
||||
</div>
|
||||
|
||||
{/* Risk Control */}
|
||||
{strategy.config.risk_control && (
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">LEV</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.btc_eth_max_leverage || '-'}x</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-600 scale-90 origin-left">POS</span>
|
||||
<span className="text-zinc-300 font-bold">{strategy.config.risk_control.max_positions || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Activity size={12} className="text-zinc-700" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
|
||||
<EyeOff size={16} className="mb-1 opacity-50" />
|
||||
<span className="text-[9px] uppercase tracking-widest">{t.configHiddenDesc}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div>
|
||||
{strategy.config_visible && strategy.config ? (
|
||||
<button
|
||||
onClick={() => handleCopyConfig(strategy)}
|
||||
className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-700 bg-black hover:bg-zinc-900 text-zinc-300 hover:text-nofx-gold hover:border-nofx-gold transition-all flex items-center justify-center gap-2 group/btn"
|
||||
>
|
||||
{copiedId === strategy.id ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-500">{t.copied}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 group-hover/btn:scale-110 transition-transform" />
|
||||
{t.copyConfig}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button disabled className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<Shield size={12} />
|
||||
{t.hideConfig}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<div>
|
||||
{strategy.config_visible && strategy.config ? (
|
||||
<button
|
||||
onClick={() => handleCopyConfig(strategy)}
|
||||
className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-700 bg-black hover:bg-zinc-900 text-zinc-300 hover:text-nofx-gold hover:border-nofx-gold transition-all flex items-center justify-center gap-2 group/btn"
|
||||
>
|
||||
{copiedId === strategy.id ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-500">{t.copied}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 group-hover/btn:scale-110 transition-transform" />
|
||||
{t.copyConfig}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button disabled className="w-full py-2.5 text-[10px] font-bold font-mono uppercase tracking-widest border border-zinc-800 bg-black text-zinc-700 cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<Shield size={12} />
|
||||
{t.hideConfig}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA - Share Strategy */}
|
||||
{user && token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-16 mb-20 flex justify-center"
|
||||
>
|
||||
<div className="relative group cursor-pointer" onClick={() => window.location.href = '/strategy'}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-nofx-gold to-yellow-600 rounded blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200"></div>
|
||||
<div className="relative px-8 py-4 bg-black border border-zinc-800 hover:border-nofx-gold/50 flex items-center gap-4 transition-all">
|
||||
<Hexagon className="text-nofx-gold animate-spin-slow" size={24} />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">{t.shareYours}</div>
|
||||
<div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-8 bg-zinc-800 mx-2"></div>
|
||||
<div className="text-xs font-mono text-zinc-400 group-hover:translate-x-1 transition-transform">
|
||||
INITIALIZE_UPLOAD ->
|
||||
{/* CTA - Share Strategy */}
|
||||
{user && token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-16 mb-20 flex justify-center"
|
||||
>
|
||||
<div className="relative group cursor-pointer" onClick={() => window.location.href = '/strategy'}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-nofx-gold to-yellow-600 rounded blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200"></div>
|
||||
<div className="relative px-8 py-4 bg-black border border-zinc-800 hover:border-nofx-gold/50 flex items-center gap-4 transition-all">
|
||||
<Hexagon className="text-nofx-gold animate-spin-slow" size={24} />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider group-hover:text-nofx-gold transition-colors">{t.shareYours}</div>
|
||||
<div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-8 bg-zinc-800 mx-2"></div>
|
||||
<div className="text-xs font-mono text-zinc-400 group-hover:translate-x-1 transition-transform">
|
||||
INITIALIZE_UPLOAD ->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Send,
|
||||
Download,
|
||||
Upload,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||
import { confirmToast, notify } from '../lib/notify'
|
||||
@@ -35,6 +36,8 @@ import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
|
||||
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
|
||||
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||
import { DeepVoidBackground } from '../components/DeepVoidBackground'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
@@ -61,6 +64,7 @@ export function StrategyStudioPage() {
|
||||
riskControl: false,
|
||||
promptSections: false,
|
||||
customPrompt: false,
|
||||
publishSettings: false,
|
||||
})
|
||||
|
||||
// Right panel states
|
||||
@@ -147,6 +151,45 @@ export function StrategyStudioPage() {
|
||||
fetchAiModels()
|
||||
}, [fetchStrategies, fetchAiModels])
|
||||
|
||||
// Track previous language to detect actual changes
|
||||
const prevLanguageRef = useRef(language)
|
||||
|
||||
// When language changes, update prompt sections to match the new language
|
||||
useEffect(() => {
|
||||
const updatePromptSectionsForLanguage = async () => {
|
||||
// Only update if language actually changed (not on initial mount)
|
||||
if (prevLanguageRef.current === language) return
|
||||
prevLanguageRef.current = language
|
||||
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
// Fetch default config for the new language
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/strategies/default-config?lang=${language}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!response.ok) return
|
||||
const defaultConfig = await response.json()
|
||||
|
||||
// Update only the prompt sections and language field
|
||||
setEditingConfig(prev => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
language: language as 'zh' | 'en',
|
||||
prompt_sections: defaultConfig.prompt_sections,
|
||||
}
|
||||
})
|
||||
setHasChanges(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to update prompt sections for language:', err)
|
||||
}
|
||||
}
|
||||
|
||||
updatePromptSectionsForLanguage()
|
||||
}, [language, token]) // Only trigger when language changes
|
||||
|
||||
// Create new strategy
|
||||
const handleCreateStrategy = async () => {
|
||||
if (!token) return
|
||||
@@ -181,6 +224,8 @@ export function StrategyStudioPage() {
|
||||
description: '',
|
||||
is_active: false,
|
||||
is_default: false,
|
||||
is_public: false,
|
||||
config_visible: true,
|
||||
config: defaultConfig,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -331,6 +376,11 @@ export function StrategyStudioPage() {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Always sync the config language with the current interface language
|
||||
const configWithLanguage = {
|
||||
...editingConfig,
|
||||
language: language as 'zh' | 'en',
|
||||
}
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/strategies/${selectedStrategy.id}`,
|
||||
{
|
||||
@@ -342,12 +392,15 @@ export function StrategyStudioPage() {
|
||||
body: JSON.stringify({
|
||||
name: selectedStrategy.name,
|
||||
description: selectedStrategy.description,
|
||||
config: editingConfig,
|
||||
config: configWithLanguage,
|
||||
is_public: selectedStrategy.is_public,
|
||||
config_visible: selectedStrategy.config_visible,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to save strategy')
|
||||
setHasChanges(false)
|
||||
notify.success(language === 'zh' ? '策略已保存' : 'Strategy saved')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
@@ -462,6 +515,7 @@ export function StrategyStudioPage() {
|
||||
duration: { zh: '耗时', en: 'Duration' },
|
||||
noModel: { zh: '请先配置 AI 模型', en: 'Please configure AI model first' },
|
||||
testNote: { zh: '使用真实 AI 模型测试,不执行交易', en: 'Test with real AI, no trading' },
|
||||
publishSettings: { zh: '发布设置', en: 'Publish' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
@@ -557,24 +611,48 @@ export function StrategyStudioPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'publishSettings' as const,
|
||||
icon: Globe,
|
||||
color: '#0ECB81',
|
||||
title: t('publishSettings'),
|
||||
content: selectedStrategy && (
|
||||
<PublishSettingsEditor
|
||||
isPublic={selectedStrategy.is_public ?? false}
|
||||
configVisible={selectedStrategy.config_visible ?? true}
|
||||
onIsPublicChange={(value) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, is_public: value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
onConfigVisibleChange={(value) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, config_visible: value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
language={language}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col" style={{ background: '#0B0E11' }}>
|
||||
<DeepVoidBackground className="h-[calc(100vh-64px)] flex flex-col bg-nofx-bg relative overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b" style={{ borderColor: '#2B3139' }}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b border-nofx-gold/20 bg-nofx-bg/60 backdrop-blur-md z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
<div className="p-2 rounded-lg bg-gradient-to-br from-nofx-gold to-yellow-500">
|
||||
<Sparkles className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold" style={{ color: '#EAECEF' }}>{t('strategyStudio')}</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>{t('subtitle')}</p>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{t('strategyStudio')}</h1>
|
||||
<p className="text-xs text-nofx-text-muted">{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs bg-nofx-danger/10 text-nofx-danger">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="hover:underline">×</button>
|
||||
</div>
|
||||
@@ -585,13 +663,13 @@ export function StrategyStudioPage() {
|
||||
{/* Main Content - Three Columns */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Column - Strategy List */}
|
||||
<div className="w-48 flex-shrink-0 border-r overflow-y-auto" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="w-48 flex-shrink-0 border-r border-nofx-gold/20 overflow-y-auto bg-nofx-bg/30 backdrop-blur-sm z-10">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2 px-2">
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('strategies')}</span>
|
||||
<span className="text-xs font-medium text-nofx-text-muted">{t('strategies')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Import button with hidden file input */}
|
||||
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer" style={{ color: '#848E9C' }} title={language === 'zh' ? '导入策略' : 'Import Strategy'}>
|
||||
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer text-nofx-text-muted hover:text-white" title={language === 'zh' ? '导入策略' : 'Import Strategy'}>
|
||||
<Upload className="w-4 h-4" />
|
||||
<input
|
||||
type="file"
|
||||
@@ -602,8 +680,7 @@ export function StrategyStudioPage() {
|
||||
</label>
|
||||
<button
|
||||
onClick={handleCreateStrategy}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: '#F0B90B' }}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors text-nofx-gold"
|
||||
title={language === 'zh' ? '新建策略' : 'New Strategy'}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -621,54 +698,58 @@ export function StrategyStudioPage() {
|
||||
setPromptPreview(null)
|
||||
setAiTestResult(null)
|
||||
}}
|
||||
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedStrategy?.id === strategy.id ? 'ring-1 ring-yellow-500/50' : 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
background: selectedStrategy?.id === strategy.id ? 'rgba(240, 185, 11, 0.1)' : 'transparent',
|
||||
}}
|
||||
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
|
||||
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
|
||||
: 'hover:bg-nofx-bg-lighter/60 hover:ring-1 hover:ring-nofx-gold/20 bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm truncate" style={{ color: '#EAECEF' }}>{strategy.name}</span>
|
||||
<span className="text-sm truncate text-nofx-text">{strategy.name}</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
|
||||
title={language === 'zh' ? '导出' : 'Export'}
|
||||
>
|
||||
<Download className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
{!strategy.is_default && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
|
||||
title={language === 'zh' ? '复制' : 'Duplicate'}
|
||||
>
|
||||
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
|
||||
className="p-1 rounded hover:bg-red-500/20"
|
||||
className="p-1 rounded hover:bg-nofx-danger/20 text-nofx-danger"
|
||||
title={language === 'zh' ? '删除' : 'Delete'}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
{strategy.is_active && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-nofx-success/15 text-nofx-success">
|
||||
{t('active')}
|
||||
</span>
|
||||
)}
|
||||
{strategy.is_default && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-nofx-gold/15 text-nofx-gold">
|
||||
{t('default')}
|
||||
</span>
|
||||
)}
|
||||
{strategy.is_public && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded flex items-center gap-0.5 bg-blue-400/15 text-blue-400">
|
||||
<Globe className="w-2.5 h-2.5" />
|
||||
{language === 'zh' ? '公开' : 'Public'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -677,7 +758,7 @@ export function StrategyStudioPage() {
|
||||
</div>
|
||||
|
||||
{/* Middle Column - Config Editor */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto border-r" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="flex-1 min-w-0 overflow-y-auto border-r border-nofx-gold/20">
|
||||
{selectedStrategy && editingConfig ? (
|
||||
<div className="p-4">
|
||||
{/* Strategy Name & Actions */}
|
||||
@@ -691,19 +772,17 @@ export function StrategyStudioPage() {
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full"
|
||||
style={{ color: '#EAECEF' }}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>● 未保存</span>
|
||||
<span className="text-xs text-nofx-gold">● 未保存</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!selectedStrategy.is_active && (
|
||||
<button
|
||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)', color: '#0ECB81' }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors bg-nofx-success/10 border border-nofx-success/30 text-nofx-success hover:bg-nofx-success/20"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
{t('activate')}
|
||||
@@ -713,11 +792,8 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={isSaving || !hasChanges}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
background: hasChanges ? '#F0B90B' : '#2B3139',
|
||||
color: hasChanges ? '#0B0E11' : '#848E9C',
|
||||
}}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{isSaving ? t('saving') : t('save')}
|
||||
@@ -731,8 +807,7 @@ export function StrategyStudioPage() {
|
||||
{configSections.map(({ key, icon: Icon, color, title, content }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(key)}
|
||||
@@ -740,12 +815,12 @@ export function StrategyStudioPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" style={{ color }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{title}</span>
|
||||
<span className="text-sm font-medium text-nofx-text">{title}</span>
|
||||
</div>
|
||||
{expandedSections[key] ? (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
<ChevronRight className="w-4 h-4 text-nofx-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections[key] && (
|
||||
@@ -760,8 +835,8 @@ export function StrategyStudioPage() {
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Activity className="w-12 h-12 mx-auto mb-2 opacity-30" style={{ color: '#848E9C' }} />
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
<Activity className="w-12 h-12 mx-auto mb-2 opacity-30 text-nofx-text-muted" />
|
||||
<p className="text-sm text-nofx-text-muted">
|
||||
{language === 'zh' ? '选择或创建策略' : 'Select or create a strategy'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -772,29 +847,19 @@ export function StrategyStudioPage() {
|
||||
{/* Right Column - Prompt Preview & AI Test */}
|
||||
<div className="w-[420px] flex-shrink-0 flex flex-col overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex-shrink-0 flex border-b" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="flex-shrink-0 flex border-b border-nofx-gold/20">
|
||||
<button
|
||||
onClick={() => setActiveRightTab('prompt')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeRightTab === 'prompt' ? 'border-b-2' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: activeRightTab === 'prompt' ? '#a855f7' : 'transparent',
|
||||
color: activeRightTab === 'prompt' ? '#a855f7' : '#848E9C',
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'prompt' ? 'border-b-2 border-purple-500 text-purple-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{t('promptPreview')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveRightTab('test')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeRightTab === 'test' ? 'border-b-2' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: activeRightTab === 'test' ? '#22c55e' : 'transparent',
|
||||
color: activeRightTab === 'test' ? '#22c55e' : '#848E9C',
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'test' ? 'border-b-2 border-green-500 text-green-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{t('aiTestRun')}
|
||||
@@ -811,8 +876,7 @@ export function StrategyStudioPage() {
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
|
||||
>
|
||||
<option value="balanced">{t('balanced')}</option>
|
||||
<option value="aggressive">{t('aggressive')}</option>
|
||||
@@ -821,8 +885,7 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={fetchPromptPreview}
|
||||
disabled={isLoadingPrompt || !editingConfig}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{ background: '#a855f7', color: '#fff' }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
{isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
|
||||
{promptPreview ? t('refreshPrompt') : t('loadPrompt')}
|
||||
@@ -832,16 +895,16 @@ export function StrategyStudioPage() {
|
||||
{promptPreview ? (
|
||||
<>
|
||||
{/* Config Summary */}
|
||||
<div className="p-2 rounded-lg" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="p-2 rounded-lg bg-nofx-bg border border-nofx-gold/20">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Code className="w-3 h-3" style={{ color: '#a855f7' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#a855f7' }}>Config</span>
|
||||
<Code className="w-3 h-3 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-500">Config</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{Object.entries(promptPreview.config_summary || {}).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<div style={{ color: '#848E9C' }}>{key.replace(/_/g, ' ')}</div>
|
||||
<div style={{ color: '#EAECEF' }}>{String(value)}</div>
|
||||
<div className="text-nofx-text-muted">{key.replace(/_/g, ' ')}</div>
|
||||
<div className="text-nofx-text">{String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -851,23 +914,23 @@ export function StrategyStudioPage() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="w-3 h-3" style={{ color: '#a855f7' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('systemPrompt')}</span>
|
||||
<FileText className="w-3 h-3 text-purple-500" />
|
||||
<span className="text-xs font-medium text-nofx-text">{t('systemPrompt')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-nofx-bg-lighter text-nofx-text-muted">
|
||||
{promptPreview.system_prompt.length.toLocaleString()} chars
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[11px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '400px' }}
|
||||
className="p-2 rounded-lg text-[11px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
style={{ maxHeight: '400px' }}
|
||||
>
|
||||
{promptPreview.system_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: '#848E9C' }}>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-nofx-text-muted">
|
||||
<Eye className="w-10 h-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">{language === 'zh' ? '点击生成 Prompt 预览' : 'Click to generate prompt preview'}</p>
|
||||
</div>
|
||||
@@ -879,15 +942,14 @@ export function StrategyStudioPage() {
|
||||
{/* Controls */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('selectModel')}</span>
|
||||
<Bot className="w-4 h-4 text-green-500" />
|
||||
<span className="text-xs font-medium text-nofx-text">{t('selectModel')}</span>
|
||||
</div>
|
||||
{aiModels.length > 0 ? (
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{aiModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
@@ -896,7 +958,7 @@ export function StrategyStudioPage() {
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="px-3 py-2 rounded-lg text-sm" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
||||
{t('noModel')}
|
||||
</div>
|
||||
)}
|
||||
@@ -905,8 +967,7 @@ export function StrategyStudioPage() {
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
<option value="balanced">{t('balanced')}</option>
|
||||
<option value="aggressive">{t('aggressive')}</option>
|
||||
@@ -915,12 +976,7 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={runAiTest}
|
||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #4ade80 100%)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)',
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 text-white shadow-lg shadow-green-500/20 bg-gradient-to-br from-green-500 to-green-600"
|
||||
>
|
||||
{isRunningAiTest ? (
|
||||
<>
|
||||
@@ -935,22 +991,22 @@ export function StrategyStudioPage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px]" style={{ color: '#848E9C' }}>{t('testNote')}</p>
|
||||
<p className="text-[10px] text-nofx-text-muted">{t('testNote')}</p>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{aiTestResult ? (
|
||||
<div className="space-y-3">
|
||||
{aiTestResult.error ? (
|
||||
<div className="p-3 rounded-lg" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}>
|
||||
<p className="text-sm" style={{ color: '#F6465D' }}>{aiTestResult.error}</p>
|
||||
<div className="p-3 rounded-lg bg-nofx-danger/10 border border-nofx-danger/30">
|
||||
<p className="text-sm text-nofx-danger">{aiTestResult.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{aiTestResult.duration_ms && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<Clock className="w-3 h-3 text-nofx-text-muted" />
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{t('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
@@ -960,12 +1016,12 @@ export function StrategyStudioPage() {
|
||||
{aiTestResult.user_prompt && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Terminal className="w-3 h-3" style={{ color: '#60a5fa' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('userPrompt')} (Input)</span>
|
||||
<Terminal className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-xs font-medium text-nofx-text">{t('userPrompt')} (Input)</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '200px' }}
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
style={{ maxHeight: '200px' }}
|
||||
>
|
||||
{aiTestResult.user_prompt}
|
||||
</pre>
|
||||
@@ -976,12 +1032,12 @@ export function StrategyStudioPage() {
|
||||
{aiTestResult.reasoning && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Sparkles className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('reasoning')}</span>
|
||||
<Sparkles className="w-3 h-3 text-nofx-gold" />
|
||||
<span className="text-xs font-medium text-nofx-text">{t('reasoning')}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap"
|
||||
style={{ background: '#0B0E11', border: '1px solid rgba(240, 185, 11, 0.3)', color: '#EAECEF', maxHeight: '200px' }}
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/30 text-nofx-text"
|
||||
style={{ maxHeight: '200px' }}
|
||||
>
|
||||
{aiTestResult.reasoning}
|
||||
</pre>
|
||||
@@ -992,12 +1048,12 @@ export function StrategyStudioPage() {
|
||||
{aiTestResult.decisions && aiTestResult.decisions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Activity className="w-3 h-3" style={{ color: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('decisions')}</span>
|
||||
<Activity className="w-3 h-3 text-green-500" />
|
||||
<span className="text-xs font-medium text-nofx-text">{t('decisions')}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid rgba(34, 197, 94, 0.3)', color: '#EAECEF', maxHeight: '200px' }}
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
|
||||
style={{ maxHeight: '200px' }}
|
||||
>
|
||||
{JSON.stringify(aiTestResult.decisions, null, 2)}
|
||||
</pre>
|
||||
@@ -1008,12 +1064,12 @@ export function StrategyStudioPage() {
|
||||
{aiTestResult.ai_response && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<FileText className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('aiOutput')} (Raw)</span>
|
||||
<FileText className="w-3 h-3 text-nofx-text-muted" />
|
||||
<span className="text-xs font-medium text-nofx-text">{t('aiOutput')} (Raw)</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '300px' }}
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
style={{ maxHeight: '300px' }}
|
||||
>
|
||||
{aiTestResult.ai_response}
|
||||
</pre>
|
||||
@@ -1023,7 +1079,7 @@ export function StrategyStudioPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: '#848E9C' }}>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-nofx-text-muted">
|
||||
<Play className="w-10 h-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">{language === 'zh' ? '点击运行 AI 测试' : 'Click to run AI test'}</p>
|
||||
</div>
|
||||
@@ -1033,7 +1089,7 @@ export function StrategyStudioPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
857
web/src/pages/TraderDashboardPage.tsx
Normal file
857
web/src/pages/TraderDashboardPage.tsx
Normal file
@@ -0,0 +1,857 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { mutate } from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import { ChartTabs } from '../components/ChartTabs'
|
||||
import { DecisionCard } from '../components/DecisionCard'
|
||||
import { PositionHistory } from '../components/PositionHistory'
|
||||
import { PunkAvatar, getTraderAvatar } from '../components/PunkAvatar'
|
||||
import { confirmToast, notify } from '../lib/notify'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../components/DeepVoidBackground'
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
Position,
|
||||
DecisionRecord,
|
||||
Statistics,
|
||||
TraderInfo,
|
||||
Exchange,
|
||||
} from '../types'
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
// 获取友好的AI模型名称
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
switch (modelId.toLowerCase()) {
|
||||
case 'deepseek':
|
||||
return 'DeepSeek'
|
||||
case 'qwen':
|
||||
return 'Qwen'
|
||||
case 'claude':
|
||||
return 'Claude'
|
||||
default:
|
||||
return modelId.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get exchange display name from exchange ID (UUID)
|
||||
function getExchangeDisplayNameFromList(
|
||||
exchangeId: string | undefined,
|
||||
exchanges: Exchange[] | undefined
|
||||
): string {
|
||||
if (!exchangeId) return 'Unknown'
|
||||
const exchange = exchanges?.find((e) => e.id === exchangeId)
|
||||
if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...'
|
||||
const typeName = exchange.exchange_type?.toUpperCase() || exchange.name
|
||||
return exchange.account_name
|
||||
? `${typeName} - ${exchange.account_name}`
|
||||
: typeName
|
||||
}
|
||||
|
||||
// Helper function to get exchange type from exchange ID (UUID) - for kline charts
|
||||
function getExchangeTypeFromList(
|
||||
exchangeId: string | undefined,
|
||||
exchanges: Exchange[] | undefined
|
||||
): string {
|
||||
if (!exchangeId) return 'binance'
|
||||
const exchange = exchanges?.find((e) => e.id === exchangeId)
|
||||
if (!exchange) return 'binance' // Default to binance for charts
|
||||
return exchange.exchange_type?.toLowerCase() || 'binance'
|
||||
}
|
||||
|
||||
// Helper function to check if exchange is a perp-dex type (wallet-based)
|
||||
function isPerpDexExchange(exchangeType: string | undefined): boolean {
|
||||
if (!exchangeType) return false
|
||||
const perpDexTypes = ['hyperliquid', 'lighter', 'aster']
|
||||
return perpDexTypes.includes(exchangeType.toLowerCase())
|
||||
}
|
||||
|
||||
// Helper function to get wallet address for perp-dex exchanges
|
||||
function getWalletAddress(exchange: Exchange | undefined): string | undefined {
|
||||
if (!exchange) return undefined
|
||||
const type = exchange.exchange_type?.toLowerCase()
|
||||
switch (type) {
|
||||
case 'hyperliquid':
|
||||
return exchange.hyperliquidWalletAddr
|
||||
case 'lighter':
|
||||
return exchange.lighterWalletAddr
|
||||
case 'aster':
|
||||
return exchange.asterSigner
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to truncate wallet address for display
|
||||
function truncateAddress(address: string, startLen = 6, endLen = 4): string {
|
||||
if (address.length <= startLen + endLen + 3) return address
|
||||
return `${address.slice(0, startLen)}...${address.slice(-endLen)}`
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
interface TraderDashboardPageProps {
|
||||
selectedTrader?: TraderInfo
|
||||
traders?: TraderInfo[]
|
||||
tradersError?: Error
|
||||
selectedTraderId?: string
|
||||
onTraderSelect: (traderId: string) => void
|
||||
onNavigateToTraders: () => void
|
||||
status?: SystemStatus
|
||||
account?: AccountInfo
|
||||
positions?: Position[]
|
||||
decisions?: DecisionRecord[]
|
||||
decisionsLimit: number
|
||||
onDecisionsLimitChange: (limit: number) => void
|
||||
stats?: Statistics
|
||||
lastUpdate: string
|
||||
language: Language
|
||||
exchanges?: Exchange[]
|
||||
}
|
||||
|
||||
export function TraderDashboardPage({
|
||||
selectedTrader,
|
||||
status,
|
||||
account,
|
||||
positions,
|
||||
decisions,
|
||||
decisionsLimit,
|
||||
onDecisionsLimitChange,
|
||||
lastUpdate,
|
||||
language,
|
||||
traders,
|
||||
tradersError,
|
||||
selectedTraderId,
|
||||
onTraderSelect,
|
||||
onNavigateToTraders,
|
||||
exchanges,
|
||||
}: TraderDashboardPageProps) {
|
||||
const [closingPosition, setClosingPosition] = useState<string | null>(null)
|
||||
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>(undefined)
|
||||
const [chartUpdateKey, setChartUpdateKey] = useState<number>(0)
|
||||
const chartSectionRef = useRef<HTMLDivElement>(null)
|
||||
const [showWalletAddress, setShowWalletAddress] = useState<boolean>(false)
|
||||
const [copiedAddress, setCopiedAddress] = useState<boolean>(false)
|
||||
|
||||
// Current positions pagination
|
||||
const [positionsPageSize, setPositionsPageSize] = useState<number>(20)
|
||||
const [positionsCurrentPage, setPositionsCurrentPage] = useState<number>(1)
|
||||
|
||||
// Calculate paginated positions
|
||||
const totalPositions = positions?.length || 0
|
||||
const totalPositionPages = Math.ceil(totalPositions / positionsPageSize)
|
||||
const paginatedPositions = positions?.slice(
|
||||
(positionsCurrentPage - 1) * positionsPageSize,
|
||||
positionsCurrentPage * positionsPageSize
|
||||
) || []
|
||||
|
||||
// Reset page when positions change
|
||||
useEffect(() => {
|
||||
setPositionsCurrentPage(1)
|
||||
}, [selectedTraderId, positionsPageSize])
|
||||
|
||||
// Get current exchange info for perp-dex wallet display
|
||||
const currentExchange = exchanges?.find(
|
||||
(e) => e.id === selectedTrader?.exchange_id
|
||||
)
|
||||
const walletAddress = getWalletAddress(currentExchange)
|
||||
const isPerpDex = isPerpDexExchange(currentExchange?.exchange_type)
|
||||
|
||||
// Copy wallet address to clipboard
|
||||
const handleCopyAddress = async () => {
|
||||
if (!walletAddress) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(walletAddress)
|
||||
setCopiedAddress(true)
|
||||
setTimeout(() => setCopiedAddress(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy address:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle symbol click from Decision Card
|
||||
const handleSymbolClick = (symbol: string) => {
|
||||
// Set the selected symbol
|
||||
setSelectedChartSymbol(symbol)
|
||||
// Scroll to chart section
|
||||
setTimeout(() => {
|
||||
chartSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 平仓操作
|
||||
const handleClosePosition = async (symbol: string, side: string) => {
|
||||
if (!selectedTraderId) return
|
||||
|
||||
const confirmMsg =
|
||||
language === 'zh'
|
||||
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
|
||||
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
|
||||
|
||||
const confirmed = await confirmToast(confirmMsg, {
|
||||
title: language === 'zh' ? '确认平仓' : 'Confirm Close',
|
||||
okText: language === 'zh' ? '确认' : 'Confirm',
|
||||
cancelText: language === 'zh' ? '取消' : 'Cancel',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setClosingPosition(symbol)
|
||||
try {
|
||||
await api.closePosition(selectedTraderId, symbol, side)
|
||||
notify.success(
|
||||
language === 'zh' ? '平仓成功' : 'Position closed successfully'
|
||||
)
|
||||
// 使用 SWR mutate 刷新数据而非重新加载页面
|
||||
await Promise.all([
|
||||
mutate(`positions-${selectedTraderId}`),
|
||||
mutate(`account-${selectedTraderId}`),
|
||||
])
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: language === 'zh'
|
||||
? '平仓失败'
|
||||
: 'Failed to close position'
|
||||
notify.error(errorMsg)
|
||||
} finally {
|
||||
setClosingPosition(null)
|
||||
}
|
||||
}
|
||||
|
||||
// If API failed with error, show empty state (likely backend not running)
|
||||
if (tradersError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] relative z-10">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div
|
||||
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center nofx-glass"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
borderColor: 'rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-nofx-gold"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-nofx-text-main">
|
||||
{language === 'zh' ? '无法连接到服务器' : 'Connection Failed'}
|
||||
</h2>
|
||||
<p className="text-base mb-6 text-nofx-text-muted">
|
||||
{language === 'zh'
|
||||
? '请确认后端服务已启动。'
|
||||
: 'Please check if the backend service is running.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95 nofx-glass border border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/10"
|
||||
>
|
||||
{language === 'zh' ? '重试' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If traders is loaded and empty, show empty state
|
||||
if (traders && traders.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] relative z-10">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div
|
||||
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center nofx-glass"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
borderColor: 'rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-nofx-gold"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-nofx-text-main">
|
||||
{t('dashboardEmptyTitle', language)}
|
||||
</h2>
|
||||
<p className="text-base mb-6 text-nofx-text-muted">
|
||||
{t('dashboardEmptyDescription', language)}
|
||||
</p>
|
||||
<button
|
||||
onClick={onNavigateToTraders}
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95 nofx-glass border border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/10"
|
||||
>
|
||||
{t('goToTradersPage', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If traders is still loading or selectedTrader is not ready, show skeleton
|
||||
if (!selectedTrader) {
|
||||
return (
|
||||
<div className="space-y-6 relative z-10">
|
||||
<div className="nofx-glass p-6 animate-pulse">
|
||||
<div className="h-8 w-48 mb-3 bg-nofx-bg/50 rounded"></div>
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-32 bg-nofx-bg/50 rounded"></div>
|
||||
<div className="h-4 w-24 bg-nofx-bg/50 rounded"></div>
|
||||
<div className="h-4 w-28 bg-nofx-bg/50 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="nofx-glass p-5 animate-pulse">
|
||||
<div className="h-4 w-24 mb-3 bg-nofx-bg/50 rounded"></div>
|
||||
<div className="h-8 w-32 bg-nofx-bg/50 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="nofx-glass p-6 animate-pulse">
|
||||
<div className="h-6 w-40 mb-4 bg-nofx-bg/50 rounded"></div>
|
||||
<div className="h-64 w-full bg-nofx-bg/50 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen pb-12" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8 relative z-10 pt-6">
|
||||
{/* Trader Header */}
|
||||
<div
|
||||
className="mb-6 rounded-lg p-6 animate-scale-in nofx-glass group"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.6) 0%, rgba(15, 23, 42, 0.4) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-4 text-nofx-text-main">
|
||||
<div className="relative">
|
||||
<PunkAvatar
|
||||
seed={getTraderAvatar(
|
||||
selectedTrader.trader_id,
|
||||
selectedTrader.trader_name
|
||||
)}
|
||||
size={56}
|
||||
className="rounded-xl border-2 border-nofx-gold/30 shadow-[0_0_15px_rgba(240,185,11,0.2)]"
|
||||
/>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-nofx-green rounded-full border-2 border-[#0B0E11] shadow-[0_0_8px_rgba(14,203,129,0.8)] animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-nofx-text-main to-nofx-text-muted">
|
||||
{selectedTrader.trader_name}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-nofx-text-muted opacity-60 flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-nofx-gold rounded-full" />
|
||||
ID: {selectedTrader.trader_id.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Trader Selector */}
|
||||
{traders && traders.length > 0 && (
|
||||
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
||||
<select
|
||||
value={selectedTraderId}
|
||||
onChange={(e) => onTraderSelect(e.target.value)}
|
||||
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main focus:outline-none px-2 py-1"
|
||||
>
|
||||
{traders.map((trader) => (
|
||||
<option key={trader.trader_id} value={trader.trader_id} className="bg-[#0B0E11]">
|
||||
{trader.trader_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet Address Display for Perp-DEX */}
|
||||
{exchanges && isPerpDex && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg nofx-glass border border-nofx-gold/20">
|
||||
{walletAddress ? (
|
||||
<>
|
||||
<span className="text-xs font-mono text-nofx-gold">
|
||||
{showWalletAddress
|
||||
? walletAddress
|
||||
: truncateAddress(walletAddress)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWalletAddress(!showWalletAddress)}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title={
|
||||
showWalletAddress
|
||||
? language === 'zh'
|
||||
? '隐藏地址'
|
||||
: 'Hide address'
|
||||
: language === 'zh'
|
||||
? '显示完整地址'
|
||||
: 'Show full address'
|
||||
}
|
||||
>
|
||||
{showWalletAddress ? (
|
||||
<EyeOff className="w-3.5 h-3.5 text-nofx-text-muted" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5 text-nofx-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAddress}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title={language === 'zh' ? '复制地址' : 'Copy address'}
|
||||
>
|
||||
{copiedAddress ? (
|
||||
<Check className="w-3.5 h-3.5 text-nofx-green" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 text-nofx-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{language === 'zh' ? '未配置地址' : 'No address configured'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm flex-wrap text-nofx-text-muted font-mono pl-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="opacity-60">AI Model:</span>
|
||||
<span
|
||||
className="font-bold px-2 py-0.5 rounded text-xs tracking-wide"
|
||||
style={{
|
||||
background: selectedTrader.ai_model.includes('qwen') ? 'rgba(192, 132, 252, 0.15)' : 'rgba(96, 165, 250, 0.15)',
|
||||
color: selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa',
|
||||
border: `1px solid ${selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa'}40`
|
||||
}}
|
||||
>
|
||||
{getModelDisplayName(
|
||||
selectedTrader.ai_model.split('_').pop() ||
|
||||
selectedTrader.ai_model
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="opacity-60">Exchange:</span>
|
||||
<span className="text-nofx-text-main font-semibold">
|
||||
{getExchangeDisplayNameFromList(
|
||||
selectedTrader.exchange_id,
|
||||
exchanges
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="opacity-60">Strategy:</span>
|
||||
<span className="text-nofx-gold font-semibold tracking-wide">
|
||||
{selectedTrader.strategy_name || 'No Strategy'}
|
||||
</span>
|
||||
</span>
|
||||
{status && (
|
||||
<>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info */}
|
||||
{account && (
|
||||
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
||||
<span>SYSTEM_STATUS::ONLINE</span>
|
||||
<div className="flex gap-4">
|
||||
<span>LAST_UPDATE::{lastUpdate}</span>
|
||||
<span>EQ::{account?.total_equity?.toFixed(2)}</span>
|
||||
<span>PNL::{account?.total_pnl?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
title={t('totalEquity', language)}
|
||||
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
|
||||
unit="USDT"
|
||||
change={account?.total_pnl_pct || 0}
|
||||
positive={(account?.total_pnl ?? 0) > 0}
|
||||
icon="💰"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('availableBalance', language)}
|
||||
value={`${account?.available_balance?.toFixed(2) || '0.00'}`}
|
||||
unit="USDT"
|
||||
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
||||
icon="💳"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('totalPnL', language)}
|
||||
value={`${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'}`}
|
||||
unit="USDT"
|
||||
change={account?.total_pnl_pct || 0}
|
||||
positive={(account?.total_pnl ?? 0) >= 0}
|
||||
icon="📈"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('positions', language)}
|
||||
value={`${account?.position_count || 0}`}
|
||||
unit="ACTIVE"
|
||||
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
||||
icon="📊"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Left Column: Charts + Positions */}
|
||||
<div className="space-y-6">
|
||||
{/* Chart Tabs (Equity / K-line) */}
|
||||
<div
|
||||
ref={chartSectionRef}
|
||||
className="chart-container animate-slide-in scroll-mt-32 backdrop-blur-sm"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<ChartTabs
|
||||
traderId={selectedTrader.trader_id}
|
||||
selectedSymbol={selectedChartSymbol}
|
||||
updateKey={chartUpdateKey}
|
||||
exchangeId={getExchangeTypeFromList(
|
||||
selectedTrader.exchange_id,
|
||||
exchanges
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current Positions */}
|
||||
<div
|
||||
className="nofx-glass p-6 animate-slide-in relative overflow-hidden group"
|
||||
style={{ animationDelay: '0.15s' }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<div className="w-24 h-24 rounded-full bg-blue-500 blur-3xl" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-5 relative z-10">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2 text-nofx-text-main uppercase tracking-wide">
|
||||
<span className="text-blue-500">◈</span> {t('currentPositions', language)}
|
||||
</h2>
|
||||
{positions && positions.length > 0 && (
|
||||
<div className="text-xs px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 font-mono shadow-[0_0_10px_rgba(240,185,11,0.1)]">
|
||||
{positions.length} {t('active', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{positions && positions.length > 0 ? (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-left border-b border-white/5">
|
||||
<tr>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left">{t('symbol', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('side', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{language === 'zh' ? '操作' : 'Action'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('entryPrice', language)}>{language === 'zh' ? '入场价' : 'Entry'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('markPrice', language)}>{language === 'zh' ? '标记价' : 'Mark'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('quantity', language)}>{language === 'zh' ? '数量' : 'Qty'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('positionValue', language)}>{language === 'zh' ? '价值' : 'Value'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center" title={t('leverage', language)}>{language === 'zh' ? '杠杆' : 'Lev.'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('unrealizedPnL', language)}>{language === 'zh' ? '未实现盈亏' : 'uPnL'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('liqPrice', language)}>{language === 'zh' ? '强平价' : 'Liq.'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedPositions.map((pos, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-white/5 last:border-0 transition-all hover:bg-white/5 cursor-pointer group/row"
|
||||
onClick={() => {
|
||||
setSelectedChartSymbol(pos.symbol)
|
||||
setChartUpdateKey(Date.now())
|
||||
if (chartSectionRef.current) {
|
||||
chartSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left text-nofx-text-main group-hover/row:text-white transition-colors">
|
||||
{pos.symbol}
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${pos.side === 'long' ? 'bg-nofx-green/10 text-nofx-green shadow-[0_0_8px_rgba(14,203,129,0.2)]' : 'bg-nofx-red/10 text-nofx-red shadow-[0_0_8px_rgba(246,70,93,0.2)]'}`}
|
||||
>
|
||||
{t(pos.side === 'long' ? 'long' : 'short', language)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleClosePosition(pos.symbol, pos.side.toUpperCase())
|
||||
}}
|
||||
disabled={closingPosition === pos.symbol}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed mx-auto bg-nofx-red/10 text-nofx-red border border-nofx-red/30 hover:bg-nofx-red/20"
|
||||
title={language === 'zh' ? '平仓' : 'Close Position'}
|
||||
>
|
||||
{closingPosition === pos.symbol ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="w-3 h-3" />
|
||||
)}
|
||||
{language === 'zh' ? '平仓' : 'Close'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.entry_price.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{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 font-bold whitespace-nowrap text-right text-nofx-text-main">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold">{pos.leverage}x</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
|
||||
<span
|
||||
className={`font-bold ${pos.unrealized_pnl >= 0 ? 'text-nofx-green shadow-nofx-green' : 'text-nofx-red shadow-nofx-red'}`}
|
||||
style={{ textShadow: pos.unrealized_pnl >= 0 ? '0 0 10px rgba(14,203,129,0.3)' : '0 0 10px rgba(246,70,93,0.3)' }}
|
||||
>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||
{pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted">{pos.liquidation_price.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination footer */}
|
||||
{totalPositions > 10 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 mt-4 text-xs border-t border-white/5 text-nofx-text-muted">
|
||||
<span>
|
||||
{language === 'zh'
|
||||
? `显示 ${paginatedPositions.length} / ${totalPositions} 个持仓`
|
||||
: `Showing ${paginatedPositions.length} of ${totalPositions} positions`}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{language === 'zh' ? '每页' : 'Per page'}:</span>
|
||||
<select
|
||||
value={positionsPageSize}
|
||||
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
|
||||
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main focus:outline-none focus:border-nofx-gold/50 transition-colors"
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
{totalPositionPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{['«', '‹', `${positionsCurrentPage} / ${totalPositionPages}`, '›', '»'].map((label, idx) => {
|
||||
const isText = idx === 2;
|
||||
const isFirst = idx === 0;
|
||||
const isPrev = idx === 1;
|
||||
const isNext = idx === 3;
|
||||
const isLast = idx === 4;
|
||||
if (isText) return <span key={idx} className="px-3 text-nofx-text-main">{label}</span>;
|
||||
|
||||
let onClick = () => { };
|
||||
let disabled = false;
|
||||
|
||||
if (isFirst) { onClick = () => setPositionsCurrentPage(1); disabled = positionsCurrentPage === 1; }
|
||||
if (isPrev) { onClick = () => setPositionsCurrentPage(p => Math.max(1, p - 1)); disabled = positionsCurrentPage === 1; }
|
||||
if (isNext) { onClick = () => setPositionsCurrentPage(p => Math.min(totalPositionPages, p + 1)); disabled = positionsCurrentPage === totalPositionPages; }
|
||||
if (isLast) { onClick = () => setPositionsCurrentPage(totalPositionPages); disabled = positionsCurrentPage === totalPositionPages; }
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`px-2 py-1 rounded transition-colors ${disabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-white/10 text-nofx-text-main bg-white/5'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-nofx-text-muted opacity-60">
|
||||
<div className="text-6xl mb-4 opacity-50 grayscale">📊</div>
|
||||
<div className="text-lg font-semibold mb-2">{t('noPositions', language)}</div>
|
||||
<div className="text-sm">{t('noActivePositions', language)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Recent Decisions */}
|
||||
<div
|
||||
className="nofx-glass p-6 animate-slide-in h-fit lg:sticky lg:top-24 lg:max-h-[calc(100vh-120px)] flex flex-col"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-5 pb-4 border-b border-white/5 shrink-0">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl shadow-[0_4px_14px_rgba(99,102,241,0.4)]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%)',
|
||||
}}
|
||||
>
|
||||
🧠
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-nofx-text-main">
|
||||
{t('recentDecisions', language)}
|
||||
</h2>
|
||||
{decisions && decisions.length > 0 && (
|
||||
<div className="text-xs text-nofx-text-muted">
|
||||
{t('lastCycles', language, { count: decisions.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Limit Selector */}
|
||||
<select
|
||||
value={decisionsLimit}
|
||||
onChange={(e) => onDecisionsLimitChange(Number(e.target.value))}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent focus:outline-none"
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Decisions List - Scrollable */}
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto pr-2 custom-scrollbar"
|
||||
style={{ maxHeight: 'calc(100vh - 280px)' }}
|
||||
>
|
||||
{decisions && decisions.length > 0 ? (
|
||||
decisions.map((decision, i) => (
|
||||
<DecisionCard key={i} decision={decision} language={language} onSymbolClick={handleSymbolClick} />
|
||||
))
|
||||
) : (
|
||||
<div className="py-16 text-center text-nofx-text-muted opacity-60">
|
||||
<div className="text-6xl mb-4 opacity-30 grayscale">🧠</div>
|
||||
<div className="text-lg font-semibold mb-2 text-nofx-text-main">
|
||||
{t('noDecisionsYet', language)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t('aiDecisionsWillAppear', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position History Section */}
|
||||
{selectedTraderId && (
|
||||
<div
|
||||
className="nofx-glass p-6 animate-slide-in"
|
||||
style={{ animationDelay: '0.25s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2 text-nofx-text-main">
|
||||
<span className="text-2xl">📜</span>
|
||||
{t('positionHistory.title', language)}
|
||||
</h2>
|
||||
</div>
|
||||
<PositionHistory traderId={selectedTraderId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
|
||||
// Stat Card Component - Deep Void Style
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
change,
|
||||
positive,
|
||||
subtitle,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
unit?: string
|
||||
change?: number
|
||||
positive?: boolean
|
||||
subtitle?: string
|
||||
icon?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity text-4xl grayscale group-hover:grayscale-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||
{value}
|
||||
</div>
|
||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||
</div>
|
||||
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
||||
>
|
||||
<span>{positive ? '▲' : '▼'}</span>
|
||||
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export interface TraderInfo {
|
||||
strategy_id?: string
|
||||
strategy_name?: string
|
||||
custom_prompt?: string
|
||||
use_coin_pool?: boolean
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
system_prompt_template?: string
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export interface CreateTraderRequest {
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
use_coin_pool?: boolean
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ export interface TraderConfigData {
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
use_coin_pool?: boolean
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
@@ -428,11 +428,32 @@ export interface Strategy {
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
is_public: boolean; // 是否在策略市场公开
|
||||
config_visible: boolean; // 配置参数是否公开可见
|
||||
config: StrategyConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 策略使用统计
|
||||
export interface StrategyStats {
|
||||
clone_count: number; // 被克隆次数
|
||||
active_users: number; // 当前使用人数
|
||||
top_performers?: StrategyPerformer[]; // 收益排行
|
||||
}
|
||||
|
||||
// 策略使用者收益排行
|
||||
export interface StrategyPerformer {
|
||||
user_id: string;
|
||||
user_name: string; // 脱敏后的用户名
|
||||
total_pnl_pct: number; // 总收益率
|
||||
total_pnl: number; // 总收益金额
|
||||
win_rate: number; // 胜率
|
||||
trade_count: number; // 交易次数
|
||||
using_since: string; // 使用开始时间
|
||||
rank: number; // 排名
|
||||
}
|
||||
|
||||
export interface PromptSectionsConfig {
|
||||
role_definition?: string;
|
||||
trading_frequency?: string;
|
||||
@@ -441,6 +462,9 @@ export interface PromptSectionsConfig {
|
||||
}
|
||||
|
||||
export interface StrategyConfig {
|
||||
// Language setting: "zh" for Chinese, "en" for English
|
||||
// Determines the language used for data formatting and prompt generation
|
||||
language?: 'zh' | 'en';
|
||||
coin_source: CoinSourceConfig;
|
||||
indicators: IndicatorConfig;
|
||||
custom_prompt?: string;
|
||||
@@ -449,14 +473,14 @@ export interface StrategyConfig {
|
||||
}
|
||||
|
||||
export interface CoinSourceConfig {
|
||||
source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed';
|
||||
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
|
||||
static_coins?: string[];
|
||||
use_coin_pool: boolean;
|
||||
coin_pool_limit?: number;
|
||||
coin_pool_api_url?: string; // AI500 币种池 API URL
|
||||
excluded_coins?: string[]; // 排除的币种列表
|
||||
use_ai500: boolean;
|
||||
ai500_limit?: number;
|
||||
use_oi_top: boolean;
|
||||
oi_top_limit?: number;
|
||||
oi_top_api_url?: string; // OI Top API URL
|
||||
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
@@ -477,16 +501,30 @@ export interface IndicatorConfig {
|
||||
atr_periods?: number[];
|
||||
boll_periods?: number[];
|
||||
external_data_sources?: ExternalDataSource[];
|
||||
|
||||
// ========== NofxOS 数据源统一配置 ==========
|
||||
// Unified NofxOS API Key - used for all NofxOS data sources
|
||||
nofxos_api_key?: string;
|
||||
|
||||
// 量化数据源(资金流向、持仓变化、价格变化)
|
||||
enable_quant_data?: boolean;
|
||||
quant_data_api_url?: string;
|
||||
enable_quant_oi?: boolean;
|
||||
enable_quant_netflow?: boolean;
|
||||
|
||||
// OI 排行数据(市场持仓量增减排行)
|
||||
enable_oi_ranking?: boolean;
|
||||
oi_ranking_api_url?: string;
|
||||
oi_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
oi_ranking_limit?: number;
|
||||
|
||||
// NetFlow 排行数据(机构/散户资金流向排行)
|
||||
enable_netflow_ranking?: boolean;
|
||||
netflow_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
netflow_ranking_limit?: number;
|
||||
|
||||
// Price 排行数据(涨跌幅排行)
|
||||
enable_price_ranking?: boolean;
|
||||
price_ranking_duration?: string; // "1h", "4h", "24h" or "1h,4h,24h"
|
||||
price_ranking_limit?: number;
|
||||
}
|
||||
|
||||
export interface KlineConfig {
|
||||
|
||||
Reference in New Issue
Block a user