11 Commits

Author SHA1 Message Date
tinkle-community
50923f6a2e feat: add DeepVoidBackground and update UI theme across pages
- Add DeepVoidBackground component with animated gradient effects
- Apply nofx theme classes to StrategyStudioPage, AITradersPage, etc.
- Update styling for consistent dark theme with gold accents
- Add PageNotFound and TraderDashboardPage components
2026-01-04 17:49:59 +08:00
tinkle-community
bdfd8dc0d0 fix: auto-restart trader on config update and add scan interval debug logs
- RemoveTrader now stops running trader before removing from memory
- handleUpdateTrader auto-restarts trader if it was running before update
- Add debug logs to trace scan_interval_minutes through update/save/load flow
2026-01-04 01:27:30 +08:00
tinkle-community
0275e23b7e feat: unify NofxOS data provider and fix language consistency
- Add unified NofxOS API key configuration in IndicatorEditor
- Add language field to StrategyConfig for consistent prompt generation
- Auto-update prompt sections when interface language changes
- Remove scattered URL inputs from CoinSourceEditor and IndicatorEditor
- Create nofxos provider package with formatted data output
- Update kernel engine to use config-based language setting
2026-01-04 00:59:07 +08:00
tinkle-community
13fda47151 refactor: rename decision package to kernel 2026-01-03 14:25:40 +08:00
tinkle-community
d664dcca3d style: update CSS styles 2026-01-03 13:44:44 +08:00
tinkle-community
04141642a5 feat: improve landing page responsiveness and styling 2026-01-03 13:12:25 +08:00
tinkle-community
7f7c4ea2a7 fix: sanitize API error messages to prevent sensitive info exposure 2026-01-03 13:11:15 +08:00
tinkle-community
e07dc0de86 feat: add excluded coins filter for strategy
- Add excluded_coins field to CoinSourceConfig
- Filter excluded coins in GetCandidateCoins function
- Add excluded coins UI in CoinSourceEditor
2026-01-03 01:21:17 +08:00
tinkle-community
cc726adb57 feat: add strategy publish settings and reorder navigation
- Add is_public and config_visible fields to Strategy type
- Add PublishSettingsEditor component for strategy studio
- Enable GORM AutoMigrate to add new columns
- Reorder nav: Market → Config → Dashboard → Strategy → Leaderboard → Arena → Backtest → FAQ
- Rename Live to Leaderboard, Debate Arena to Arena
2026-01-03 00:52:11 +08:00
tinkle-community
7df8197542 fix: convert branch name for docker manifest tags 2026-01-01 23:34:43 +08:00
tinkle-community
60194306e1 feat: add stable release branch support
- Add release/stable branch to CI workflow
- Create docker-compose.stable.yml with :stable tag
- Create install-stable.sh for one-click deployment
- Add stable tag creation in manifest step
2026-01-01 23:27:53 +08:00
67 changed files with 7579 additions and 5983 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: branches:
- main - main
- dev - dev
- release/stable
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
@@ -152,12 +153,14 @@ jobs:
env: env:
IMAGE_BASE: ${{ needs.prepare.outputs.image_base }} IMAGE_BASE: ${{ needs.prepare.outputs.image_base }}
run: | 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 }}" GHCR_IMAGE="${{ env.REGISTRY_GHCR }}/${IMAGE_BASE}/nofx-${{ matrix.image_suffix }}"
echo "📦 Creating manifest for ${{ matrix.image_suffix }}" echo "📦 Creating manifest for ${{ matrix.image_suffix }}"
echo "Repository: ${IMAGE_BASE}" echo "Repository: ${IMAGE_BASE}"
echo "Image: ${GHCR_IMAGE}" echo "Image: ${GHCR_IMAGE}"
echo "Ref name: ${REF_NAME}"
docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \ docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \
"${GHCR_IMAGE}:${REF_NAME}-amd64" \ "${GHCR_IMAGE}:${REF_NAME}-amd64" \
@@ -171,6 +174,14 @@ jobs:
echo "✅ Created latest tag (main branch only)" echo "✅ Created latest tag (main branch only)"
fi 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 if [[ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]]; then
DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}" DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}"
docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:${REF_NAME}" \ docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:${REF_NAME}" \

View File

@@ -15,7 +15,7 @@ import (
"nofx/backtest" "nofx/backtest"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/provider" "nofx/provider/nofxos"
"nofx/store" "nofx/store"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -60,7 +60,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
var req backtestStartRequest var req backtestStartRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -78,23 +78,23 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
if cfg.StrategyID != "" { if cfg.StrategyID != "" {
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID) strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
if err != nil { 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 return
} }
if strategy == nil { if strategy == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("strategy not found: %s", cfg.StrategyID)}) SafeBadRequest(c, "Strategy not found")
return return
} }
var strategyConfig store.StrategyConfig var strategyConfig store.StrategyConfig
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil { 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 return
} }
cfg.SetLoadedStrategy(&strategyConfig) cfg.SetLoadedStrategy(&strategyConfig)
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID) 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.SourceType,
strategyConfig.CoinSource.UseCoinPool, strategyConfig.CoinSource.UseAI500,
strategyConfig.CoinSource.UseOITop, strategyConfig.CoinSource.UseOITop,
strategyConfig.CoinSource.StaticCoins) strategyConfig.CoinSource.StaticCoins)
@@ -102,7 +102,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
if len(cfg.Symbols) == 0 { if len(cfg.Symbols) == 0 {
symbols, err := s.resolveStrategyCoins(&strategyConfig) symbols, err := s.resolveStrategyCoins(&strategyConfig)
if err != nil { 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 return
} }
cfg.Symbols = symbols cfg.Symbols = symbols
@@ -111,7 +111,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
} }
if err := s.hydrateBacktestAIConfig(&cfg); err != nil { if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Failed to configure AI model")
return return
} }
@@ -120,7 +120,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
runner, err := s.backtestManager.Start(context.Background(), cfg) runner, err := s.backtestManager.Start(context.Background(), cfg)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
return return
} }
@@ -149,11 +149,11 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
var req runIDRequest var req runIDRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
if req.RunID == "" { if req.RunID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"}) SafeBadRequest(c, "run_id is required")
return return
} }
@@ -162,7 +162,7 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
} }
if err := fn(req.RunID); err != nil { 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 return
} }
@@ -181,11 +181,11 @@ func (s *Server) handleBacktestLabel(c *gin.Context) {
} }
var req labelRequest var req labelRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
if strings.TrimSpace(req.RunID) == "" { if strings.TrimSpace(req.RunID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"}) SafeBadRequest(c, "run_id is required")
return return
} }
userID := normalizeUserID(c.GetString("user_id")) 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) meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "Update backtest label", err)
return return
} }
c.JSON(http.StatusOK, meta) c.JSON(http.StatusOK, meta)
@@ -207,11 +207,11 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
} }
var req runIDRequest var req runIDRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
if strings.TrimSpace(req.RunID) == "" { if strings.TrimSpace(req.RunID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"}) SafeBadRequest(c, "run_id is required")
return return
} }
userID := normalizeUserID(c.GetString("user_id")) userID := normalizeUserID(c.GetString("user_id"))
@@ -219,7 +219,7 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
return return
} }
if err := s.backtestManager.Delete(req.RunID); err != nil { 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 return
} }
c.JSON(http.StatusOK, gin.H{"message": "deleted"}) c.JSON(http.StatusOK, gin.H{"message": "deleted"})
@@ -277,7 +277,7 @@ func (s *Server) handleBacktestRuns(c *gin.Context) {
metas, err := s.backtestManager.ListRuns() metas, err := s.backtestManager.ListRuns()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "List backtest runs", err)
return return
} }
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state"))) 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) points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
return return
} }
c.JSON(http.StatusOK, points) c.JSON(http.StatusOK, points)
@@ -375,7 +375,7 @@ func (s *Server) handleBacktestTrades(c *gin.Context) {
events, err := s.backtestManager.LoadTrades(runID, limit) events, err := s.backtestManager.LoadTrades(runID, limit)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
return return
} }
c.JSON(http.StatusOK, events) 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"}) c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
return return
} }
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
return return
} }
c.JSON(http.StatusOK, metrics) c.JSON(http.StatusOK, metrics)
@@ -427,7 +427,7 @@ func (s *Server) handleBacktestTrace(c *gin.Context) {
cycle := queryInt(c, "cycle", 0) cycle := queryInt(c, "cycle", 0)
record, err := s.backtestManager.GetTrace(runID, cycle) record, err := s.backtestManager.GetTrace(runID, cycle)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trace record")
return return
} }
c.JSON(http.StatusOK, record) c.JSON(http.StatusOK, record)
@@ -461,7 +461,7 @@ func (s *Server) handleBacktestDecisions(c *gin.Context) {
records, err := backtest.LoadDecisionRecords(runID, limit, offset) records, err := backtest.LoadDecisionRecords(runID, limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "Load decision records", err)
return return
} }
c.JSON(http.StatusOK, records) c.JSON(http.StatusOK, records)
@@ -483,7 +483,7 @@ func (s *Server) handleBacktestExport(c *gin.Context) {
} }
path, err := s.backtestManager.ExportRun(runID) path, err := s.backtestManager.ExportRun(runID)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
return return
} }
defer os.Remove(path) defer os.Remove(path)
@@ -536,8 +536,7 @@ func (s *Server) handleBacktestKlines(c *gin.Context) {
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime) klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
if err != nil { if err != nil {
logger.Errorf("Failed to fetch klines for %s: %v", symbol, err) SafeInternalError(c, "Fetch klines", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to fetch klines: %v", err)})
return return
} }
@@ -620,11 +619,11 @@ func writeBacktestAccessError(c *gin.Context, err error) bool {
} }
switch { switch {
case errors.Is(err, errBacktestForbidden): 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): 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: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "Access backtest", err)
} }
return true return true
} }
@@ -639,21 +638,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
var symbols []string var symbols []string
symbolSet := make(map[string]bool) 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 // Handle empty source_type - check flags for backward compatibility
sourceType := coinSource.SourceType sourceType := coinSource.SourceType
if sourceType == "" { if sourceType == "" {
if coinSource.UseCoinPool && coinSource.UseOITop { if coinSource.UseAI500 && coinSource.UseOITop {
sourceType = "mixed" sourceType = "mixed"
} else if coinSource.UseCoinPool { } else if coinSource.UseAI500 {
sourceType = "coinpool" sourceType = "ai500"
} else if coinSource.UseOITop { } else if coinSource.UseOITop {
sourceType = "oi_top" sourceType = "oi_top"
} else if len(coinSource.StaticCoins) > 0 { } else if len(coinSource.StaticCoins) > 0 {
@@ -674,13 +665,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
} }
} }
case "coinpool": case "ai500":
limit := coinSource.CoinPoolLimit limit := coinSource.AI500Limit
if limit <= 0 { if limit <= 0 {
limit = 30 limit = 30
} }
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit) logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
coins, err := provider.GetTopRatedCoins(limit) coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get AI500 coins: %w", err) 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": case "oi_top":
coins, err := provider.GetOITopSymbols() coins, err := nofxos.DefaultClient().GetOITopSymbols()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get OI Top coins: %w", err) 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": case "mixed":
// Get from coin pool // Get from AI500
if coinSource.UseCoinPool { if coinSource.UseAI500 {
limit := coinSource.CoinPoolLimit limit := coinSource.AI500Limit
if limit <= 0 { if limit <= 0 {
limit = 30 limit = 30
} }
coins, err := provider.GetTopRatedCoins(limit) coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
if err != nil { if err != nil {
logger.Warnf("Failed to get AI500 coins: %v", err) logger.Warnf("Failed to get AI500 coins: %v", err)
} else { } else {
@@ -736,7 +727,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
// Get from OI Top // Get from OI Top
if coinSource.UseOITop { if coinSource.UseOITop {
coins, err := provider.GetOITopSymbols() coins, err := nofxos.DefaultClient().GetOITopSymbols()
if err != nil { if err != nil {
logger.Warnf("Failed to get OI Top coins: %v", err) logger.Warnf("Failed to get OI Top coins: %v", err)
} else { } else {

View File

@@ -8,7 +8,7 @@ import (
"nofx/debate" "nofx/debate"
"nofx/logger" "nofx/logger"
"nofx/provider" "nofx/provider/nofxos"
"nofx/store" "nofx/store"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -131,7 +131,7 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
var req CreateDebateRequest var req CreateDebateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -158,35 +158,27 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
if len(coinSource.StaticCoins) > 0 { if len(coinSource.StaticCoins) > 0 {
req.Symbol = coinSource.StaticCoins[0] req.Symbol = coinSource.StaticCoins[0]
} }
case "coinpool": case "ai500":
// Fetch from coin pool API // Fetch from AI500 API
if coinSource.CoinPoolAPIURL != "" { if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
}
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
req.Symbol = 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": case "oi_top":
// Fetch from OI top API // Fetch from OI top API
if coinSource.OITopAPIURL != "" { if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
provider.SetOITopAPI(coinSource.OITopAPIURL)
}
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
req.Symbol = coins[0] req.Symbol = coins[0]
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol) logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
} }
case "mixed": case "mixed":
// Try coin pool first, then OI top // Try AI500 first, then OI top
if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" { if coinSource.UseAI500 {
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
req.Symbol = 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 != "" { } else if coinSource.UseOITop {
provider.SetOITopAPI(coinSource.OITopAPIURL) if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
req.Symbol = coins[0] req.Symbol = coins[0]
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol) 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 // Start debate asynchronously
if err := h.engine.StartDebate(debateID); err != nil { if err := h.engine.StartDebate(debateID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "Start debate", err)
return return
} }
@@ -316,7 +308,7 @@ func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
} }
if err := h.engine.CancelDebate(debateID); err != nil { if err := h.engine.CancelDebate(debateID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "Cancel debate", err)
return return
} }
@@ -495,20 +487,20 @@ func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
// Parse request // Parse request
var req ExecuteDebateRequest var req ExecuteDebateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
// Get trader executor // Get trader executor
executor, err := h.traderManager.GetTraderExecutor(req.TraderID) executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
if err != nil { 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 return
} }
// Execute consensus // Execute consensus
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil { if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) SafeInternalError(c, "Execute consensus", err)
return return
} }
@@ -635,7 +627,9 @@ func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.Deb
} }
func (h *DebateHandler) broadcastError(sessionID string, err error) { 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{}{ h.broadcast(sessionID, "error", map[string]interface{}{
"error": err.Error(), "error": safeMsg,
}) })
} }

95
api/errors.go Normal file
View 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()
}

View File

@@ -406,7 +406,7 @@ type CreateTraderRequest struct {
CustomPrompt string `json:"custom_prompt"` CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"` OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name 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"` UseOITop bool `json:"use_oi_top"`
} }
@@ -486,7 +486,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id") userID := c.GetString("user_id")
var req CreateTraderRequest var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -666,7 +666,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
BTCETHLeverage: btcEthLeverage, BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage, AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols, TradingSymbols: req.TradingSymbols,
UseCoinPool: req.UseCoinPool, UseAI500: req.UseAI500,
UseOITop: req.UseOITop, UseOITop: req.UseOITop,
CustomPrompt: req.CustomPrompt, CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt, OverrideBasePrompt: req.OverrideBasePrompt,
@@ -682,7 +682,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
err = s.store.Trader().Create(traderRecord) err = s.store.Trader().Create(traderRecord)
if err != nil { if err != nil {
logger.Infof("❌ Failed to create trader: %v", err) logger.Infof("❌ Failed to create trader: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create trader: %v", err)}) SafeInternalError(c, "Failed to create trader", err)
return return
} }
logger.Infof("🔧 DEBUG: CreateTrader succeeded") logger.Infof("🔧 DEBUG: CreateTrader succeeded")
@@ -732,7 +732,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
var req UpdateTraderRequest var req UpdateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -779,11 +779,13 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
// Set scan interval, allow updates // Set scan interval, allow updates
scanIntervalMinutes := req.ScanIntervalMinutes scanIntervalMinutes := req.ScanIntervalMinutes
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
if scanIntervalMinutes <= 0 { if scanIntervalMinutes <= 0 {
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
} else if scanIntervalMinutes < 3 { } else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 scanIntervalMinutes = 3
} }
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
// Set system prompt template // Set system prompt template
systemPromptTemplate := req.SystemPromptTemplate systemPromptTemplate := req.SystemPromptTemplate
@@ -818,16 +820,26 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
IsRunning: existingTrader.IsRunning, // Keep original value 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 // Update database
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, req.StrategyID=%s", logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, req.StrategyID) traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
err = s.store.Trader().Update(traderRecord) err = s.store.Trader().Update(traderRecord)
if err != nil { 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 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) s.traderManager.RemoveTrader(traderID)
// Reload traders into memory with fresh config // 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) 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) 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{ c.JSON(http.StatusOK, gin.H{
@@ -854,7 +878,7 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
// Delete from database // Delete from database
err := s.store.Trader().Delete(userID, traderID) err := s.store.Trader().Delete(userID, traderID)
if err != nil { 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 return
} }
@@ -1012,14 +1036,14 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
// Update database // Update database
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt) err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
if err != nil { 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 return
} }
@@ -1044,14 +1068,14 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
// Update database // Update database
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition) err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
if err != nil { 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 return
} }
@@ -1150,7 +1174,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
if createErr != nil { if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr) 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 return
} }
@@ -1158,7 +1182,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
balanceInfo, balanceErr := tempTrader.GetBalance() balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil { if balanceErr != nil {
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr) 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 return
} }
@@ -1302,7 +1326,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
if createErr != nil { if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr) 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 return
} }
@@ -1344,7 +1368,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
if closeErr != nil { if closeErr != nil {
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr) 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 return
} }
@@ -1582,7 +1606,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
models, err := s.store.AIModel().List(userID) models, err := s.store.AIModel().List(userID)
if err != nil { if err != nil {
logger.Infof("❌ Failed to get AI model configs: %v", err) 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 return
} }
@@ -1684,7 +1708,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
for modelID, modelData := range req.Models { for modelID, modelData := range req.Models {
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName) err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
if err != nil { 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 return
} }
} }
@@ -1706,8 +1730,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
logger.Infof("🔍 Querying exchange configs for user %s", userID) logger.Infof("🔍 Querying exchange configs for user %s", userID)
exchanges, err := s.store.Exchange().List(userID) exchanges, err := s.store.Exchange().List(userID)
if err != nil { if err != nil {
logger.Infof("❌ Failed to get exchange configs: %v", err) SafeInternalError(c, "Failed to get exchange configs", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get exchange configs: %v", err)})
return return
} }
@@ -1805,7 +1828,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
for exchangeID, exchangeData := range req.Exchanges { 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) 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 { 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 return
} }
} }
@@ -1910,7 +1933,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
) )
if err != nil { if err != nil {
logger.Infof("❌ Failed to create exchange account: %v", err) 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 return
} }
@@ -1953,7 +1976,7 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
err = s.store.Exchange().Delete(userID, exchangeID) err = s.store.Exchange().Delete(userID, exchangeID)
if err != nil { if err != nil {
logger.Infof("❌ Failed to delete exchange account: %v", err) 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 return
} }
@@ -1966,7 +1989,7 @@ func (s *Server) handleTraderList(c *gin.Context) {
userID := c.GetString("user_id") userID := c.GetString("user_id")
traders, err := s.store.Trader().List(userID) traders, err := s.store.Trader().List(userID)
if err != nil { 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 return
} }
@@ -2019,7 +2042,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID) fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Failed to get trader config: %v", err)}) SafeNotFound(c, "Trader config")
return return
} }
traderConfig := fullCfg.Trader traderConfig := fullCfg.Trader
@@ -2050,7 +2073,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
"custom_prompt": traderConfig.CustomPrompt, "custom_prompt": traderConfig.CustomPrompt,
"override_base_prompt": traderConfig.OverrideBasePrompt, "override_base_prompt": traderConfig.OverrideBasePrompt,
"is_cross_margin": traderConfig.IsCrossMargin, "is_cross_margin": traderConfig.IsCrossMargin,
"use_coin_pool": traderConfig.UseCoinPool, "use_ai500": traderConfig.UseAI500,
"use_oi_top": traderConfig.UseOITop, "use_oi_top": traderConfig.UseOITop,
"is_running": isRunning, "is_running": isRunning,
} }
@@ -2062,13 +2085,13 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
func (s *Server) handleStatus(c *gin.Context) { func (s *Server) handleStatus(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
@@ -2080,23 +2103,20 @@ func (s *Server) handleStatus(c *gin.Context) {
func (s *Server) handleAccount(c *gin.Context) { func (s *Server) handleAccount(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
logger.Infof("📊 Received account info request [%s]", trader.GetName()) logger.Infof("📊 Received account info request [%s]", trader.GetName())
account, err := trader.GetAccountInfo() account, err := trader.GetAccountInfo()
if err != nil { if err != nil {
logger.Infof("❌ Failed to get account info [%s]: %v", trader.GetName(), err) SafeInternalError(c, "Get account info", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get account info: %v", err),
})
return return
} }
@@ -2113,21 +2133,19 @@ func (s *Server) handleAccount(c *gin.Context) {
func (s *Server) handlePositions(c *gin.Context) { func (s *Server) handlePositions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
positions, err := trader.GetPositions() positions, err := trader.GetPositions()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get positions", err)
"error": fmt.Sprintf("Failed to get position list: %v", err),
})
return return
} }
@@ -2138,13 +2156,13 @@ func (s *Server) handlePositions(c *gin.Context) {
func (s *Server) handlePositionHistory(c *gin.Context) { func (s *Server) handlePositionHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
@@ -2165,9 +2183,7 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
// Get closed positions // Get closed positions
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit) positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get position history", err)
"error": fmt.Sprintf("Failed to get position history: %v", err),
})
return return
} }
@@ -2192,13 +2208,13 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
func (s *Server) handleTrades(c *gin.Context) { func (s *Server) handleTrades(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
@@ -2224,9 +2240,7 @@ func (s *Server) handleTrades(c *gin.Context) {
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit) allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get trades", err)
"error": fmt.Sprintf("Failed to get trades: %v", err),
})
return return
} }
@@ -2249,13 +2263,13 @@ func (s *Server) handleTrades(c *gin.Context) {
func (s *Server) handleOrders(c *gin.Context) { func (s *Server) handleOrders(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
@@ -2283,9 +2297,7 @@ func (s *Server) handleOrders(c *gin.Context) {
// Get all orders for this trader // Get all orders for this trader
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit) allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get orders", err)
"error": fmt.Sprintf("Failed to get orders: %v", err),
})
return return
} }
@@ -2317,13 +2329,13 @@ func (s *Server) handleOrderFills(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
@@ -2336,9 +2348,7 @@ func (s *Server) handleOrderFills(c *gin.Context) {
// Get fills for this order // Get fills for this order
fills, err := store.Order().GetOrderFills(orderID) fills, err := store.Order().GetOrderFills(orderID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get order fills", err)
"error": fmt.Sprintf("Failed to get order fills: %v", err),
})
return return
} }
@@ -2376,30 +2386,21 @@ func (s *Server) handleKlines(c *gin.Context) {
// US Stocks via Alpaca // US Stocks via Alpaca
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit) klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
if err != nil { if err != nil {
logger.Errorf("❌ Alpaca API failed for %s: %v", symbol, err) SafeInternalError(c, "Get klines from Alpaca", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from Alpaca: %v", err),
})
return return
} }
case "forex", "metals": case "forex", "metals":
// Forex and Metals via Twelve Data // Forex and Metals via Twelve Data
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit) klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
if err != nil { if err != nil {
logger.Errorf("❌ TwelveData API failed for %s: %v", symbol, err) SafeInternalError(c, "Get klines from TwelveData", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from TwelveData: %v", err),
})
return return
} }
case "hyperliquid", "hyperliquid-xyz", "xyz": case "hyperliquid", "hyperliquid-xyz", "xyz":
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex) // Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit) klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
if err != nil { if err != nil {
logger.Errorf("❌ Hyperliquid API failed for %s: %v", symbol, err) SafeInternalError(c, "Get klines from Hyperliquid", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from Hyperliquid: %v", err),
})
return return
} }
default: default:
@@ -2407,10 +2408,7 @@ func (s *Server) handleKlines(c *gin.Context) {
symbol = market.Normalize(symbol) symbol = market.Normalize(symbol)
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit) klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
if err != nil { if err != nil {
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err) SafeInternalError(c, "Get klines from CoinAnk", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
})
return return
} }
} }
@@ -2728,22 +2726,20 @@ func (s *Server) handleSymbols(c *gin.Context) {
func (s *Server) handleDecisions(c *gin.Context) { func (s *Server) handleDecisions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
// Get all historical decision records (unlimited) // Get all historical decision records (unlimited)
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000) records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get decision log", err)
"error": fmt.Sprintf("Failed to get decision log: %v", err),
})
return return
} }
@@ -2754,13 +2750,13 @@ func (s *Server) handleDecisions(c *gin.Context) {
func (s *Server) handleLatestDecisions(c *gin.Context) { func (s *Server) handleLatestDecisions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
@@ -2777,9 +2773,7 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit) records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get decision log", err)
"error": fmt.Sprintf("Failed to get decision log: %v", err),
})
return return
} }
@@ -2796,21 +2790,19 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
func (s *Server) handleStatistics(c *gin.Context) { func (s *Server) handleStatistics(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
trader, err := s.traderManager.GetTrader(traderID) trader, err := s.traderManager.GetTrader(traderID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) SafeNotFound(c, "Trader")
return return
} }
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID()) stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get statistics", err)
"error": fmt.Sprintf("Failed to get statistics: %v", err),
})
return return
} }
@@ -2829,9 +2821,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
competition, err := s.traderManager.GetCompetitionData() competition, err := s.traderManager.GetCompetitionData()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get competition data", err)
"error": fmt.Sprintf("Failed to get competition data: %v", err),
})
return return
} }
@@ -2843,7 +2833,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
func (s *Server) handleEquityHistory(c *gin.Context) { func (s *Server) handleEquityHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid trader ID")
return return
} }
@@ -2851,9 +2841,7 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
// Every 3 minutes per cycle: 10000 records = about 20 days of data // Every 3 minutes per cycle: 10000 records = about 20 days of data
snapshots, err := s.store.Equity().GetLatest(traderID, 10000) snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get historical data", err)
"error": fmt.Sprintf("Failed to get historical data: %v", err),
})
return return
} }
@@ -2931,7 +2919,8 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
// Validate JWT token // Validate JWT token
claims, err := auth.ValidateJWT(tokenString) claims, err := auth.ValidateJWT(tokenString)
if err != nil { 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() c.Abort()
return return
} }
@@ -2999,7 +2988,7 @@ func (s *Server) handleRegister(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -3036,7 +3025,7 @@ func (s *Server) handleRegister(c *gin.Context) {
err = s.store.User().Create(user) err = s.store.User().Create(user)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) SafeInternalError(c, "Failed to create user", err)
return return
} }
@@ -3059,14 +3048,14 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
// Get user information // Get user information
user, err := s.store.User().GetByID(req.UserID) user, err := s.store.User().GetByID(req.UserID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"}) SafeNotFound(c, "User")
return return
} }
@@ -3112,7 +3101,7 @@ func (s *Server) handleLogin(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -3156,14 +3145,14 @@ func (s *Server) handleVerifyOTP(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
// Get user information // Get user information
user, err := s.store.User().GetByID(req.UserID) user, err := s.store.User().GetByID(req.UserID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"}) SafeNotFound(c, "User")
return return
} }
@@ -3197,7 +3186,7 @@ func (s *Server) handleResetPassword(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) SafeBadRequest(c, "Invalid request parameters")
return return
} }
@@ -3326,9 +3315,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
// Get trader information from all users // Get trader information from all users
competition, err := s.traderManager.GetCompetitionData() competition, err := s.traderManager.GetCompetitionData()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get trader list", err)
"error": fmt.Sprintf("Failed to get trader list: %v", err),
})
return return
} }
@@ -3371,9 +3358,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
func (s *Server) handlePublicCompetition(c *gin.Context) { func (s *Server) handlePublicCompetition(c *gin.Context) {
competition, err := s.traderManager.GetCompetitionData() competition, err := s.traderManager.GetCompetitionData()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get competition data", err)
"error": fmt.Sprintf("Failed to get competition data: %v", err),
})
return return
} }
@@ -3384,9 +3369,7 @@ func (s *Server) handlePublicCompetition(c *gin.Context) {
func (s *Server) handleTopTraders(c *gin.Context) { func (s *Server) handleTopTraders(c *gin.Context) {
topTraders, err := s.traderManager.GetTopTradersData() topTraders, err := s.traderManager.GetTopTradersData()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get top traders data", err)
"error": fmt.Sprintf("Failed to get top 10 trader data: %v", err),
})
return return
} }
@@ -3409,9 +3392,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
// If no trader_ids specified, return historical data for top 5 // If no trader_ids specified, return historical data for top 5
topTraders, err := s.traderManager.GetTopTradersData() topTraders, err := s.traderManager.GetTopTradersData()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ SafeInternalError(c, "Get top traders", err)
"error": fmt.Sprintf("Failed to get top 5 traders: %v", err),
})
return return
} }
@@ -3506,7 +3487,8 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
snapshots, err = s.store.Equity().GetLatest(traderID, 500) snapshots, err = s.store.Equity().GetLatest(traderID, 500)
} }
if err != nil { 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 continue
} }

View File

@@ -4,11 +4,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"nofx/decision" "nofx/kernel"
"nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/store" "nofx/store"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,11 +19,11 @@ import (
func validateStrategyConfig(config *store.StrategyConfig) []string { func validateStrategyConfig(config *store.StrategyConfig) []string {
var warnings []string var warnings []string
// Validate quant data URL if enabled // Validate NofxOS API key if any NofxOS feature is enabled
if config.Indicators.EnableQuantData && config.Indicators.QuantDataAPIURL != "" { if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
if !strings.Contains(config.Indicators.QuantDataAPIURL, "{symbol}") { config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) &&
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.") config.Indicators.NofxOSAPIKey == "" {
} warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.")
} }
return warnings return warnings
@@ -33,7 +33,7 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
func (s *Server) handlePublicStrategies(c *gin.Context) { func (s *Server) handlePublicStrategies(c *gin.Context) {
strategies, err := s.store.Strategy().ListPublic() strategies, err := s.store.Strategy().ListPublic()
if err != nil { 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 return
} }
@@ -76,7 +76,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
strategies, err := s.store.Strategy().List(userID) strategies, err := s.store.Strategy().List(userID)
if err != nil { 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 return
} }
@@ -151,14 +151,14 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
// Serialize configuration // Serialize configuration
configJSON, err := json.Marshal(req.Config) configJSON, err := json.Marshal(req.Config)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"}) SafeInternalError(c, "Serialize configuration", err)
return return
} }
@@ -173,7 +173,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
} }
if err := s.store.Strategy().Create(strategy); err != nil { 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 return
} }
@@ -221,14 +221,14 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
// Serialize configuration // Serialize configuration
configJSON, err := json.Marshal(req.Config) configJSON, err := json.Marshal(req.Config)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"}) SafeInternalError(c, "Serialize configuration", err)
return return
} }
@@ -243,7 +243,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
} }
if err := s.store.Strategy().Update(strategy); err != nil { 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 return
} }
@@ -269,7 +269,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
} }
if err := s.store.Strategy().Delete(userID, strategyID); err != nil { if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete strategy: " + err.Error()}) SafeInternalError(c, "Failed to delete strategy", err)
return return
} }
@@ -287,7 +287,7 @@ func (s *Server) handleActivateStrategy(c *gin.Context) {
} }
if err := s.store.Strategy().SetActive(userID, strategyID); err != nil { 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 return
} }
@@ -309,13 +309,13 @@ func (s *Server) handleDuplicateStrategy(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
newID := uuid.New().String() newID := uuid.New().String()
if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil { 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 return
} }
@@ -383,7 +383,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
@@ -396,7 +396,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
} }
// Create strategy engine to build prompt // 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) // Build system prompt (using built-in method from strategy engine)
systemPrompt := engine.BuildSystemPrompt( systemPrompt := engine.BuildSystemPrompt(
@@ -433,7 +433,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
@@ -442,13 +442,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
} }
// Create strategy engine to build prompt // Create strategy engine to build prompt
engine := decision.NewStrategyEngine(&req.Config) engine := kernel.NewStrategyEngine(&req.Config)
// Get candidate coins // Get candidate coins
candidates, err := engine.GetCandidateCoins() candidates, err := engine.GetCandidateCoins()
if err != nil { if err != nil {
logger.Errorf("[API Error] Failed to get candidate coins: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get candidate coins: " + err.Error(), "error": "Failed to get candidate coins",
"ai_response": "", "ai_response": "",
}) })
return return
@@ -502,12 +503,18 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
// Fetch OI ranking data (market-wide position changes) // Fetch OI ranking data (market-wide position changes)
oiRankingData := engine.FetchOIRankingData() 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) // 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"), CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: 0, RuntimeMinutes: 0,
CallCount: 1, CallCount: 1,
Account: decision.AccountInfo{ Account: kernel.AccountInfo{
TotalEquity: 1000.0, TotalEquity: 1000.0,
AvailableBalance: 1000.0, AvailableBalance: 1000.0,
UnrealizedPnL: 0, UnrealizedPnL: 0,
@@ -517,12 +524,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
MarginUsedPct: 0, MarginUsedPct: 0,
PositionCount: 0, PositionCount: 0,
}, },
Positions: []decision.PositionInfo{}, Positions: []kernel.PositionInfo{},
CandidateCoins: candidates, CandidateCoins: candidates,
PromptVariant: req.PromptVariant, PromptVariant: req.PromptVariant,
MarketDataMap: marketDataMap, MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap, QuantDataMap: quantDataMap,
OIRankingData: oiRankingData, OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,
} }
// Build System Prompt // Build System Prompt

View File

@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"nofx/decision" "nofx/kernel"
"nofx/market" "nofx/market"
) )
@@ -17,7 +17,7 @@ type cachedDecision struct {
Key string `json:"key"` Key string `json:"key"`
PromptVariant string `json:"prompt_variant"` PromptVariant string `json:"prompt_variant"`
Timestamp int64 `json:"ts"` Timestamp int64 `json:"ts"`
Decision *decision.FullDecision `json:"decision"` Decision *kernel.FullDecision `json:"decision"`
} }
// AICache persists AI decisions for repeated backtesting or replay. // AICache persists AI decisions for repeated backtesting or replay.
@@ -67,7 +67,7 @@ func (c *AICache) Path() string {
return c.path 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 == "" { if c == nil || key == "" {
return nil, false return nil, false
} }
@@ -80,7 +80,7 @@ func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
return cloneDecision(entry.Decision), true 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 { if c == nil || key == "" || decision == nil {
return nil return nil
} }
@@ -109,7 +109,7 @@ func (c *AICache) save() error {
return writeFileAtomic(c.path, data, 0o644) return writeFileAtomic(c.path, data, 0o644)
} }
func cloneDecision(src *decision.FullDecision) *decision.FullDecision { func cloneDecision(src *kernel.FullDecision) *kernel.FullDecision {
if src == nil { if src == nil {
return nil return nil
} }
@@ -117,14 +117,14 @@ func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
if err != nil { if err != nil {
return nil return nil
} }
var dst decision.FullDecision var dst kernel.FullDecision
if err := json.Unmarshal(data, &dst); err != nil { if err := json.Unmarshal(data, &dst); err != nil {
return nil return nil
} }
return &dst 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 { if ctx == nil {
return "", fmt.Errorf("context is 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"` Variant string `json:"variant"`
Timestamp int64 `json:"ts"` Timestamp int64 `json:"ts"`
CurrentTime string `json:"current_time"` CurrentTime string `json:"current_time"`
Account decision.AccountInfo `json:"account"` Account kernel.AccountInfo `json:"account"`
Positions []decision.PositionInfo `json:"positions"` Positions []kernel.PositionInfo `json:"positions"`
CandidateCoins []decision.CandidateCoin `json:"candidate_coins"` CandidateCoins []kernel.CandidateCoin `json:"candidate_coins"`
MarketData map[string]market.Data `json:"market"` MarketData map[string]market.Data `json:"market"`
MarginUsedPct float64 `json:"margin_used_pct"` MarginUsedPct float64 `json:"margin_used_pct"`
Runtime int `json:"runtime_minutes"` Runtime int `json:"runtime_minutes"`

View File

@@ -199,7 +199,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
if len(cfg.Symbols) > 0 { if len(cfg.Symbols) > 0 {
result.CoinSource.SourceType = "static" result.CoinSource.SourceType = "static"
result.CoinSource.StaticCoins = cfg.Symbols result.CoinSource.StaticCoins = cfg.Symbols
result.CoinSource.UseCoinPool = false result.CoinSource.UseAI500 = false
result.CoinSource.UseOITop = false result.CoinSource.UseOITop = false
} }
@@ -241,12 +241,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
return &store.StrategyConfig{ return &store.StrategyConfig{
CoinSource: store.CoinSourceConfig{ CoinSource: store.CoinSourceConfig{
SourceType: "static", SourceType: "static",
StaticCoins: cfg.Symbols, StaticCoins: cfg.Symbols,
UseCoinPool: false, UseAI500: false,
CoinPoolLimit: len(cfg.Symbols), AI500Limit: len(cfg.Symbols),
UseOITop: false, UseOITop: false,
OITopLimit: 0, OITopLimit: 0,
}, },
Indicators: store.IndicatorConfig{ Indicators: store.IndicatorConfig{
Klines: store.KlineConfig{ Klines: store.KlineConfig{

View File

@@ -13,7 +13,7 @@ import (
"sync" "sync"
"time" "time"
"nofx/decision" "nofx/kernel"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/store" "nofx/store"
@@ -34,7 +34,7 @@ type Runner struct {
cfg BacktestConfig cfg BacktestConfig
feed *DataFeed feed *DataFeed
account *BacktestAccount account *BacktestAccount
strategyEngine *decision.StrategyEngine strategyEngine *kernel.StrategyEngine
decisionLogDir string decisionLogDir string
mcpClient mcp.AIClient 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 // Create strategy engine from backtest config for unified prompt generation
strategyConfig := cfg.ToStrategyConfig() strategyConfig := cfg.ToStrategyConfig()
strategyEngine := decision.NewStrategyEngine(strategyConfig) strategyEngine := kernel.NewStrategyEngine(strategyConfig)
r := &Runner{ r := &Runner{
cfg: cfg, cfg: cfg,
@@ -305,7 +305,7 @@ func (r *Runner) stepOnce() error {
record = rec record = rec
var ( var (
fullDecision *decision.FullDecision fullDecision *kernel.FullDecision
fromCache bool fromCache bool
cacheKey string cacheKey string
) )
@@ -470,7 +470,7 @@ func (r *Runner) stepOnce() error {
return nil 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) equity, unrealized, _ := r.account.TotalEquity(priceMap)
available := r.account.Cash() available := r.account.Cash()
marginUsed := r.totalMarginUsed() marginUsed := r.totalMarginUsed()
@@ -479,7 +479,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
marginPct = (marginUsed / equity) * 100 marginPct = (marginUsed / equity) * 100
} }
accountInfo := decision.AccountInfo{ accountInfo := kernel.AccountInfo{
TotalEquity: equity, TotalEquity: equity,
AvailableBalance: available, AvailableBalance: available,
TotalPnL: equity - r.account.InitialBalance(), 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() candidateCoins, err := r.strategyEngine.GetCandidateCoins()
if err != nil { if err != nil {
// Fallback to simple list if strategy engine fails // 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 { 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) 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"), CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: runtime, RuntimeMinutes: runtime,
CallCount: callCount, 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) // Fetch quantitative data if enabled in strategy (uses current data as approximation)
strategyConfig := r.strategyEngine.GetConfig() strategyConfig := r.strategyEngine.GetConfig()
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" { if strategyConfig.Indicators.EnableQuantData {
// Collect symbols to query (candidate coins + position coins) // Collect symbols to query (candidate coins + position coins)
symbolSet := make(map[string]bool) symbolSet := make(map[string]bool)
for _, sym := range r.cfg.Symbols { 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{ record := &store.DecisionRecord{
AccountState: store.AccountSnapshot{ AccountState: store.AccountSnapshot{
TotalBalance: accountInfo.TotalEquity, TotalBalance: accountInfo.TotalEquity,
@@ -566,7 +584,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
return ctx, record, nil 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.InputPrompt = full.UserPrompt
record.CoTTrace = full.CoTTrace record.CoTTrace = full.CoTTrace
if len(full.Decisions) > 0 { 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 var lastErr error
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ { for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine // Use GetFullDecisionWithStrategy with the pre-configured strategy engine
// This ensures backtest uses the same unified prompt generation as live trading // This ensures backtest uses the same unified prompt generation as live trading
fd, err := decision.GetFullDecisionWithStrategy( fd, err := kernel.GetFullDecisionWithStrategy(
ctx, ctx,
r.mcpClient, r.mcpClient,
r.strategyEngine, r.strategyEngine,
@@ -597,7 +615,7 @@ func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecisio
return nil, lastErr 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 symbol := dec.Symbol
usedLeverage := r.resolveLeverage(dec.Leverage, symbol) usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
actionRecord := store.DecisionAction{ 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() snapshot := r.snapshotState()
equity := snapshot.Equity equity := snapshot.Equity
if equity <= 0 { if equity <= 0 {
@@ -777,7 +795,7 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
return qty 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() { for _, pos := range r.account.Positions() {
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side { if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
return pos.Quantity return pos.Quantity
@@ -831,12 +849,12 @@ func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.Position
return list 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() positions := r.account.Positions()
list := make([]decision.PositionInfo, 0, len(positions)) list := make([]kernel.PositionInfo, 0, len(positions))
for _, pos := range positions { for _, pos := range positions {
price := priceMap[pos.Symbol] price := priceMap[pos.Symbol]
list = append(list, decision.PositionInfo{ list = append(list, kernel.PositionInfo{
Symbol: pos.Symbol, Symbol: pos.Symbol,
Side: pos.Side, Side: pos.Side,
EntryPrice: pos.EntryPrice, EntryPrice: pos.EntryPrice,
@@ -1416,7 +1434,7 @@ func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot {
return positions return positions
} }
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision { func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
if len(decisions) <= 1 { if len(decisions) <= 1 {
return decisions 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) copy(result, decisions)
sort.Slice(result, func(i, j int) bool { sort.Slice(result, func(i, j int) bool {

View File

@@ -9,7 +9,7 @@ import (
"sync" "sync"
"time" "time"
"nofx/decision" "nofx/kernel"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
@@ -18,7 +18,7 @@ import (
// TraderExecutor interface for executing trades // TraderExecutor interface for executing trades
type TraderExecutor interface { type TraderExecutor interface {
ExecuteDecision(decision *decision.Decision) error ExecuteDecision(decision *kernel.Decision) error
GetBalance() (map[string]interface{}, error) GetBalance() (map[string]interface{}, error)
} }
@@ -166,7 +166,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
}() }()
// Create strategy engine for building context // Create strategy engine for building context
strategyEngine := decision.NewStrategyEngine(strategyConfig) strategyEngine := kernel.NewStrategyEngine(strategyConfig)
// Build market context using strategy config // Build market context using strategy config
ctx, err := e.buildMarketContext(session, strategyEngine) 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 // 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() config := strategyEngine.GetConfig()
// Get candidate coins // Get candidate coins
@@ -335,12 +335,18 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
// Fetch OI ranking data (market-wide position changes) // Fetch OI ranking data (market-wide position changes)
oiRankingData := strategyEngine.FetchOIRankingData() 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 // Build context
ctx := &decision.Context{ ctx := &kernel.Context{
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: 0, RuntimeMinutes: 0,
CallCount: 1, CallCount: 1,
Account: decision.AccountInfo{ Account: kernel.AccountInfo{
TotalEquity: 1000.0, // Simulated for debate TotalEquity: 1000.0, // Simulated for debate
AvailableBalance: 1000.0, AvailableBalance: 1000.0,
UnrealizedPnL: 0, UnrealizedPnL: 0,
@@ -350,12 +356,14 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
MarginUsedPct: 0, MarginUsedPct: 0,
PositionCount: 0, PositionCount: 0,
}, },
Positions: []decision.PositionInfo{}, Positions: []kernel.PositionInfo{},
CandidateCoins: candidates, CandidateCoins: candidates,
PromptVariant: session.PromptVariant, PromptVariant: session.PromptVariant,
MarketDataMap: marketDataMap, MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap, QuantDataMap: quantDataMap,
OIRankingData: oiRankingData, OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,
} }
return ctx, nil return ctx, nil
@@ -539,7 +547,7 @@ func (e *DebateEngine) getParticipantResponse(
} }
// collectVotes collects final votes from all participants // 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 var votes []*store.DebateVote
// Build voting context // Build voting context
@@ -1009,7 +1017,7 @@ func (e *DebateEngine) ExecuteConsensus(sessionID string, executor TraderExecuto
} }
// Create decision // Create decision
tradeDecision := &decision.Decision{ tradeDecision := &kernel.Decision{
Symbol: session.Symbol, Symbol: session.Symbol,
Action: action, Action: action,
Leverage: session.FinalDecision.Leverage, Leverage: session.FinalDecision.Leverage,

48
docker-compose.stable.yml Normal file
View 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
View 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 错误。

View File

@@ -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. **资金流向解读**:机构与散户的资金流向通常呈相反趋势,可作为市场情绪判断依据

View File

@@ -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

View File

@@ -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 个币种 - **用途:** 获取 AI 评分最高的 N 个币种
- **标签:** `["ai500"]` - **标签:** `["ai500"]`

106
install-stable.sh Executable file
View 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

View File

@@ -1,4 +1,4 @@
package decision package kernel
import ( import (
"encoding/json" "encoding/json"
@@ -8,7 +8,7 @@ import (
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/provider" "nofx/provider/nofxos"
"nofx/security" "nofx/security"
"nofx/store" "nofx/store"
"regexp" "regexp"
@@ -119,8 +119,10 @@ type Context struct {
MultiTFMarket map[string]map[string]*market.Data `json:"-"` MultiTFMarket map[string]map[string]*market.Data `json:"-"`
OITopDataMap map[string]*OITopData `json:"-"` OITopDataMap map[string]*OITopData `json:"-"`
QuantDataMap map[string]*QuantData `json:"-"` QuantDataMap map[string]*QuantData `json:"-"`
OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
BTCETHLeverage int `json:"-"` 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:"-"` AltcoinLeverage int `json:"-"`
Timeframes []string `json:"-"` Timeframes []string `json:"-"`
} }
@@ -189,12 +191,23 @@ type OIDeltaData struct {
// StrategyEngine strategy execution engine // StrategyEngine strategy execution engine
type StrategyEngine struct { type StrategyEngine struct {
config *store.StrategyConfig config *store.StrategyConfig
nofxosClient *nofxos.Client
} }
// NewStrategyEngine creates strategy execution engine // NewStrategyEngine creates strategy execution engine
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { 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 // GetRiskControlConfig gets risk control configuration
@@ -202,6 +215,19 @@ func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
return e.config.RiskControl 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 // GetConfig gets complete strategy configuration
func (e *StrategyEngine) GetConfig() *store.StrategyConfig { func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
return e.config return e.config
@@ -239,7 +265,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
// Ensure OITopDataMap is initialized // Ensure OITopDataMap is initialized
if ctx.OITopDataMap == nil { if ctx.OITopDataMap == nil {
ctx.OITopDataMap = make(map[string]*OITopData) ctx.OITopDataMap = make(map[string]*OITopData)
oiPositions, err := provider.GetOITopPositions() oiPositions, err := engine.nofxosClient.GetOITopPositions()
if err == nil { if err == nil {
for _, pos := range oiPositions { for _, pos := range oiPositions {
ctx.OITopDataMap[pos.Symbol] = &OITopData{ ctx.OITopDataMap[pos.Symbol] = &OITopData{
@@ -385,13 +411,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
coinSource := e.config.CoinSource coinSource := e.config.CoinSource
if coinSource.CoinPoolAPIURL != "" {
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
}
if coinSource.OITopAPIURL != "" {
provider.SetOITopAPI(coinSource.OITopAPIURL)
}
switch coinSource.SourceType { switch coinSource.SourceType {
case "static": case "static":
for _, symbol := range coinSource.StaticCoins { for _, symbol := range coinSource.StaticCoins {
@@ -401,12 +420,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
Sources: []string{"static"}, Sources: []string{"static"},
}) })
} }
return candidates, nil
case "coinpool": return e.filterExcludedCoins(candidates), nil
// 检查 use_coin_pool 标志,如果为 false 则回退到静态币种
if !coinSource.UseCoinPool { case "ai500":
logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins") // 检查 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 { for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol) symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{ candidates = append(candidates, CandidateCoin{
@@ -414,9 +434,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
Sources: []string{"static"}, 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": case "oi_top":
// 检查 use_oi_top 标志,如果为 false 则回退到静态币种 // 检查 use_oi_top 标志,如果为 false 则回退到静态币种
@@ -429,15 +453,19 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
Sources: []string{"static"}, 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": case "mixed":
if coinSource.UseCoinPool { if coinSource.UseAI500 {
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
if err != nil { if err != nil {
logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err) logger.Infof("⚠️ Failed to get AI500 coins: %v", err)
} else { } else {
for _, coin := range poolCoins { for _, coin := range poolCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500") symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
@@ -471,19 +499,45 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
Sources: sources, Sources: sources,
}) })
} }
return candidates, nil return e.filterExcludedCoins(candidates), nil
default: default:
return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType) 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 { if limit <= 0 {
limit = 30 limit = 30
} }
symbols, err := provider.GetTopRatedCoins(limit) symbols, err := e.nofxosClient.GetTopRatedCoins(limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -503,7 +557,7 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
limit = 20 limit = 20
} }
positions, err := provider.GetOITopPositions() positions, err := e.nofxosClient.GetOITopPositions()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -610,50 +664,82 @@ func extractJSONPath(data interface{}, path string) interface{} {
// FetchQuantData fetches quantitative data for a single coin // FetchQuantData fetches quantitative data for a single coin
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) { 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 return nil, nil
} }
apiURL := e.config.Indicators.QuantDataAPIURL // Use nofxos client with unified API key
url := strings.Replace(apiURL, "{symbol}", symbol, -1) include := "oi,price"
if e.config.Indicators.EnableQuantNetflow {
include = "netflow,oi,price"
}
// SSRF Protection: Validate URL before making request nofxosData, err := e.nofxosClient.GetCoinData(symbol, include)
resp, err := security.SafeGet(url, 10*time.Second)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("failed to fetch quant data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) if nofxosData == nil {
if err != nil { return nil, nil
return nil, fmt.Errorf("failed to read response: %w", err)
} }
var apiResp struct { // Convert nofxos.QuantData to kernel.QuantData
Code int `json:"code"` quantData := &QuantData{
Data *QuantData `json:"data"` Symbol: nofxosData.Symbol,
Price: nofxosData.Price,
PriceChange: nofxosData.PriceChange,
} }
if err := json.Unmarshal(body, &apiResp); err != nil { // Convert OI data
return nil, fmt.Errorf("failed to parse JSON: %w", err) 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 { // Convert Netflow data
return nil, fmt.Errorf("API returned error code: %d", apiResp.Code) 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 // FetchQuantDataBatch batch fetches quantitative data
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData { func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
result := make(map[string]*QuantData) result := make(map[string]*QuantData)
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { if !e.config.Indicators.EnableQuantData {
return result return result
} }
@@ -672,28 +758,12 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
} }
// FetchOIRankingData fetches market-wide OI ranking data // FetchOIRankingData fetches market-wide OI ranking data
func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData { func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
indicators := e.config.Indicators indicators := e.config.Indicators
if !indicators.EnableOIRanking { if !indicators.EnableOIRanking {
return nil 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 duration := indicators.OIRankingDuration
if duration == "" { if duration == "" {
duration = "1h" duration = "1h"
@@ -706,7 +776,7 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit) 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 { if err != nil {
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err) logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
return nil return nil
@@ -718,6 +788,68 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
return data 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 // Prompt Building - System Prompt
// ============================================================================ // ============================================================================
@@ -729,7 +861,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
promptSections := e.config.PromptSections promptSections := e.config.PromptSections
// 0. Data Dictionary & Schema (ensure AI understands all fields) // 0. Data Dictionary & Schema (ensure AI understands all fields)
lang := detectLanguage(promptSections.RoleDefinition) lang := e.GetLanguage()
schemaPrompt := GetSchemaPrompt(lang) schemaPrompt := GetSchemaPrompt(lang)
sb.WriteString(schemaPrompt) sb.WriteString(schemaPrompt)
sb.WriteString("\n\n") sb.WriteString("\n\n")
@@ -920,7 +1052,7 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
sb.WriteString("- Funding rate\n") 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") 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) // Historical trading statistics (helps AI understand past performance)
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
// Detect language from strategy config // Get language from strategy config
lang := detectLanguage(e.config.PromptSections.RoleDefinition) lang := e.GetLanguage()
// Win/Loss ratio // Win/Loss ratio
var winLossRatio float64 var winLossRatio float64
@@ -1081,9 +1213,25 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
} }
sb.WriteString("\n") 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) // OI Ranking data (market-wide open interest changes)
if ctx.OIRankingData != nil { 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") sb.WriteString("---\n\n")

View File

@@ -1,8 +1,9 @@
package decision package kernel
import ( import (
"fmt" "fmt"
"nofx/market" "nofx/market"
"nofx/provider/nofxos"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -89,11 +90,11 @@ func formatContextData(ctx *Context, lang Language) string {
// 7. OI排名数据如果有 // 7. OI排名数据如果有
if ctx.OIRankingData != nil { if ctx.OIRankingData != nil {
nofxosLang := nofxos.LangEnglish
if lang == LangChinese { if lang == LangChinese {
sb.WriteString(formatOIRankingZH(ctx.OIRankingData)) nofxosLang = nofxos.LangChinese
} else {
sb.WriteString(formatOIRankingEN(ctx.OIRankingData))
} }
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
} }
return sb.String() return sb.String()
@@ -354,11 +355,6 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
return sb.String() return sb.String()
} }
// formatOIRankingZH 格式化OI排名数据中文
func formatOIRankingZH(oiData interface{}) string {
// TODO: 根据实际OIRankingData结构实现
return "## 市场持仓量排名\n\n(数据加载中...)\n\n"
}
// getOIInterpretationZH 获取OI变化解读中文 // getOIInterpretationZH 获取OI变化解读中文
func getOIInterpretationZH(oiChange, priceChange string) string { func getOIInterpretationZH(oiChange, priceChange string) string {
@@ -624,10 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
return sb.String() return sb.String()
} }
// formatOIRankingEN 格式化OI排名数据英文
func formatOIRankingEN(oiData interface{}) string {
return "## Market-wide OI Ranking\n\n(Loading data...)\n\n"
}
// getOIInterpretationEN 获取OI变化解读英文 // getOIInterpretationEN 获取OI变化解读英文
func getOIInterpretationEN(oiChange, priceChange string) string { func getOIInterpretationEN(oiChange, priceChange string) string {

View File

@@ -1,4 +1,4 @@
package decision package kernel
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package decision package kernel
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package decision package kernel
import "fmt" import "fmt"

View File

@@ -1,4 +1,4 @@
package decision package kernel
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package decision package kernel
import ( import (
"testing" "testing"

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"nofx/debate" "nofx/debate"
"nofx/decision" "nofx/kernel"
"nofx/logger" "nofx/logger"
"nofx/store" "nofx/store"
"nofx/trader" "nofx/trader"
@@ -19,7 +19,7 @@ type TraderExecutorAdapter struct {
} }
// ExecuteDecision executes a trading decision // 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) 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) // RemoveTrader removes a trader from memory (does not affect database)
// Used to force reload when updating trader configuration // Used to force reload when updating trader configuration
// If the trader is running, it will be stopped first
func (tm *TraderManager) RemoveTrader(traderID string) { func (tm *TraderManager) RemoveTrader(traderID string) {
tm.mu.Lock() tm.mu.Lock()
defer tm.mu.Unlock() 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) delete(tm.traders, traderID)
logger.Infof("✓ Trader %s removed from memory", traderID) logger.Infof("✓ Trader %s removed from memory", traderID)
} }
@@ -606,7 +613,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
continue 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) err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil { if err != nil {
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err) 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) 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{ traderConfig := trader.AutoTraderConfig{
ID: traderCfg.ID, ID: traderCfg.ID,
Name: traderCfg.Name, Name: traderCfg.Name,
@@ -664,6 +671,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
StrategyConfig: strategyConfig, 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) // Set API keys based on exchange type (convert EncryptedString to string)
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":

View File

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

View File

@@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" }
// StrategyConfig strategy configuration details (JSON structure) // StrategyConfig strategy configuration details (JSON structure)
type StrategyConfig struct { 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 // coin source configuration
CoinSource CoinSourceConfig `json:"coin_source"` CoinSource CoinSourceConfig `json:"coin_source"`
// quantitative data configuration // quantitative data configuration
@@ -58,22 +61,21 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration // CoinSourceConfig coin source configuration
type CoinSourceConfig struct { type CoinSourceConfig struct {
// source type: "static" | "coinpool" | "oi_top" | "mixed" // source type: "static" | "ai500" | "oi_top" | "mixed"
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
// static coin list (used when source_type = "static") // static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"` 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 // whether to use AI500 coin pool
UseCoinPool bool `json:"use_coin_pool"` UseAI500 bool `json:"use_ai500"`
// AI500 coin pool maximum count // AI500 coin pool maximum count
CoinPoolLimit int `json:"coin_pool_limit,omitempty"` AI500Limit int `json:"ai500_limit,omitempty"`
// AI500 coin pool API URL (strategy-level configuration)
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
// whether to use OI Top // whether to use OI Top
UseOITop bool `json:"use_oi_top"` UseOITop bool `json:"use_oi_top"`
// OI Top maximum count // OI Top maximum count
OITopLimit int `json:"oi_top_limit,omitempty"` OITopLimit int `json:"oi_top_limit,omitempty"`
// OI Top API URL (strategy-level configuration) // Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
} }
// IndicatorConfig indicator configuration // IndicatorConfig indicator configuration
@@ -101,16 +103,30 @@ type IndicatorConfig struct {
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
// external data sources // external data sources
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"` 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) // quantitative data sources (capital flow, position changes, price changes)
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data 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
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
// OI ranking data (market-wide open interest increase/decrease rankings) // OI ranking data (market-wide open interest increase/decrease rankings)
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data 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 OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10) 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 // KlineConfig K-line configuration
@@ -172,14 +188,7 @@ func NewStrategyStore(db *gorm.DB) *StrategyStore {
} }
func (s *StrategyStore) initTables() error { func (s *StrategyStore) initTables() error {
// For PostgreSQL with existing table, skip AutoMigrate // AutoMigrate will add missing columns without dropping existing data
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
}
}
return s.db.AutoMigrate(&Strategy{}) return s.db.AutoMigrate(&Strategy{})
} }
@@ -190,15 +199,20 @@ func (s *StrategyStore) initDefaultData() error {
// GetDefaultStrategyConfig returns the default strategy configuration for the given language // GetDefaultStrategyConfig returns the default strategy configuration for the given language
func GetDefaultStrategyConfig(lang string) StrategyConfig { func GetDefaultStrategyConfig(lang string) StrategyConfig {
// Normalize language to "zh" or "en"
normalizedLang := "en"
if lang == "zh" {
normalizedLang = "zh"
}
config := StrategyConfig{ config := StrategyConfig{
Language: normalizedLang,
CoinSource: CoinSourceConfig{ CoinSource: CoinSourceConfig{
SourceType: "coinpool", SourceType: "ai500",
UseCoinPool: true, UseAI500: true,
CoinPoolLimit: 10, AI500Limit: 10,
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c", UseOITop: false,
UseOITop: false, OITopLimit: 20,
OITopLimit: 20,
OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c",
}, },
Indicators: IndicatorConfig{ Indicators: IndicatorConfig{
Klines: KlineConfig{ Klines: KlineConfig{
@@ -222,15 +236,24 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
RSIPeriods: []int{7, 14}, RSIPeriods: []int{7, 14},
ATRPeriods: []int{14}, ATRPeriods: []int{14},
BOLLPeriods: []int{20}, BOLLPeriods: []int{20},
// NofxOS unified API key
NofxOSAPIKey: "cm_568c67eae410d912c54c",
// Quant data
EnableQuantData: true, EnableQuantData: true,
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
EnableQuantOI: true, EnableQuantOI: true,
EnableQuantNetflow: true, EnableQuantNetflow: true,
// OI ranking data - market-wide OI increase/decrease rankings // OI ranking data
EnableOIRanking: true, EnableOIRanking: true,
OIRankingAPIURL: "http://nofxaios.com:30006",
OIRankingDuration: "1h", OIRankingDuration: "1h",
OIRankingLimit: 10, OIRankingLimit: 10,
// NetFlow ranking data
EnableNetFlowRanking: true,
NetFlowRankingDuration: "1h",
NetFlowRankingLimit: 10,
// Price ranking data
EnablePriceRanking: true,
PriceRankingDuration: "1h,4h,24h",
PriceRankingLimit: 10,
}, },
RiskControl: RiskControlConfig{ RiskControl: RiskControlConfig{
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)

View File

@@ -37,7 +37,7 @@ type Trader struct {
BTCETHLeverage int `gorm:"column:btc_eth_leverage;default:5" json:"btc_eth_leverage,omitempty"` 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"` AltcoinLeverage int `gorm:"column:altcoin_leverage;default:5" json:"altcoin_leverage,omitempty"`
TradingSymbols string `gorm:"column:trading_symbols;default:''" json:"trading_symbols,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"` 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"` 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"` 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 { if trader.ScanIntervalMinutes > 0 {
updates["scan_interval_minutes"] = trader.ScanIntervalMinutes 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{}). return s.db.Model(&Trader{}).

View File

@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"nofx/decision" "nofx/kernel"
"nofx/experience" "nofx/experience"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
@@ -104,7 +104,7 @@ type AutoTrader struct {
trader Trader // Use Trader interface (supports multiple platforms) trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient mcpClient mcp.AIClient
store *store.Store // Data storage (decision records, etc.) 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 cycleNumber int // Current cycle number
initialBalance float64 initialBalance float64
dailyPnL float64 dailyPnL float64
@@ -310,7 +310,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
if config.StrategyConfig == nil { if config.StrategyConfig == nil {
return nil, fmt.Errorf("[%s] strategy not configured", config.Name) return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
} }
strategyEngine := decision.NewStrategyEngine(config.StrategyConfig) strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig)
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name) logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
return &AutoTrader{ return &AutoTrader{
@@ -524,7 +524,7 @@ func (at *AutoTrader) runCycle() error {
// 5. Use strategy engine to call AI for decision // 5. Use strategy engine to call AI for decision
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]") 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 { if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
@@ -585,8 +585,8 @@ func (at *AutoTrader) runCycle() error {
// logger.Infof(strings.Repeat("-", 70) + "\n") // logger.Infof(strings.Repeat("-", 70) + "\n")
// 7. Print AI decisions // 7. Print AI decisions
// logger.Infof("📋 AI decision list (%d items):\n", len(decision.Decisions)) // logger.Infof("📋 AI decision list (%d items):\n", len(kernel.Decisions))
// for i, d := range decision.Decisions { // for i, d := range kernel.Decisions {
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning) // logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
// if d.Action == "open_long" || d.Action == "open_short" { // if d.Action == "open_long" || d.Action == "open_short" {
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f", // 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 // buildTradingContext builds trading context
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// 1. Get account information // 1. Get account information
balance, err := at.trader.GetBalance() balance, err := at.trader.GetBalance()
if err != nil { if err != nil {
@@ -701,7 +701,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
return nil, fmt.Errorf("failed to get positions: %w", err) return nil, fmt.Errorf("failed to get positions: %w", err)
} }
var positionInfos []decision.PositionInfo var positionInfos []kernel.PositionInfo
totalMarginUsed := 0.0 totalMarginUsed := 0.0
// Current position key set (for cleaning up closed position records) // 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] peakPnlPct := at.peakPnLCache[posKey]
at.peakPnLCacheMutex.RUnlock() at.peakPnLCacheMutex.RUnlock()
positionInfos = append(positionInfos, decision.PositionInfo{ positionInfos = append(positionInfos, kernel.PositionInfo{
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
EntryPrice: entryPrice, 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) logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
// 6. Build context // 6. Build context
ctx := &decision.Context{ ctx := &kernel.Context{
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: int(time.Since(at.startTime).Minutes()), RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
CallCount: at.callCount, CallCount: at.callCount,
BTCETHLeverage: btcEthLeverage, BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage, AltcoinLeverage: altcoinLeverage,
Account: decision.AccountInfo{ Account: kernel.AccountInfo{
TotalEquity: totalEquity, TotalEquity: totalEquity,
AvailableBalance: availableBalance, AvailableBalance: availableBalance,
UnrealizedPnL: totalUnrealizedProfit, 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") 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, Symbol: trade.Symbol,
Side: trade.Side, Side: trade.Side,
EntryPrice: trade.EntryPrice, EntryPrice: trade.EntryPrice,
@@ -881,7 +881,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
} else if stats.TotalTrades == 0 { } else if stats.TotalTrades == 0 {
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id) logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
} else { } else {
ctx.TradingStats = &decision.TradingStats{ ctx.TradingStats = &kernel.TradingStats{
TotalTrades: stats.TotalTrades, TotalTrades: stats.TotalTrades,
WinRate: stats.WinRate, WinRate: stats.WinRate,
ProfitFactor: stats.ProfitFactor, ProfitFactor: stats.ProfitFactor,
@@ -899,7 +899,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
} }
// 8. Get quantitative data (if enabled in strategy config) // 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) // Collect symbols to query (candidate coins + position coins)
symbolsToQuery := make(map[string]bool) symbolsToQuery := make(map[string]bool)
for _, coin := range candidateCoins { 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 return ctx, nil
} }
// executeDecisionWithRecord executes AI decision and records detailed information // 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 { switch decision.Action {
case "open_long": case "open_long":
return at.executeOpenLongWithRecord(decision, actionRecord) 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) // ExecuteDecision executes a trading decision from external sources (e.g., debate consensus)
// This is a public method that can be called by other modules // 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) logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol)
// Create a minimal action record for tracking // 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 // 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) logger.Infof(" 📈 Open long: %s", decision.Symbol)
// ⚠️ Get current positions for multiple checks // ⚠️ 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 // 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) logger.Infof(" 📉 Open short: %s", decision.Symbol)
// ⚠️ Get current positions for multiple checks // ⚠️ 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 // 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) logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price // Get current price
@@ -1277,7 +1297,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
} }
// executeCloseShortWithRecord executes close short position and records detailed information // 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) logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price // 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) // 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 { if at.store == nil || ctx == nil {
return return
} }
@@ -1624,7 +1644,7 @@ func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait // sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
// This avoids position stacking overflow when changing positions // 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 { if len(decisions) <= 1 {
return decisions return decisions
} }
@@ -1644,7 +1664,7 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
} }
// Copy decision list // Copy decision list
sorted := make([]decision.Decision, len(decisions)) sorted := make([]kernel.Decision, len(decisions))
copy(sorted, decisions) copy(sorted, decisions)
// Sort by priority // Sort by priority

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

View File

@@ -145,43 +145,41 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
console.log('[ChartTabs] rendering, activeTab:', activeTab) console.log('[ChartTabs] rendering, activeTab:', activeTab)
return ( 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 */} {/* Clean Professional Toolbar */}
<div <div
className="flex items-center justify-between px-3 py-1.5" 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(43, 49, 57, 0.6)', background: '#161B22' }} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
> >
{/* Left: Tab Switcher */} {/* Left: Tab Switcher */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => setActiveTab('equity')} onClick={() => setActiveTab('equity')}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
activeTab === 'equity' ? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
? 'bg-blue-500/15 text-blue-400' : 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
: 'text-gray-500 hover:text-gray-300' }`}
}`}
> >
<BarChart3 className="w-3 h-3" /> <BarChart3 className="w-3.5 h-3.5" />
<span>{t('accountEquityCurve', language)}</span> <span>{t('accountEquityCurve', language)}</span>
</button> </button>
<button <button
onClick={() => setActiveTab('kline')} onClick={() => setActiveTab('kline')}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'kline'
activeTab === 'kline' ? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
? 'bg-blue-500/15 text-blue-400' : 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
: 'text-gray-500 hover:text-gray-300' }`}
}`}
> >
<CandlestickChart className="w-3 h-3" /> <CandlestickChart className="w-3.5 h-3.5" />
<span>{t('marketChart', language)}</span> <span>{t('marketChart', language)}</span>
</button> </button>
{/* Market Type Pills - Only when kline active */} {/* Market Type Pills - Only when kline active */}
{activeTab === 'kline' && ( {activeTab === 'kline' && (
<> <>
<div className="w-px h-3 bg-[#30363D] mx-1" /> <div className="w-px h-4 bg-white/10 mx-2" />
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-1">
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => { {(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
const config = MARKET_CONFIG[type] const config = MARKET_CONFIG[type]
const isActive = marketType === type const isActive = marketType === type
@@ -189,13 +187,13 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
<button <button
key={type} key={type}
onClick={() => handleMarketTypeChange(type)} onClick={() => handleMarketTypeChange(type)}
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-all ${ className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
isActive ? 'bg-white/10 text-white border-white/20'
? 'bg-[#21262D] text-white' : 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
: 'text-gray-500 hover:text-gray-400' }`}
}`}
> >
{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> </button>
) )
})} })}
@@ -206,87 +204,89 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
{/* Right: Symbol + Interval */} {/* Right: Symbol + Interval */}
{activeTab === 'kline' && ( {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 */} {/* Symbol Dropdown */}
{marketConfig.hasDropdown ? ( <div className="shrink-0 relative" ref={dropdownRef}>
<div className="relative" ref={dropdownRef}> {marketConfig.hasDropdown ? (
<button <>
onClick={() => setShowDropdown(!showDropdown)} <button
className="flex items-center gap-1 px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white hover:bg-[#30363D] transition-all" 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-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`} /> <span>{chartSymbol}</span>
</button> <ChevronDown className={`w-3 h-3 text-nofx-text-muted transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
{showDropdown && ( </button>
<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"> {showDropdown && (
<div className="p-2 border-b border-[#30363D]"> <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="flex items-center gap-2 px-2 py-1 bg-[#0D1117] rounded border border-[#30363D]"> <div className="p-2 border-b border-white/5">
<Search className="w-3 h-3 text-gray-500" /> <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">
<input <Search className="w-3.5 h-3.5 text-nofx-text-muted" />
type="text" <input
value={searchFilter} type="text"
onChange={(e) => setSearchFilter(e.target.value)} value={searchFilter}
placeholder="Search..." onChange={(e) => setSearchFilter(e.target.value)}
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none" placeholder="Search symbol..."
autoFocus 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> </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 <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>
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' } )}
return ( </div>
<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>
)}
{/* Interval Selector */} {/* Interval Selector - Allow scrolling if needed */}
<div className="flex items-center bg-[#21262D] rounded overflow-hidden"> <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) => ( {INTERVALS.map((int) => (
<button <button
key={int.value} key={int.value}
onClick={() => setInterval(int.value)} onClick={() => setInterval(int.value)}
className={`px-2 py-1 text-[10px] font-medium transition-all ${ className={`px-2 py-1 text-[10px] font-medium transition-all ${interval === int.value
interval === int.value ? 'bg-nofx-gold/20 text-nofx-gold'
? 'bg-blue-500/30 text-blue-400' : 'text-nofx-text-muted hover:text-white hover:bg-white/5'
: 'text-gray-500 hover:text-gray-300 hover:bg-[#30363D]' }`}
}`}
> >
{int.label} {int.label}
</button> </button>
))} ))}
</div> </div>
{/* Quick Input */} {/* Quick Input - Hidden on mobile, dropdown search is enough */}
<form onSubmit={handleSymbolSubmit} className="flex items-center"> <form onSubmit={handleSymbolSubmit} className="hidden md:flex items-center shrink-0">
<input <input
type="text" type="text"
value={symbolInput} value={symbolInput}
onChange={(e) => setSymbolInput(e.target.value)} onChange={(e) => setSymbolInput(e.target.value)}
placeholder="Symbol..." placeholder="Sym"
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" 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 Go
</button> </button>
</form> </form>
@@ -295,32 +295,33 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
</div> </div>
{/* Tab Content */} {/* 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"> <AnimatePresence mode="wait">
{activeTab === 'equity' ? ( {activeTab === 'equity' ? (
<motion.div <motion.div
key="equity" key="equity"
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, x: 20 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="h-full" className="h-full w-full absolute inset-0"
> >
<EquityChart traderId={traderId} embedded /> <EquityChart traderId={traderId} embedded />
</motion.div> </motion.div>
) : ( ) : (
<motion.div <motion.div
key={`kline-${chartSymbol}-${interval}-${currentExchange}`} key={`kline-${chartSymbol}-${interval}-${currentExchange}`}
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="h-full" className="h-full w-full absolute inset-0"
> >
<AdvancedChart <AdvancedChart
symbol={chartSymbol} symbol={chartSymbol}
interval={interval} interval={interval}
traderID={traderId} traderID={traderId}
// Dynamic height to fill container
height={550} height={550}
exchange={currentExchange} exchange={currentExchange}
onSymbolChange={setChartSymbol} onSymbolChange={setChartSymbol}

View File

@@ -9,6 +9,7 @@ import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations' import { t } from '../i18n/translations'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar' import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
import { DeepVoidBackground } from './DeepVoidBackground'
export function CompetitionPage() { export function CompetitionPage() {
const { language } = useLanguage() const { language } = useLanguage()
@@ -44,83 +45,78 @@ export function CompetitionPage() {
if (!competition) { if (!competition) {
return ( return (
<div className="space-y-6"> <DeepVoidBackground className="py-8" disableAnimation>
<div className="binance-card p-8 animate-pulse"> <div className="container mx-auto max-w-7xl px-4 md:px-8">
<div className="flex items-center justify-between mb-6"> <div className="space-y-6">
<div className="space-y-3 flex-1"> <div className="animate-pulse bg-black/40 border border-white/10 rounded-xl p-8 backdrop-blur-md">
<div className="skeleton h-8 w-64"></div> <div className="flex items-center justify-between mb-6">
<div className="skeleton h-4 w-48"></div> <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>
<div className="skeleton h-12 w-32"></div>
</div> </div>
</div> </div>
<div className="binance-card p-6"> </DeepVoidBackground>
<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>
) )
} }
// 如果有数据返回但没有交易员,显示空状态 // 如果有数据返回但没有交易员,显示空状态
if (!competition.traders || competition.traders.length === 0) { if (!competition.traders || competition.traders.length === 0) {
return ( return (
<div className="space-y-5 animate-fade-in"> <DeepVoidBackground className="py-8" disableAnimation>
{/* Competition Header - 精简版 */} <div className="container mx-auto max-w-7xl px-4 md:px-8 space-y-8 animate-fade-in">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0"> {/* Competition Header - 精简版 */}
<div className="flex items-center gap-3 md:gap-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
<div <div className="flex items-center gap-3 md:gap-4">
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" <div
style={{ 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)]"
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' }}
> >
{t('aiCompetition', language)} <Trophy
<span className="w-6 h-6 md:w-7 md:h-7 text-nofx-gold"
className="text-xs font-normal px-2 py-1 rounded" />
style={{ </div>
background: 'rgba(240, 185, 11, 0.15)', <div>
color: '#F0B90B', <h1
}} className="text-xl md:text-2xl font-bold flex items-center gap-2 text-white"
> >
0 {t('traders', language)} {t('aiCompetition', language)}
</span> <span
</h1> className="text-xs font-normal px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20"
<p className="text-xs" style={{ color: '#848E9C' }}> >
{t('liveBattle', language)} 0 {t('traders', language)}
</p> </span>
</h1>
<p className="text-xs text-zinc-400">
{t('liveBattle', language)}
</p>
</div>
</div> </div>
</div> </div>
</div>
{/* Empty State */} {/* Empty State */}
<div className="binance-card p-8 text-center"> <div className="bg-black/40 border border-white/10 rounded-xl p-16 text-center backdrop-blur-md">
<Trophy <Trophy
className="w-16 h-16 mx-auto mb-4 opacity-40" className="w-16 h-16 mx-auto mb-4 text-zinc-700"
style={{ color: '#848E9C' }} />
/> <h3 className="text-lg font-bold mb-2 text-white">
<h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}> {t('noTraders', language)}
{t('noTraders', language)} </h3>
</h3> <p className="text-sm text-zinc-400">
<p className="text-sm" style={{ color: '#848E9C' }}> {t('createFirstTrader', language)}
{t('createFirstTrader', language)} </p>
</p> </div>
</div> </div>
</div> </DeepVoidBackground>
) )
} }
@@ -133,375 +129,358 @@ export function CompetitionPage() {
const leader = sortedTraders[0] const leader = sortedTraders[0]
return ( return (
<div className="space-y-5 animate-fade-in"> <DeepVoidBackground className="py-8" disableAnimation>
{/* Competition Header - 精简版 */} <div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0"> {/* Competition Header - 精简版 */}
<div className="flex items-center gap-3 md:gap-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
<div <div className="flex items-center gap-3 md:gap-4">
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" <div
style={{ 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)]"
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' }}
> >
{t('aiCompetition', language)} <Trophy
<span className="w-6 h-6 md:w-7 md:h-7 text-nofx-gold"
className="text-xs font-normal px-2 py-1 rounded" />
style={{ </div>
background: 'rgba(240, 185, 11, 0.15)', <div>
color: '#F0B90B', <h1
}} className="text-xl md:text-2xl font-bold flex items-center gap-2 text-white"
> >
{competition.count} {t('traders', language)} {t('aiCompetition', language)}
</span> <span
</h1> className="text-xs font-normal px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20"
<p className="text-xs" style={{ color: '#848E9C' }}> >
{t('liveBattle', language)} {competition.count} {t('traders', language)}
</p> </span>
</div> </h1>
</div> <p className="text-xs text-zinc-400">
<div className="text-left md:text-right w-full md:w-auto"> {t('liveBattle', language)}
<div className="text-xs mb-1" style={{ color: '#848E9C' }}> </p>
{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)}
</div> </div>
</div> </div>
<ComparisonChart traders={sortedTraders.slice(0, 10)} /> <div className="text-left md:text-right w-full md:w-auto">
</div> <div className="text-xs mb-1 text-zinc-400">
{t('leader', language)}
{/* Right: Leaderboard */} </div>
<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 <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={{ style={{
background: 'rgba(240, 185, 11, 0.1)', color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
color: '#F0B90B',
border: '1px solid rgba(240, 185, 11, 0.2)',
}} }}
> >
{t('live', language)} {(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
</div> </div>
</div> </div>
<div className="space-y-2"> </div>
{sortedTraders.map((trader, index) => {
const isLeader = index === 0
const traderColor = getTraderColor(
sortedTraders,
trader.trader_id
)
return ( {/* Left/Right Split: Performance Chart + Leaderboard */}
<div <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
key={trader.trader_id} {/* Left: Performance Comparison Chart */}
onClick={() => handleTraderClick(trader.trader_id)} <div
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg" 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={{ style={{ animationDelay: '0.1s' }}
background: isLeader >
? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' <div className="flex items-center justify-between mb-6">
: '#0B0E11', <h2
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`, className="text-lg font-bold flex items-center gap-2 text-white"
boxShadow: isLeader >
? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)' {t('performanceComparison', language)}
: '0 1px 4px rgba(0, 0, 0, 0.3)', </h2>
}} <div className="text-xs text-zinc-400">
> {t('realTimePnL', language)}
<div className="flex items-center justify-between"> </div>
{/* Rank & Avatar & Name */} </div>
<div className="flex items-center gap-3"> <ComparisonChart traders={sortedTraders.slice(0, 10)} />
{/* Rank Badge */} </div>
<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>
{/* Stats */} {/* Right: Leaderboard */}
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap"> <div
{/* Total Equity */} className="bg-black/40 border border-white/10 rounded-xl p-6 backdrop-blur-md animate-slide-in hover:border-white/20 transition-colors"
<div className="text-right"> style={{ animationDelay: '0.1s' }}
<div className="text-xs" style={{ color: '#848E9C' }}> >
{t('equity', language)} <div className="flex items-center justify-between mb-6">
</div> <h2
<div className="text-lg font-bold flex items-center gap-2 text-white"
className="text-xs md:text-sm font-bold mono" >
style={{ color: '#EAECEF' }} {t('leaderboard', language)}
> </h2>
{trader.total_equity?.toFixed(2) || '0.00'} <div
</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)]"
</div> >
{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 */} return (
<div className="text-right min-w-[70px] md:min-w-[90px]"> <div
<div className="text-xs" style={{ color: '#848E9C' }}> key={trader.trader_id}
{t('pnl', language)} onClick={() => handleTraderClick(trader.trader_id)}
</div> 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 <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={{ style={{
color: background: index === 0
(trader.total_pnl ?? 0) >= 0 ? 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)'
? '#0ECB81' : index === 1
: '#F6465D', ? '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 ? '+' : ''} {index + 1}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div> </div>
<div {/* Punk Avatar */}
className="text-xs mono" <PunkAvatar
style={{ color: '#848E9C' }} seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
> size={36}
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''} className="rounded-lg"
{trader.total_pnl?.toFixed(2) || '0.00'} />
<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>
</div> </div>
{/* Positions */} {/* Stats */}
<div className="text-right"> <div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
<div className="text-xs" style={{ color: '#848E9C' }}> {/* Total Equity */}
{t('pos', language)} <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>
<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 */} {/* P&L */}
<div> <div className="text-right min-w-[70px] md:min-w-[90px]">
<div <div className="text-xs" style={{ color: '#848E9C' }}>
className="px-2 py-1 rounded text-xs font-bold" {t('pnl', language)}
style={ </div>
trader.is_running <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)', background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81', color: '#0ECB81',
} }
: { : {
background: 'rgba(246, 70, 93, 0.1)', background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D', color: '#F6465D',
} }
} }
> >
{trader.is_running ? '●' : '○'} {trader.is_running ? '●' : '○'}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> )
) })}
})} </div>
</div> </div>
</div> </div>
</div>
{/* Head-to-Head Stats */} {/* Head-to-Head Stats */}
{competition.traders.length === 2 && ( {competition.traders.length === 2 && (
<div <div
className="binance-card p-5 animate-slide-in" className="bg-black/40 border border-white/10 rounded-xl p-6 backdrop-blur-md animate-slide-in"
style={{ animationDelay: '0.3s' }} style={{ animationDelay: '0.3s' }}
>
<h2
className="text-lg font-bold mb-4 flex items-center gap-2"
style={{ color: '#EAECEF' }}
> >
{t('headToHead', language)} <h2
</h2> className="text-lg font-bold mb-6 flex items-center gap-2 text-white"
<div className="grid grid-cols-2 gap-4"> >
{sortedTraders.map((trader, index) => { {t('headToHead', language)}
const isWinning = index === 0 </h2>
const opponent = sortedTraders[1 - index] <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 // Check if both values are valid numbers
const hasValidData = const hasValidData =
trader.total_pnl_pct != null && trader.total_pnl_pct != null &&
opponent.total_pnl_pct != null && opponent.total_pnl_pct != null &&
!isNaN(trader.total_pnl_pct) && !isNaN(trader.total_pnl_pct) &&
!isNaN(opponent.total_pnl_pct) !isNaN(opponent.total_pnl_pct)
const gap = hasValidData const gap = hasValidData
? trader.total_pnl_pct - opponent.total_pnl_pct ? trader.total_pnl_pct - opponent.total_pnl_pct
: NaN : NaN
return ( return (
<div <div
key={trader.trader_id} key={trader.trader_id}
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]" className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
style={ style={
isWinning isWinning
? { ? {
background: background:
'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)', '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)', border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)', boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)',
} }
: { : {
background: '#0B0E11', background: '#0B0E11',
border: '1px solid #2B3139', border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)', boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
} }
} }
> >
<div className="text-center"> <div className="text-center">
{/* Avatar */} {/* Avatar */}
<div className="flex justify-center mb-3"> <div className="flex justify-center mb-3">
<PunkAvatar <PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)} seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={56} size={56}
className="rounded-xl" 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> </div>
)}
{hasValidData && !isWinning && gap < 0 && (
<div <div
className="text-xs font-semibold" className="text-sm md:text-base font-bold mb-2"
style={{ color: '#F6465D' }} style={{
color: getTraderColor(sortedTraders, trader.trader_id),
}}
> >
{t('behindBy', language, { {trader.trader_name}
gap: Math.abs(gap).toFixed(2),
})}
</div> </div>
)}
{!hasValidData && (
<div <div
className="text-xs font-semibold" className="text-lg md:text-2xl font-bold mono mb-1"
style={{ color: '#848E9C' }} 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> </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> </div>
</div> )}
)}
{/* Trader Config View Modal */} {/* Trader Config View Modal */}
<TraderConfigViewModal <TraderConfigViewModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={closeModal} onClose={closeModal}
traderData={selectedTrader} traderData={selectedTrader}
/> />
</div> </div>
</DeepVoidBackground>
) )
} }

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

View File

@@ -85,10 +85,7 @@ export default function HeaderBar({
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" 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" /> <img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
<span <span className="text-lg font-bold text-nofx-gold">
className="text-lg font-bold"
style={{ color: 'var(--brand-yellow)' }}
>
NOFX NOFX
</span> </span>
</div> </div>
@@ -101,11 +98,11 @@ export default function HeaderBar({
{(() => { {(() => {
// Define all navigation tabs // Define all navigation tabs
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [ 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: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true }, { page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true }, { page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', 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: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true }, { page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false }, { page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
@@ -128,28 +125,12 @@ export default function HeaderBar({
<button <button
key={tab.page} key={tab.page}
onClick={() => handleNavClick(tab)} onClick={() => handleNavClick(tab)}
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500" className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
style={{ ${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
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)'
}
}}
> >
{currentPage === tab.page && ( {currentPage === tab.page && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
style={{ background: 'rgba(240, 185, 11, 0.15)', zIndex: -1 }}
/> />
)} )}
{tab.label} {tab.label}
@@ -167,16 +148,7 @@ export default function HeaderBar({
href={OFFICIAL_LINKS.github} href={OFFICIAL_LINKS.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg transition-all hover:scale-110" className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5"
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'
}}
title="GitHub" title="GitHub"
> >
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
@@ -188,16 +160,7 @@ export default function HeaderBar({
href={OFFICIAL_LINKS.twitter} href={OFFICIAL_LINKS.twitter}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg transition-all hover:scale-110" className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#1DA1F2] hover:bg-[#1DA1F2]/10"
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'
}}
title="Twitter" title="Twitter"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -209,16 +172,7 @@ export default function HeaderBar({
href={OFFICIAL_LINKS.telegram} href={OFFICIAL_LINKS.telegram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg transition-all hover:scale-110" className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-[#0088cc] hover:bg-[#0088cc]/10"
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'
}}
title="Telegram" title="Telegram"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <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}> <div className="relative" ref={userDropdownRef}>
<button <button
onClick={() => setUserDropdownOpen(!userDropdownOpen)} onClick={() => setUserDropdownOpen(!userDropdownOpen)}
className="flex items-center gap-2 px-3 py-2 rounded transition-colors" 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"
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)')
}
> >
<div <div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
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)',
}}
>
{user.email[0].toUpperCase()} {user.email[0].toUpperCase()}
</div> </div>
<span <span className="text-sm text-nofx-text-muted">
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email} {user.email}
</span> </span>
<ChevronDown <ChevronDown className="w-4 h-4 text-nofx-text-muted" />
className="w-4 h-4"
style={{ color: 'var(--brand-light-gray)' }}
/>
</button> </button>
{userDropdownOpen && ( {userDropdownOpen && (
<div <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">
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50" <div className="px-3 py-2 border-b border-nofx-gold/20">
style={{ <div className="text-xs text-nofx-text-muted">
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)' }}
>
{t('loggedInAs', language)} {t('loggedInAs', language)}
</div> </div>
<div <div className="text-sm font-medium text-nofx-text-muted">
className="text-sm font-medium"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email} {user.email}
</div> </div>
</div> </div>
@@ -302,11 +218,7 @@ export default function HeaderBar({
onLogout() onLogout()
setUserDropdownOpen(false) setUserDropdownOpen(false)
}} }}
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center" 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"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
> >
{t('exitLogin', language)} {t('exitLogin', language)}
</button> </button>
@@ -322,19 +234,14 @@ export default function HeaderBar({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<a <a
href="/login" href="/login"
className="px-3 py-2 text-sm font-medium transition-colors rounded" className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
style={{ color: 'var(--brand-light-gray)' }}
> >
{t('signIn', language)} {t('signIn', language)}
</a> </a>
{registrationEnabled && ( {registrationEnabled && (
<a <a
href="/register" href="/register"
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90" className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90 bg-nofx-gold text-black"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
> >
{t('signUp', language)} {t('signUp', language)}
</a> </a>
@@ -347,15 +254,7 @@ export default function HeaderBar({
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)} onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
className="flex items-center gap-2 px-3 py-2 rounded transition-colors" className="flex items-center gap-2 px-3 py-2 rounded transition-colors text-nofx-text-muted hover:bg-white/5"
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')
}
> >
<span className="text-lg"> <span className="text-lg">
{language === 'zh' ? '🇨🇳' : '🇺🇸'} {language === 'zh' ? '🇨🇳' : '🇺🇸'}
@@ -364,28 +263,14 @@ export default function HeaderBar({
</button> </button>
{languageDropdownOpen && ( {languageDropdownOpen && (
<div <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">
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)',
}}
>
<button <button
onClick={() => { onClick={() => {
onLanguageChange?.('zh') onLanguageChange?.('zh')
setLanguageDropdownOpen(false) setLanguageDropdownOpen(false)
}} }}
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${ className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
language === 'zh' ? '' : 'hover:opacity-80' ${language === 'zh' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
}`}
style={{
color: 'var(--brand-light-gray)',
background:
language === 'zh'
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
}}
> >
<span className="text-base">🇨🇳</span> <span className="text-base">🇨🇳</span>
<span className="text-sm"></span> <span className="text-sm"></span>
@@ -395,16 +280,8 @@ export default function HeaderBar({
onLanguageChange?.('en') onLanguageChange?.('en')
setLanguageDropdownOpen(false) setLanguageDropdownOpen(false)
}} }}
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${ className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
language === 'en' ? '' : 'hover:opacity-80' ${language === 'en' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
}`}
style={{
color: 'var(--brand-light-gray)',
background:
language === 'en'
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
}}
> >
<span className="text-base">🇺🇸</span> <span className="text-base">🇺🇸</span>
<span className="text-sm">English</span> <span className="text-sm">English</span>
@@ -418,8 +295,7 @@ export default function HeaderBar({
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<motion.button <motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden" className="md:hidden text-nofx-text-muted hover:text-white"
style={{ color: 'var(--brand-light-gray)' }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
> >
{mobileMenuOpen ? ( {mobileMenuOpen ? (
@@ -439,21 +315,17 @@ export default function HeaderBar({
: { height: 0, opacity: 0 } : { height: 0, opacity: 0 }
} }
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="md:hidden overflow-hidden" className="md:hidden overflow-hidden bg-nofx-bg-lighter border-t border-nofx-gold/10"
style={{
background: 'var(--brand-dark-gray)',
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
}}
> >
<div className="px-4 py-4 space-y-2"> <div className="px-4 py-4 space-y-2">
{/* Mobile Navigation Tabs - Show all tabs */} {/* Mobile Navigation Tabs - Show all tabs */}
{(() => { {(() => {
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [ 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: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true }, { page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true }, { page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', 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: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true }, { page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false }, { page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
@@ -476,25 +348,17 @@ export default function HeaderBar({
<button <button
key={tab.page} key={tab.page}
onClick={() => handleMobileNavClick(tab)} onClick={() => handleMobileNavClick(tab)}
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500" 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
style={{ ${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-white hover:bg-white/5'}`}
color: currentPage === tab.page ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left',
}}
> >
{currentPage === tab.page && ( {currentPage === tab.page && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
style={{ background: 'rgba(240, 185, 11, 0.15)', zIndex: -1 }}
/> />
)} )}
{tab.label} {tab.label}
{tab.requiresAuth && !isLoggedIn && ( {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'} {language === 'zh' ? '需登录' : 'Login'}
</span> </span>
)} )}
@@ -511,21 +375,19 @@ export default function HeaderBar({
<a <a
key={item.key} key={item.key}
href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`} href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`}
className="block text-sm py-2" className="block text-sm py-2 text-nofx-text-muted hover:text-white"
style={{ color: 'var(--brand-light-gray)' }}
> >
{item.label} {item.label}
</a> </a>
))} ))}
{/* Social Links - Mobile */} {/* 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 <a
href={OFFICIAL_LINKS.github} href={OFFICIAL_LINKS.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg" className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-white"
style={{ color: '#848E9C', background: 'rgba(255, 255, 255, 0.05)' }}
> >
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor"> <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" /> <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} href={OFFICIAL_LINKS.twitter}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg" className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#1DA1F2]"
style={{ color: '#848E9C', background: 'rgba(255, 255, 255, 0.05)' }}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <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" /> <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} href={OFFICIAL_LINKS.telegram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg" className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#0088cc]"
style={{ color: '#848E9C', background: 'rgba(255, 255, 255, 0.05)' }}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <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" /> <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 */} {/* Language Toggle */}
<div className="py-2"> <div className="py-2">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span <span className="text-xs text-nofx-text-muted">
className="text-xs"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('language', language)}: {t('language', language)}:
</span> </span>
</div> </div>
@@ -571,11 +428,10 @@ export default function HeaderBar({
onLanguageChange?.('zh') onLanguageChange?.('zh')
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'zh'
language === 'zh' ? 'bg-yellow-500 text-black'
? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
: 'text-gray-400 hover:text-white' }`}
}`}
> >
<span className="text-lg">🇨🇳</span> <span className="text-lg">🇨🇳</span>
<span className="text-sm"></span> <span className="text-sm"></span>
@@ -585,11 +441,10 @@ export default function HeaderBar({
onLanguageChange?.('en') onLanguageChange?.('en')
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'en'
language === 'en' ? 'bg-yellow-500 text-black'
? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
: 'text-gray-400 hover:text-white' }`}
}`}
> >
<span className="text-lg">🇺🇸</span> <span className="text-lg">🇺🇸</span>
<span className="text-sm">English</span> <span className="text-sm">English</span>
@@ -600,33 +455,17 @@ export default function HeaderBar({
{/* User info and logout for mobile when logged in */} {/* User info and logout for mobile when logged in */}
{isLoggedIn && user && ( {isLoggedIn && user && (
<div <div
className="mt-4 pt-4" className="mt-4 pt-4 border-t border-nofx-gold/20"
style={{ borderTop: '1px solid var(--panel-border)' }}
> >
<div <div className="flex items-center gap-2 px-3 py-2 mb-2 rounded bg-nofx-bg-lighter">
className="flex items-center gap-2 px-3 py-2 mb-2 rounded" <div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
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)',
}}
>
{user.email[0].toUpperCase()} {user.email[0].toUpperCase()}
</div> </div>
<div> <div>
<div <div className="text-xs text-nofx-text-muted">
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('loggedInAs', language)} {t('loggedInAs', language)}
</div> </div>
<div <div className="text-sm text-nofx-text-muted">
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email} {user.email}
</div> </div>
</div> </div>
@@ -637,11 +476,7 @@ export default function HeaderBar({
onLogout() onLogout()
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center" className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center bg-nofx-danger/20 text-nofx-danger"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
> >
{t('exitLogin', language)} {t('exitLogin', language)}
</button> </button>
@@ -656,11 +491,7 @@ export default function HeaderBar({
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<a <a
href="/login" href="/login"
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors" 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"
style={{
color: 'var(--brand-light-gray)',
border: '1px solid var(--brand-light-gray)',
}}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
{t('signIn', language)} {t('signIn', language)}
@@ -668,11 +499,7 @@ export default function HeaderBar({
{registrationEnabled && ( {registrationEnabled && (
<a <a
href="/register" href="/register"
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors" 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"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
{t('signUp', language)} {t('signUp', language)}

View File

@@ -3,6 +3,7 @@ import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations' import { t } from '../i18n/translations'
import { Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import { DeepVoidBackground } from './DeepVoidBackground'
// import { Input } from './ui/input' // Removed unused import // import { Input } from './ui/input' // Removed unused import
import { toast } from 'sonner' import { toast } from 'sonner'
import { useSystemConfig } from '../hooks/useSystemConfig' import { useSystemConfig } from '../hooks/useSystemConfig'
@@ -102,13 +103,7 @@ export function LoginPage() {
} }
return ( return (
<div className="min-h-screen bg-black text-zinc-300 font-mono relative overflow-hidden flex items-center justify-center py-12"> <DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
{/* 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>
<div className="w-full max-w-md relative z-10 px-6"> <div className="w-full max-w-md relative z-10 px-6">
{/* Navigation - Top Bar (Mobile/Desktop Friendly) */} {/* Navigation - Top Bar (Mobile/Desktop Friendly) */}
@@ -361,6 +356,6 @@ export function LoginPage() {
</div> </div>
)} )}
</div> </div>
</div> </DeepVoidBackground>
) )
} }

View File

@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react' import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
import { DeepVoidBackground } from './DeepVoidBackground'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
interface LoginRequiredOverlayProps { interface LoginRequiredOverlayProps {
@@ -51,111 +52,114 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/90 backdrop-blur-sm" className="fixed inset-0 z-50"
onClick={onClose}
> >
{/* Scanline Effect */} <DeepVoidBackground
<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> className="w-full h-full bg-nofx-bg/95 backdrop-blur-md flex items-center justify-center p-4 text-nofx-text"
disableAnimation
<motion.div onClick={onClose}
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()}
> >
{/* Terminal Window Header */}
<div className="flex items-center justify-between px-3 py-2 bg-zinc-900 border-b border-zinc-800"> <motion.div
<div className="flex items-center gap-2"> initial={{ opacity: 0, scale: 0.95, y: 10 }}
<Terminal size={12} className="text-nofx-gold" /> animate={{ opacity: 1, scale: 1, y: 0 }}
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">auth_protocol.exe</span> 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> </div>
<button
onClick={onClose}
className="text-zinc-600 hover:text-red-500 transition-colors"
>
<X size={14} />
</button>
</div>
{/* Main Content */} {/* Main Content */}
<div className="p-8 relative"> <div className="p-8 relative">
{/* Background Grid */} {/* 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="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"> <div className="relative z-10">
{/* Flashing Access Denied */} {/* Flashing Access Denied */}
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<div className="relative"> <div className="relative">
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div> <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)]"> <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" /> <AlertTriangle size={18} className="animate-pulse" />
<span className="font-bold tracking-widest text-sm uppercase">{language === 'zh' ? '访问被拒绝' : 'ACCESS DENIED'}</span> <span className="font-bold tracking-widest text-sm uppercase">{language === 'zh' ? '访问被拒绝' : 'ACCESS DENIED'}</span>
</div>
</div> </div>
</div> </div>
</div>
{/* Terminal Text */} {/* Terminal Text */}
<div className="space-y-4 mb-8"> <div className="space-y-4 mb-8">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{t.title}</h2> <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> <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>
<div className="bg-zinc-900/50 border-l-2 border-zinc-700 p-3 my-4"> {/* Action Buttons */}
<p className="text-xs text-zinc-400 leading-relaxed font-mono"> <div className="space-y-3">
<span className="text-green-500 mr-2">$</span> <a
{t.description} href="/login"
</p> 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">-&gt;</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>
<div className="grid grid-cols-2 gap-2"> <div className="mt-4 text-center">
{t.benefits.map((benefit, i) => ( <button
<div key={i} className="flex items-center gap-2 text-[10px] text-zinc-500 uppercase tracking-wide"> onClick={onClose}
<span className="text-nofx-gold"></span> {benefit} className="text-[10px] text-nofx-text-muted hover:text-nofx-danger uppercase tracking-widest hover:underline decoration-red-500/30"
</div> >
))} [ {t.later} ]
</button>
</div> </div>
</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">-&gt;</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>
</div>
{/* Corner Accents */} {/* Corner Accents */}
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div> <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> <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> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@@ -6,6 +6,7 @@ import { getSystemConfig } from '../lib/config'
import { toast } from 'sonner' import { toast } from 'sonner'
import { copyWithToast } from '../lib/clipboard' import { copyWithToast } from '../lib/clipboard'
import { Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import { DeepVoidBackground } from './DeepVoidBackground'
// import { Input } from './ui/input' // Removed unused import // import { Input } from './ui/input' // Removed unused import
import PasswordChecklist from 'react-password-checklist' import PasswordChecklist from 'react-password-checklist'
import { RegistrationDisabled } from './RegistrationDisabled' import { RegistrationDisabled } from './RegistrationDisabled'
@@ -148,13 +149,7 @@ export function RegisterPage() {
} }
return ( return (
<div className="min-h-screen bg-black text-zinc-300 font-mono relative overflow-hidden flex items-center justify-center py-12"> <DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
{/* 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>
<div className="w-full max-w-lg relative z-10 px-6"> <div className="w-full max-w-lg relative z-10 px-6">
{/* Navigation - Top Bar (Mobile/Desktop Friendly) */} {/* Navigation - Top Bar (Mobile/Desktop Friendly) */}
@@ -469,6 +464,6 @@ export function RegisterPage() {
)} )}
</div> </div>
</div> </DeepVoidBackground>
) )
} }

View File

@@ -368,7 +368,7 @@ export function TraderConfigModal({
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]"> <div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div> <div>
: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' : : {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' : '混合'} selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
</div> </div>
<div> <div>

View File

@@ -16,7 +16,7 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
} }
return ( 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 */} {/* 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-[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 bg-gradient-to-t from-black via-transparent to-transparent pointer-events-none"></div>

View File

@@ -56,14 +56,11 @@ export function FAQContent({
return ( return (
<div className="space-y-12"> <div className="space-y-12">
{categories.map((category) => ( {categories.map((category) => (
<div key={category.id}> <div key={category.id} className="nofx-glass p-8 rounded-xl border border-white/5">
{/* Category Header */} {/* Category Header */}
<div <div className="flex items-center gap-3 mb-6 pb-3 border-b border-white/10">
className="flex items-center gap-3 mb-6 pb-3" <category.icon className="w-7 h-7 text-nofx-gold" />
style={{ borderBottom: '2px solid #2B3139' }} <h2 className="text-2xl font-bold text-nofx-text-main">
>
<category.icon className="w-7 h-7" style={{ color: '#F0B90B' }} />
<h2 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t(category.titleKey, language)} {t(category.titleKey, language)}
</h2> </h2>
</div> </div>
@@ -79,21 +76,12 @@ export function FAQContent({
className="scroll-mt-24" className="scroll-mt-24"
> >
{/* Question */} {/* Question */}
<h3 <h3 className="text-xl font-semibold mb-3 text-nofx-text-main">
className="text-xl font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t(item.questionKey, language)} {t(item.questionKey, language)}
</h3> </h3>
{/* Answer */} {/* Answer */}
<div <div className="prose prose-invert max-w-none text-nofx-text-muted leading-relaxed">
className="prose prose-invert max-w-none"
style={{
color: '#B7BDC6',
lineHeight: '1.7',
}}
>
{item.id === 'github-projects-tasks' ? ( {item.id === 'github-projects-tasks' ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-base"> <div className="text-base">
@@ -295,7 +283,7 @@ export function FAQContent({
href="https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md" href="https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
style={{ color: '#F0B90B' }} className="text-nofx-gold hover:underline"
> >
CONTRIBUTING.md CONTRIBUTING.md
</a> </a>
@@ -304,7 +292,7 @@ export function FAQContent({
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/PR_TITLE_GUIDE.md" href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/PR_TITLE_GUIDE.md"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
style={{ color: '#F0B90B' }} className="text-nofx-gold hover:underline"
> >
PR_TITLE_GUIDE.md PR_TITLE_GUIDE.md
</a> </a>
@@ -383,16 +371,10 @@ export function FAQContent({
)} )}
</ol> </ol>
<div <div className="rounded p-3 mt-3 bg-nofx-gold/10 border border-nofx-gold/25">
className="rounded p-3 mt-3"
style={{
background: 'rgba(240, 185, 11, 0.08)',
border: '1px solid rgba(240, 185, 11, 0.25)',
}}
>
{language === 'zh' ? ( {language === 'zh' ? (
<div className="text-sm"> <div className="text-sm">
<strong style={{ color: '#F0B90B' }}>提示:</strong>{' '} <strong className="text-nofx-gold">Note:</strong>{' '}
我们为高质量贡献提供激励Bounty/奖金、荣誉徽章与鸣谢、优先 我们为高质量贡献提供激励Bounty/奖金、荣誉徽章与鸣谢、优先
Review/合并与内测资格 等)。 详情可关注带 Review/合并与内测资格 等)。 详情可关注带
<a <a
@@ -448,7 +430,7 @@ export function FAQContent({
</div> </div>
{/* Divider */} {/* Divider */}
<div className="mt-6 h-px" style={{ background: '#2B3139' }} /> <div className="mt-6 h-px bg-white/5" />
</section> </section>
))} ))}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { HelpCircle } from 'lucide-react' import { HelpCircle } from 'lucide-react'
import { Container } from '../Container' import { DeepVoidBackground } from '../DeepVoidBackground'
import { t, type Language } from '../../i18n/translations' import { t, type Language } from '../../i18n/translations'
import { FAQSearchBar } from './FAQSearchBar' import { FAQSearchBar } from './FAQSearchBar'
import { FAQSidebar } from './FAQSidebar' import { FAQSidebar } from './FAQSidebar'
@@ -58,125 +58,121 @@ export function FAQLayout({ language }: FAQLayoutProps) {
} }
return ( return (
<Container className="py-6 pt-24"> <DeepVoidBackground className="py-6 pt-24" disableAnimation>
{/* Page Header */} <div className="w-full px-4 md:px-8">
<div className="text-center mb-12"> {/* Page Header */}
<div className="flex items-center justify-center gap-3 mb-4"> <div className="text-center mb-12">
<div <div className="flex items-center justify-center gap-3 mb-4">
className="w-16 h-16 rounded-full flex items-center justify-center" <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)]">
style={{ <HelpCircle className="w-8 h-8 text-[#0B0E11]" />
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)', </div>
boxShadow: '0 8px 24px rgba(240, 185, 11, 0.4)', </div>
}} <h1 className="text-4xl font-bold mb-4 text-nofx-text-main">
> {t('faqTitle', language)}
<HelpCircle className="w-8 h-8" style={{ color: '#0B0E11' }} /> </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>
</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 */} {/* Main Content */}
<div className="max-w-2xl mx-auto"> <div className="flex gap-8">
<FAQSearchBar {/* Sidebar - Hidden on mobile, visible on desktop */}
searchTerm={searchTerm} <aside className="hidden lg:block w-64 flex-shrink-0">
onSearchChange={setSearchTerm} <FAQSidebar
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
categories={filteredCategories} categories={filteredCategories}
activeItemId={activeItemId}
language={language} language={language}
onActiveItemChange={setActiveItemId} onItemClick={handleItemClick}
/> />
) : ( </aside>
<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 */} {/* Content Area */}
<div <main className="flex-1 min-w-0">
className="mt-16 p-8 rounded-lg text-center" {filteredCategories.length > 0 ? (
style={{ <FAQContent
background: categories={filteredCategories}
'linear-gradient(135deg, rgba(240, 185, 11, 0.1) 0%, rgba(252, 213, 53, 0.05) 100%)', language={language}
border: '1px solid rgba(240, 185, 11, 0.2)', onActiveItemChange={setActiveItemId}
}} />
> ) : (
<h3 className="text-xl font-bold mb-3" style={{ color: '#EAECEF' }}> <div className="text-center py-12">
{t('faqStillHaveQuestions', language)} <p className="text-lg" style={{ color: '#848E9C' }}>
</h3> {language === 'zh'
<p className="mb-6" style={{ color: '#848E9C' }}> ? '没有找到匹配的问题'
{t('faqContactUs', language)} : 'No matching questions found'}
</p> </p>
<div className="flex items-center justify-center gap-4"> <button
<a onClick={() => setSearchTerm('')}
href="https://github.com/NoFxAiOS/nofx" className="mt-4 px-6 py-2 rounded-lg font-semibold transition-all hover:opacity-90"
target="_blank" style={{
rel="noopener noreferrer" background:
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105" 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
style={{ color: '#0B0E11',
background: '#1E2329', }}
color: '#EAECEF', >
border: '1px solid #2B3139', {language === 'zh' ? '清除搜索' : 'Clear Search'}
}} </button>
> </div>
GitHub )}
</a> </main>
<a </div>
href="https://t.me/nofx_dev_community"
target="_blank" {/* Contact Section */}
rel="noopener noreferrer" <div
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105" className="mt-16 p-8 rounded-lg text-center"
style={{ style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)', background:
color: '#0B0E11', '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)',
> }}
{t('community', language)} >
</a> <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>
</div> </div>
</Container> </DeepVoidBackground>
) )
} }

View File

@@ -12,36 +12,21 @@ export function FAQSearchBar({
placeholder = 'Search FAQ...', placeholder = 'Search FAQ...',
}: FAQSearchBarProps) { }: FAQSearchBarProps) {
return ( return (
<div className="relative"> <div className="relative group">
<Search <Search
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5" 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"
style={{ color: '#848E9C' }}
/> />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="w-full pl-12 pr-12 py-3 rounded-lg text-base transition-all focus:outline-none focus:ring-2" 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"
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'
}}
/> />
{searchTerm && ( {searchTerm && (
<button <button
onClick={() => onSearchChange('')} onClick={() => onSearchChange('')}
className="absolute right-4 top-1/2 transform -translate-y-1/2 hover:opacity-70 transition-opacity" className="absolute right-4 top-1/2 transform -translate-y-1/2 text-nofx-text-muted hover:text-white transition-colors"
style={{ color: '#848E9C' }}
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>

View File

@@ -24,14 +24,11 @@ export function FAQSidebar({
> >
<div className="space-y-6"> <div className="space-y-6">
{categories.map((category) => ( {categories.map((category) => (
<div key={category.id}> <div key={category.id} className="nofx-glass p-4 rounded-xl border border-white/5">
{/* Category Title */} {/* Category Title */}
<div className="flex items-center gap-2 mb-3 px-3"> <div className="flex items-center gap-2 mb-3 px-3">
<category.icon className="w-5 h-5" style={{ color: '#F0B90B' }} /> <category.icon className="w-5 h-5 text-nofx-gold" />
<h3 <h3 className="text-sm font-bold uppercase tracking-wide text-nofx-gold">
className="text-sm font-bold uppercase tracking-wide"
style={{ color: '#F0B90B' }}
>
{t(category.titleKey, language)} {t(category.titleKey, language)}
</h3> </h3>
</div> </div>
@@ -44,30 +41,10 @@ export function FAQSidebar({
<li key={item.id}> <li key={item.id}>
<button <button
onClick={() => onItemClick(category.id, item.id)} onClick={() => onItemClick(category.id, item.id)}
className="w-full text-left px-3 py-2 rounded-lg text-sm transition-all" className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-all border-l-[3px] ${isActive
style={{ ? 'bg-nofx-gold/10 text-nofx-gold border-nofx-gold pl-[9px]'
background: isActive : 'bg-transparent text-nofx-text-muted border-transparent pl-3 hover:bg-nofx-gold/5 hover:text-nofx-text-main'
? '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'
}
}}
> >
{t(item.questionKey, language)} {t(item.questionKey, language)}
</button> </button>

View File

@@ -42,9 +42,9 @@ export default function FooterSection({ language }: FooterSectionProps) {
return ( return (
<footer style={{ background: '#0B0E11', borderTop: '1px solid rgba(255, 255, 255, 0.06)' }}> <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 */} {/* 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 */} {/* Brand */}
<div className="md:col-span-1"> <div className="md:col-span-1">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">

View File

@@ -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="max-w-[1920px] mx-auto px-4 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="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-12 md:text-left">
{stats.map((stat, i) => ( {stats.map((stat, i) => (
<motion.div <motion.div
key={i} key={i}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }} whileInView={{ opacity: 1 }}
transition={{ delay: i * 0.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} {stat.value}
</div> </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} {stat.label}
</div> </div>
</motion.div> </motion.div>

View File

@@ -4,6 +4,8 @@ import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
const agents = [ const agents = [
{ {
name: "ALPHA-1", 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", class: "SCALPER",
desc: "High-frequency microstructure exploitation.", desc: "High-frequency microstructure exploitation.",
apy: "142%", apy: "142%",
@@ -41,8 +43,10 @@ const agents = [
] ]
export default function AgentGrid() { export default function AgentGrid() {
// Simplified State to prevent crash
return ( 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 */} {/* Background Details */}
<div className="absolute top-0 right-0 p-10 opacity-20 pointer-events-none"> <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="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>
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase"> <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 <Crosshair className="w-4 h-4" /> MARKET SELECT
@@ -65,17 +69,18 @@ export default function AgentGrid() {
</div> </div>
</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) => { {agents.map((agent, i) => {
const Icon = agent.icon const Icon = agent.icon
return ( return (
<motion.div <motion.div
key={i} key={i}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }} 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}`} 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`}
style={{ clipPath: 'polygon(0 0, 100% 0, 100% 85%, 85% 100%, 0 100%)' }}
> >
{/* Top "Hinge" decoration */} {/* 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> <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>

View File

@@ -65,7 +65,7 @@ export default function LiveFeed() {
<section className="w-full bg-[#020304] border-y border-zinc-800 py-1 overflow-hidden relative"> <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="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) */} {/* 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"> <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="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' : <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 === 'ARB' ? 'text-nofx-gold bg-nofx-gold/10 px-1 rounded' :
log.type === 'EXE' ? 'text-green-500' : 'text-zinc-500' log.type === 'EXE' ? 'text-green-500' : 'text-zinc-500'
}`}>{log.type}</span> }`}>{log.type}</span>
<span className={`${log.color}`}>{log.msg}</span> <span className={`${log.color}`}>{log.msg}</span>
</motion.div> </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"> <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="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' : <span className={`font-bold w-8 shrink-0 ${log.type === 'LIQ' ? 'text-red-500' :
log.type === 'ARB' ? 'text-nofx-gold' : log.type === 'ARB' ? 'text-nofx-gold' :
'text-zinc-500' 'text-zinc-500'
}`}>{log.type}</span> }`}>{log.type}</span>
<span className={`${log.color} truncate`}>{log.msg}</span> <span className={`${log.color} truncate`}>{log.msg}</span>
</div> </div>

View File

@@ -1,7 +1,6 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ArrowRight, Shield, Activity, CircuitBoard, Cpu, Wifi, Globe, Lock, Zap, Star, GitFork, Users, MessageCircle } from 'lucide-react' import { ArrowRight, Shield, Activity, CircuitBoard, Cpu, Wifi, Globe, Lock, Zap, Star, GitFork, Users, MessageCircle } from 'lucide-react'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { httpClient } from '../../../lib/httpClient'
import { useGitHubStats } from '../../../hooks/useGitHubStats' import { useGitHubStats } from '../../../hooks/useGitHubStats'
export default function TerminalHero() { export default function TerminalHero() {
@@ -27,9 +26,18 @@ export default function TerminalHero() {
try { try {
const results = await Promise.all(symbols.map(async (sym) => { const results = await Promise.all(symbols.map(async (sym) => {
try { try {
const res = await httpClient.get(`/api/klines?symbol=${sym}USDT&interval=1m&limit=1`) // Use native fetch to bypass global error handlers (toasts) in httpClient
if (res.success && res.data?.length > 0) { const response = await fetch(`/api/klines?symbol=${sym}USDT&interval=1m&limit=1`)
const closePrice = parseFloat(res.data[0].close) 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 // Format price: < 1 use 4 decimals, > 1 use 2
const formatted = closePrice < 1 const formatted = closePrice < 1
? closePrice.toFixed(4) ? closePrice.toFixed(4)
@@ -37,7 +45,7 @@ export default function TerminalHero() {
return { symbol: sym, price: formatted } return { symbol: sym, price: formatted }
} }
} catch (err) { } catch (err) {
// ignore individual failures // Silent failure for background polling
} }
return null return null
})) }))
@@ -64,6 +72,7 @@ export default function TerminalHero() {
{/* BACKGROUND LAYERS */} {/* BACKGROUND LAYERS */}
{/* 1. Grid */} {/* 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-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> <div className="absolute inset-0 bg-grid-pattern opacity-[0.03] pointer-events-none"></div>
{/* 2. World Map / Data Viz Background (Abstract) */} {/* 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 className="absolute w-[60vw] h-[60vw] rounded-full border border-dashed border-nofx-accent/20 animate-[spin_60s_linear_infinite]"></div>
</div> </div>
{/* 3. Gradient Spots */} {/* 3. Gradient Spots - Intensified for Mobile */}
<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 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/5 rounded-full blur-[120px] pointer-events-none"></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 */} {/* 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"> <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> </motion.div>
{/* Main Title - Massive & Impactful */} {/* 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"> {/* Main Title - Massive & Impactful */}
AGENTIC<br /> <div className="relative z-20 mix-blend-hard-light md:mix-blend-normal">
<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 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">
</h1> 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"> <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. The World's First Open-Source Agentic Trading OS.
Deploy autonomous high-frequency trading agents powered by advanced LLMs. Deploy autonomous high-frequency trading agents powered by advanced LLMs.
</p> </p>
</div>
{/* Market Access Strip - Prominent Display */} {/* Market Access Strip - Prominent Display */}
{/* Market Access Strip - Prominent Display */} {/* Market Access Strip - Prominent Display */}
@@ -214,26 +236,52 @@ export default function TerminalHero() {
</div> </div>
</div> </div>
{/* RIGHT COLUMN: HOLOGRAPHIC DISPLAY - Absolute Overlay for "Far Right" Effect */} {/* RIGHT COLUMN: HOLOGRAPHIC DISPLAY - Absolute Overlay for "Far Right" Effect on Desktop, Background on Mobile */}
<div className="absolute top-0 right-0 h-full w-[45vw] hidden lg:flex pointer-events-none items-center justify-center z-0"> <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"> <div className="relative w-full h-full flex items-center justify-center perspective-1000">
{/* 3D Hologram Effect Container */} {/* 3D Hologram Effect Container */}
<div className="relative w-full h-[90%] flex items-center justify-center transform-style-3d rotate-y-[-12deg]"> <div className="relative w-full h-[90%] flex items-center justify-center transform-style-3d rotate-y-[-12deg]">
{/* Scanning Grid behind Mascot */} {/* 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%)]"></div> <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 */} {/* 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="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-0 bg-nofx-accent/10 blur-[80px] rounded-full animate-pulse-slow transition-colors duration-500 group-hover:bg-nofx-gold/40"></div> <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>
<img
src="/images/nofx_mascot.png" {/* Mobile Holo-Portrait Style - Full Color & Optimized & Premium Desktop */}
alt="Agent NoFX" <div className="relative w-full h-full flex items-end justify-center">
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" <img
style={{ maskImage: 'linear-gradient(to bottom, black 90%, transparent 100%)' }} src="/images/nofx_mascot.png"
/> alt="Agent NoFX"
{/* Holo Scan Line */} className="w-full h-full object-contain object-bottom char-premium-effects animate-breath-mobile transition-all duration-500"
<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> 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>
</div> </div>
@@ -241,7 +289,7 @@ export default function TerminalHero() {
<motion.div <motion.div
animate={{ y: [0, -10, 0] }} animate={{ y: [0, -10, 0] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }} 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" /> <Cpu className="w-5 h-5 text-nofx-accent" />
</motion.div> </motion.div>
@@ -249,7 +297,7 @@ export default function TerminalHero() {
<motion.div <motion.div
animate={{ y: [0, 10, 0] }} animate={{ y: [0, 10, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 1 }} 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" /> <Lock className="w-5 h-5 text-nofx-gold" />
</motion.div> </motion.div>

View File

@@ -1,11 +1,7 @@
import { useState } from 'react' 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' 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 { interface CoinSourceEditorProps {
config: CoinSourceConfig config: CoinSourceConfig
onChange: (config: CoinSourceConfig) => void onChange: (config: CoinSourceConfig) => void
@@ -20,26 +16,23 @@ export function CoinSourceEditor({
language, language,
}: CoinSourceEditorProps) { }: CoinSourceEditorProps) {
const [newCoin, setNewCoin] = useState('') const [newCoin, setNewCoin] = useState('')
const [newExcludedCoin, setNewExcludedCoin] = useState('')
const t = (key: string) => { const t = (key: string) => {
const translations: Record<string, Record<string, string>> = { const translations: Record<string, Record<string, string>> = {
sourceType: { zh: '数据来源类型', en: 'Source Type' }, sourceType: { zh: '数据来源类型', en: 'Source Type' },
static: { zh: '静态列表', en: 'Static List' }, 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' }, oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
mixed: { zh: '混合模式', en: 'Mixed Mode' }, mixed: { zh: '混合模式', en: 'Mixed Mode' },
staticCoins: { zh: '自定义币种', en: 'Custom Coins' }, staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
addCoin: { zh: '添加币种', en: 'Add Coin' }, addCoin: { zh: '添加币种', en: 'Add Coin' },
useCoinPool: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' }, useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
coinPoolLimit: { zh: '数据源数量上限', en: 'Data Provider Limit' }, ai500Limit: { zh: '数量上限', en: 'Limit' },
coinPoolApiUrl: { zh: 'AI500 API URL', en: 'AI500 API URL' },
coinPoolApiUrlPlaceholder: { zh: '输入 AI500 数据源 API 地址...', en: 'Enter AI500 data provider API URL...' },
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' }, useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
oiTopLimit: { zh: 'OI Top 数量上限', en: 'OI Top Limit' }, oiTopLimit: { zh: '数量上限', en: 'Limit' },
oiTopApiUrl: { zh: 'OI Top API URL', en: 'OI Top API URL' },
oiTopApiUrlPlaceholder: { zh: '输入 OI Top 持仓数据 API 地址...', en: 'Enter OI Top API URL...' },
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' }, staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
coinpoolDesc: { ai500Desc: {
zh: '使用 AI500 智能筛选的热门币种', zh: '使用 AI500 智能筛选的热门币种',
en: 'Use AI500 smart-filtered popular coins', en: 'Use AI500 smart-filtered popular coins',
}, },
@@ -51,16 +44,18 @@ export function CoinSourceEditor({
zh: '组合多种数据源AI500 + OI Top + 自定义', zh: '组合多种数据源AI500 + OI Top + 自定义',
en: 'Combine multiple sources: AI500 + OI Top + Custom', 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' }, 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 return translations[key]?.[language] || key
} }
const sourceTypes = [ const sourceTypes = [
{ value: 'static', icon: List, color: '#848E9C' }, { 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: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
{ value: 'mixed', icon: Database, color: '#60a5fa' }, { value: 'mixed', icon: Database, color: '#60a5fa' },
] as const ] 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Source Type Selector */} {/* Source Type Selector */}
<div> <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')} {t('sourceType')}
</label> </label>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
@@ -131,24 +165,16 @@ export function CoinSourceEditor({
onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] }) onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] })
} }
disabled={disabled} disabled={disabled}
className={`p-4 rounded-lg border transition-all ${ className={`p-4 rounded-lg border transition-all ${config.source_type === value
config.source_type === value ? 'ring-2 ring-nofx-gold bg-nofx-gold/10'
? 'ring-2 ring-yellow-500' : 'hover:bg-white/5 bg-nofx-bg'
: 'hover:bg-white/5' } border-nofx-gold/20`}
}`}
style={{
background:
config.source_type === value
? 'rgba(240, 185, 11, 0.1)'
: '#0B0E11',
borderColor: '#2B3139',
}}
> >
<Icon className="w-6 h-6 mx-auto mb-2" style={{ color }} /> <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)} {t(value)}
</div> </div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}> <div className="text-xs mt-1 text-nofx-text-muted">
{t(`${value}Desc`)} {t(`${value}Desc`)}
</div> </div>
</button> </button>
@@ -159,15 +185,14 @@ export function CoinSourceEditor({
{/* Static Coins */} {/* Static Coins */}
{(config.source_type === 'static' || config.source_type === 'mixed') && ( {(config.source_type === 'static' || config.source_type === 'mixed') && (
<div> <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')} {t('staticCoins')}
</label> </label>
<div className="flex flex-wrap gap-2 mb-3"> <div className="flex flex-wrap gap-2 mb-3">
{(config.static_coins || []).map((coin) => ( {(config.static_coins || []).map((coin) => (
<span <span
key={coin} key={coin}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm" className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-bg-lighter text-nofx-text"
style={{ background: '#2B3139', color: '#EAECEF' }}
> >
{coin} {coin}
{!disabled && ( {!disabled && (
@@ -189,17 +214,11 @@ export function CoinSourceEditor({
onChange={(e) => setNewCoin(e.target.value)} onChange={(e) => setNewCoin(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()} onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()}
placeholder="BTC, ETH, SOL..." placeholder="BTC, ETH, SOL..."
className="flex-1 px-4 py-2 rounded-lg" className="flex-1 px-4 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/> />
<button <button
onClick={handleAddCoin} onClick={handleAddCoin}
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors" className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors bg-nofx-gold text-black hover:bg-yellow-500"
style={{ background: '#F0B90B', color: '#0B0E11' }}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
{t('addCoin')} {t('addCoin')}
@@ -209,195 +228,172 @@ export function CoinSourceEditor({
</div> </div>
)} )}
{/* Coin Pool Options */} {/* Excluded Coins */}
{(config.source_type === 'coinpool' || config.source_type === 'mixed') && ( <div>
<div className="space-y-4"> <div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-2 mb-2"> <Ban className="w-4 h-4 text-nofx-danger" />
<Link className="w-4 h-4" style={{ color: '#F0B90B' }} /> <label className="text-sm font-medium text-nofx-text">
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}> {t('excludedCoins')}
{t('dataSourceConfig')} - AI500 </label>
</span> </div>
</div> <p className="text-xs mb-3 text-nofx-text-muted">
{t('excludedCoinsDesc')}
<div className="grid grid-cols-2 gap-4"> </p>
<div> <div className="flex flex-wrap gap-2 mb-3">
<label className="flex items-center gap-3 mb-3 cursor-pointer"> {(config.excluded_coins || []).map((coin) => (
<input <span
type="checkbox" key={coin}
checked={config.use_coin_pool} className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-danger/15 text-nofx-danger"
onChange={(e) => >
!disabled && onChange({ ...config, use_coin_pool: e.target.checked }) {coin}
} {!disabled && (
disabled={disabled} <button
className="w-5 h-5 rounded accent-yellow-500" onClick={() => handleRemoveExcludedCoin(coin)}
/> className="ml-1 hover:text-white transition-colors"
<span style={{ color: '#EAECEF' }}>{t('useCoinPool')}</span> >
</label> <X className="w-3 h-3" />
{config.use_coin_pool && ( </button>
<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>
)} )}
</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>
</div> </div>
{config.use_coin_pool && ( <div className="space-y-3">
<div> <label className="flex items-center gap-3 cursor-pointer">
<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>
<input <input
type="url" type="checkbox"
value={config.coin_pool_api_url || ''} checked={config.use_ai500}
onChange={(e) => onChange={(e) =>
!disabled && onChange({ ...config, coin_pool_api_url: e.target.value }) !disabled && onChange({ ...config, use_ai500: e.target.checked })
} }
disabled={disabled} disabled={disabled}
placeholder={t('coinPoolApiUrlPlaceholder')} className="w-5 h-5 rounded accent-nofx-gold"
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/> />
{!config.coin_pool_api_url && ( <span className="text-nofx-text">{t('useAI500')}</span>
<div className="flex items-center gap-2 mt-2"> </label>
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#F0B90B' }}> {config.use_ai500 && (
{t('apiUrlRequired')} <div className="flex items-center gap-3 pl-8">
</span> <span className="text-sm text-nofx-text-muted">
</div> {t('ai500Limit')}:
)} </span>
</div> <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> </div>
)} )}
{/* OI Top Options */} {/* OI Top Options */}
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && ( {(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
<div className="space-y-4"> <div
<div className="flex items-center gap-2 mb-2"> className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
<Link className="w-4 h-4" style={{ color: '#0ECB81' }} /> >
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}> <div className="flex items-center justify-between mb-3">
{t('dataSourceConfig')} - OI Top <div className="flex items-center gap-2">
</span> <TrendingUp className="w-4 h-4 text-nofx-success" />
</div> <span className="text-sm font-medium text-nofx-text">
OI Top {t('dataSourceConfig')}
<div className="grid grid-cols-2 gap-4"> </span>
<div> <NofxOSBadge />
<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> </div>
</div> </div>
{config.use_oi_top && ( <div className="space-y-3">
<div> <label className="flex items-center gap-3 cursor-pointer">
<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>
<input <input
type="url" type="checkbox"
value={config.oi_top_api_url || ''} checked={config.use_oi_top}
onChange={(e) => onChange={(e) =>
!disabled && onChange({ ...config, oi_top_api_url: e.target.value }) !disabled && onChange({ ...config, use_oi_top: e.target.checked })
} }
disabled={disabled} disabled={disabled}
placeholder={t('oiTopApiUrlPlaceholder')} className="w-5 h-5 rounded accent-nofx-success"
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/> />
{!config.oi_top_api_url && ( <span className="text-nofx-text">{t('useOITop')}</span>
<div className="flex items-center gap-2 mt-2"> </label>
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#F0B90B' }}> {config.use_oi_top && (
{t('apiUrlRequired')} <div className="flex items-center gap-3 pl-8">
</span> <span className="text-sm text-nofx-text-muted">
</div> {t('oiTopLimit')}:
)} </span>
</div> <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>
)} )}
</div> </div>

View File

@@ -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' import type { IndicatorConfig } from '../../types'
// Default API URL for quant data (must contain {symbol} placeholder) // Default NofxOS API Key
const DEFAULT_QUANT_DATA_API_URL = 'http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c' const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
// Default API base URL for OI ranking data
const DEFAULT_OI_RANKING_API_URL = 'http://nofxaios.com:30006'
interface IndicatorEditorProps { interface IndicatorEditorProps {
config: IndicatorConfig config: IndicatorConfig
@@ -47,7 +45,7 @@ export function IndicatorEditor({
marketSentiment: { zh: '市场情绪', en: 'Market Sentiment' }, marketSentiment: { zh: '市场情绪', en: 'Market Sentiment' },
marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data' }, marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data' },
quantData: { zh: '量化数据', en: 'Quant Data' }, quantData: { zh: '量化数据', en: 'Quant Data' },
quantDataDesc: { zh: '第三方数据源:资金流向、大户动向', en: 'Third-party: netflow, whale movements' }, quantDataDesc: { zh: '资金流向、大户动向', en: 'Netflow, whale movements' },
// Timeframes // Timeframes
timeframes: { zh: '时间周期', en: 'Timeframes' }, timeframes: { zh: '时间周期', en: 'Timeframes' },
@@ -81,20 +79,40 @@ export function IndicatorEditor({
fundingRate: { zh: '资金费率', en: 'Funding Rate' }, fundingRate: { zh: '资金费率', en: 'Funding Rate' },
fundingRateDesc: { zh: '永续合约资金费率', en: 'Perpetual 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 // OI Ranking
oiRanking: { zh: 'OI 排行数据', en: 'OI Ranking Data' }, oiRanking: { zh: 'OI 排行', en: 'OI Ranking' },
oiRankingDesc: { zh: '市场持仓量增减排行,反映资金流向', en: 'Market-wide OI changes, reflects capital flow' }, oiRankingDesc: { zh: '持仓量增减排行', en: 'OI change ranking' },
oiRankingDuration: { zh: '时间周期', en: 'Duration' },
oiRankingLimit: { zh: '排行数量', en: 'Top N' },
oiRankingNote: { zh: '显示持仓量增加/减少的币种排行,帮助发现资金流向', en: 'Shows coins with OI increase/decrease, helps identify capital flow' }, 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 // Tips
aiCanCalculate: { zh: '💡 提示AI 可自行计算这些指标,开启可减少 AI 计算量', en: '💡 Tip: AI can calculate these, enabling reduces AI workload' }, 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 return translations[key]?.[language] || key
} }
@@ -166,9 +184,364 @@ export function IndicatorEditor({
ensureRawKlines() 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 ( return (
<div className="space-y-5"> <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="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' }}> <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' }} /> <BarChart2 className="w-4 h-4" style={{ color: '#F0B90B' }} />
@@ -275,7 +648,9 @@ export function IndicatorEditor({
</div> </div>
</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="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' }}> <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' }} /> <Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
@@ -345,7 +720,9 @@ export function IndicatorEditor({
</div> </div>
</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="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' }}> <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' }} /> <TrendingUp className="w-4 h-4" style={{ color: '#22c55e' }} />
@@ -387,163 +764,6 @@ export function IndicatorEditor({
</div> </div>
</div> </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> </div>
) )
} }

View 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

View File

@@ -18,11 +18,11 @@ export const translations = {
view: 'View', view: 'View',
// Navigation // Navigation
realtimeNav: 'Live', realtimeNav: 'Leaderboard',
configNav: 'Config', configNav: 'Config',
dashboardNav: 'Dashboard', dashboardNav: 'Dashboard',
strategyNav: 'Strategy', strategyNav: 'Strategy',
debateNav: 'Debate Arena', debateNav: 'Arena',
faqNav: 'FAQ', faqNav: 'FAQ',
// Footer // Footer
@@ -504,7 +504,7 @@ export const translations = {
noExchangesConfigured: 'No configured exchanges', noExchangesConfigured: 'No configured exchanges',
signalSource: 'Signal Source', signalSource: 'Signal Source',
signalSourceConfig: 'Signal Source Configuration', signalSourceConfig: 'Signal Source Configuration',
coinPoolDescription: ai500Description:
'API endpoint for AI500 data provider, leave blank to disable this signal source', 'API endpoint for AI500 data provider, leave blank to disable this signal source',
oiTopDescription: oiTopDescription:
'API endpoint for open interest rankings, leave blank to disable this signal source', 'API endpoint for open interest rankings, leave blank to disable this signal source',
@@ -784,7 +784,7 @@ export const translations = {
candidateCoins: 'Candidate Coins', candidateCoins: 'Candidate Coins',
candidateCoinsZeroWarning: 'Candidate Coins Count is 0', candidateCoinsZeroWarning: 'Candidate Coins Count is 0',
possibleReasons: 'Possible Reasons:', possibleReasons: 'Possible Reasons:',
coinPoolApiNotConfigured: ai500ApiNotConfigured:
'AI500 data provider API not configured or inaccessible (check signal source settings)', 'AI500 data provider API not configured or inaccessible (check signal source settings)',
apiConnectionTimeout: 'API connection timeout or returned empty data', apiConnectionTimeout: 'API connection timeout or returned empty data',
noCustomCoinsAndApiFailed: noCustomCoinsAndApiFailed:
@@ -792,7 +792,7 @@ export const translations = {
solutions: 'Solutions:', solutions: 'Solutions:',
setCustomCoinsInConfig: 'Set custom coin list in trader configuration', setCustomCoinsInConfig: 'Set custom coin list in trader configuration',
orConfigureCorrectApiUrl: 'Or configure correct data provider API address', orConfigureCorrectApiUrl: 'Or configure correct data provider API address',
orDisableCoinPoolOptions: orDisableAI500Options:
'Or disable "Use AI500 Data Provider" and "Use OI Top" options', 'Or disable "Use AI500 Data Provider" and "Use OI Top" options',
signalSourceNotConfigured: 'Signal Source Not Configured', signalSourceNotConfigured: 'Signal Source Not Configured',
signalSourceWarningMessage: signalSourceWarningMessage:
@@ -1226,11 +1226,11 @@ export const translations = {
view: '查看', view: '查看',
// Navigation // Navigation
realtimeNav: '实时', realtimeNav: '排行榜',
configNav: '配置', configNav: '配置',
dashboardNav: '看板', dashboardNav: '看板',
strategyNav: '策略', strategyNav: '策略',
debateNav: '行情辩论', debateNav: '竞技场',
faqNav: '常见问题', faqNav: '常见问题',
// Footer // Footer
@@ -1691,7 +1691,7 @@ export const translations = {
noExchangesConfigured: '暂无已配置的交易所', noExchangesConfigured: '暂无已配置的交易所',
signalSource: '信号源', signalSource: '信号源',
signalSourceConfig: '信号源配置', signalSourceConfig: '信号源配置',
coinPoolDescription: ai500Description:
'用于获取 AI500 数据源的 API 地址,留空则不使用此数据源', '用于获取 AI500 数据源的 API 地址,留空则不使用此数据源',
oiTopDescription: '用于获取持仓量排行数据的API地址留空则不使用此信号源', oiTopDescription: '用于获取持仓量排行数据的API地址留空则不使用此信号源',
information: '说明', information: '说明',
@@ -1939,14 +1939,14 @@ export const translations = {
candidateCoins: '候选币种', candidateCoins: '候选币种',
candidateCoinsZeroWarning: '候选币种数量为 0', candidateCoinsZeroWarning: '候选币种数量为 0',
possibleReasons: '可能原因:', possibleReasons: '可能原因:',
coinPoolApiNotConfigured: ai500ApiNotConfigured:
'AI500 数据源 API 未配置或无法访问(请检查信号源设置)', 'AI500 数据源 API 未配置或无法访问(请检查信号源设置)',
apiConnectionTimeout: 'API连接超时或返回数据为空', apiConnectionTimeout: 'API连接超时或返回数据为空',
noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败', noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败',
solutions: '解决方案:', solutions: '解决方案:',
setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表', setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表',
orConfigureCorrectApiUrl: '或者配置正确的数据源 API 地址', orConfigureCorrectApiUrl: '或者配置正确的数据源 API 地址',
orDisableCoinPoolOptions: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项', orDisableAI500Options: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项',
signalSourceNotConfigured: '信号源未配置', signalSourceNotConfigured: '信号源未配置',
signalSourceWarningMessage: signalSourceWarningMessage:
'您有交易员启用了"使用 AI500 数据源"或"使用 OI Top",但尚未配置信号源 API 地址。这将导致候选币种数量为 0交易员无法正常工作。', '您有交易员启用了"使用 AI500 数据源"或"使用 OI Top",但尚未配置信号源 API 地址。这将导致候选币种数量为 0交易员无法正常工作。',

View File

@@ -439,11 +439,15 @@ button:disabled {
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
} }
/* Premium Input Styles */
/* Premium Input Styles */ /* Premium Input Styles */
input, input,
select, select,
textarea { textarea {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 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, input:focus,
@@ -452,6 +456,35 @@ textarea:focus {
border-color: var(--binance-yellow); border-color: var(--binance-yellow);
box-shadow: 0 0 0 2px rgba(252, 213, 53, 0.15); box-shadow: 0 0 0 2px rgba(252, 213, 53, 0.15);
outline: none; 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 */ /* Binance Card - Premium Polish */
@@ -846,4 +879,103 @@ tr:hover {
.stat-card { .stat-card {
padding: 1rem; 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));
} }

View File

@@ -27,6 +27,7 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
} from 'lucide-react' } from 'lucide-react'
import { DeepVoidBackground } from '../components/DeepVoidBackground'
// Translations // Translations
const T: Record<string, Record<string, string>> = { const T: Record<string, Record<string, string>> = {
@@ -144,7 +145,7 @@ function MessageCard({ msg }: { msg: DebateMessage }) {
return ( return (
<div <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}` }} style={{ borderLeft: `3px solid ${p.color}` }}
> >
{/* Header - Always visible */} {/* Header - Always visible */}
@@ -153,16 +154,16 @@ function MessageCard({ msg }: { msg: DebateMessage }) {
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
<AIAvatar name={msg.ai_model_name} size={24} /> <AIAvatar name={msg.ai_model_name} size={24} />
<span className="text-sm text-white font-medium">{msg.ai_model_name}</span> <span className="text-sm text-nofx-text font-medium">{msg.ai_model_name}</span>
<span className="text-xs text-gray-500">{p.nameEn}</span> <span className="text-xs text-nofx-text-muted">{p.nameEn}</span>
<div className="flex-1" /> <div className="flex-1" />
{msg.decision && ( {msg.decision && (
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${a.bg} ${a.color}`}> <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} {a.icon} {msg.decision.symbol || ''} {a.label}
</span> </span>
)} )}
<span className="text-xs text-yellow-400 font-medium">{msg.decision?.confidence || msg.confidence}%</span> <span className="text-xs text-nofx-gold font-medium">{msg.decision?.confidence || msg.confidence}%</span>
{open ? <ChevronUp size={14} className="text-gray-500" /> : <ChevronDown size={14} className="text-gray-500" />} {open ? <ChevronUp size={14} className="text-nofx-text-muted" /> : <ChevronDown size={14} className="text-nofx-text-muted" />}
</div> </div>
{/* Preview when collapsed */} {/* 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 a = ACT[vote.action] || ACT.wait
const confColor = vote.confidence >= 70 ? 'bg-green-500' : vote.confidence >= 50 ? 'bg-yellow-500' : 'bg-gray-500' const confColor = vote.confidence >= 70 ? 'bg-green-500' : vote.confidence >= 50 ? 'bg-yellow-500' : 'bg-gray-500'
return ( 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 justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AIAvatar name={vote.ai_model_name} size={28} /> <AIAvatar name={vote.ai_model_name} size={28} />
<div> <div>
<span className="text-white font-semibold block">{vote.ai_model_name}</span> <span className="text-nofx-text font-semibold block">{vote.ai_model_name}</span>
{vote.symbol && <span className="text-xs text-gray-400">{vote.symbol}</span>} {vote.symbol && <span className="text-xs text-nofx-text-muted">{vote.symbol}</span>}
</div> </div>
</div> </div>
<span className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold ${a.bg} ${a.color}`}> <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> </div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> <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-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-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-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-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-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-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">TP</span><span className="text-green-400 font-semibold">{vote.take_profit_pct ? `${(vote.take_profit_pct * 100).toFixed(1)}%` : '-'}</span></div>
</div> </div>
{vote.reasoning && ( {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> </div>
) )
@@ -386,22 +387,22 @@ function CreateModal({
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="bg-[#1a1d24] rounded-xl w-full max-w-md p-4 border border-white/10"> <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"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-white">{t('createDebate', language)}</h3> <h3 className="text-lg font-bold text-nofx-text">{t('createDebate', language)}</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button> <button onClick={onClose}><X size={20} className="text-nofx-text-muted" /></button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<input <input
value={name} onChange={e => setName(e.target.value)} 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 */} {/* Strategy selector - moved up */}
<select value={strategyId} onChange={e => setStrategyId(e.target.value)} <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>)} {strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select> </select>
@@ -409,17 +410,17 @@ function CreateModal({
{/* Show dropdown only for static type with coins defined */} {/* Show dropdown only for static type with coins defined */}
{isStaticWithCoins ? ( {isStaticWithCoins ? (
<select value={symbol} onChange={e => setSymbol(e.target.value)} <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>)} {staticCoins.map(coin => <option key={coin} value={coin}>{coin}</option>)}
</select> </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'} {language === 'zh' ? '根据策略规则自动选择' : 'Auto-selected by strategy'}
</div> </div>
)} )}
<select value={maxRounds} onChange={e => setMaxRounds(+e.target.value)} <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"> 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>)} {[2, 3, 4, 5].map(n => <option key={n} value={n}>{n} {language === 'zh' ? '轮' : 'rounds'}</option>)}
</select> </select>
</div> </div>
@@ -431,7 +432,7 @@ function CreateModal({
{/* Personality selector */} {/* Personality selector */}
<select value={p.personality} onChange={e => { <select value={p.personality} onChange={e => {
const up = [...participants]; up[i].personality = e.target.value as DebatePersonality; setParticipants(up) 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]) => ( {Object.entries(PERS).map(([k, v]) => (
<option key={k} value={k}>{v.emoji} {language === 'zh' ? v.name : v.nameEn}</option> <option key={k} value={k}>{v.emoji} {language === 'zh' ? v.name : v.nameEn}</option>
))} ))}
@@ -439,23 +440,23 @@ function CreateModal({
{/* AI model selector */} {/* AI model selector */}
<select value={p.ai_model_id} onChange={e => { <select value={p.ai_model_id} onChange={e => {
const up = [...participants]; up[i].ai_model_id = e.target.value; setParticipants(up) 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>)} {aiModels.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select> </select>
<button onClick={() => setParticipants(participants.filter((_, j) => j !== i))} <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> </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)} + {t('addAI', language)}
</button> </button>
</div> </div>
</div> </div>
<div className="flex gap-2 mt-4"> <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} <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)} {creating ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('create', language)}
</button> </button>
</div> </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>) const voteSum = votes.reduce((a, v) => { a[v.action] = (a[v.action] || 0) + 1; return a }, {} as Record<string, number>)
return ( return (
<div className="h-full bg-[#0a0c10] flex overflow-hidden"> <DeepVoidBackground className="h-full flex overflow-hidden relative" disableAnimation>
{/* Left - Debate List + Online Traders */} {/* 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 */} {/* New Debate Button */}
<button onClick={() => setShowCreate(true)} <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)} <Plus size={16} /> {t('newDebate', language)}
</button> </button>
{/* Debate List */} {/* 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%' }}> <div className="overflow-y-auto" style={{ maxHeight: '30%' }}>
{debates?.map(d => ( {debates?.map(d => (
<div key={d.id} onClick={() => setSelectedId(d.id)} <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"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${STATUS_COLOR[d.status]}`} /> <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>
<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 && ( {d.status === 'pending' && selectedId === d.id && (
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
<button onClick={e => { e.stopPropagation(); onStart(d.id) }} <button onClick={e => { e.stopPropagation(); onStart(d.id) }}
@@ -569,41 +571,41 @@ export function DebateArenaPage() {
</div> </div>
{/* Online Traders Section */} {/* Online Traders Section */}
<div className="flex-1 border-t border-white/5 mt-2 overflow-hidden flex flex-col"> <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-gray-500 font-semibold flex items-center gap-1"> <div className="px-2 py-2 text-xs text-nofx-text-muted font-semibold flex items-center gap-1">
<Zap size={12} className="text-green-400" /> <Zap size={12} className="text-nofx-success" />
{t('onlineTraders', language)} {t('onlineTraders', language)}
</div> </div>
<div className="flex-1 overflow-y-auto px-2 space-y-2"> <div className="flex-1 overflow-y-auto px-2 space-y-2">
{traders?.filter(tr => tr.is_running).map(tr => ( {traders?.filter(tr => tr.is_running).map(tr => (
<div key={tr.trader_id} <div key={tr.trader_id}
onClick={() => { setTraderId(tr.trader_id); if (decision && !decision.executed) setExecId(detail?.id || null) }} 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"> <div className="flex items-center gap-2">
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" /> <PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div> <div className="text-sm text-nofx-text font-medium truncate">{tr.trader_name}</div>
<div className="text-xs text-gray-500 truncate">{tr.ai_model}</div> <div className="text-xs text-nofx-text-muted truncate">{tr.ai_model}</div>
</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>
</div> </div>
))} ))}
{traders?.filter(tr => !tr.is_running).slice(0, 3).map(tr => ( {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="flex items-center gap-2">
<div className="grayscale"> <div className="grayscale">
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" /> <PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div> <div className="text-sm text-nofx-text font-medium truncate">{tr.trader_name}</div>
<div className="text-xs text-gray-500">{t('offline', language)}</div> <div className="text-xs text-nofx-text-muted">{t('offline', language)}</div>
</div> </div>
</div> </div>
</div> </div>
))} ))}
{(!traders || traders.length === 0) && ( {(!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>
</div> </div>
@@ -614,12 +616,12 @@ export function DebateArenaPage() {
{detail ? ( {detail ? (
<> <>
{/* Header Bar - Compact */} {/* 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={`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="font-bold text-nofx-text truncate">{detail.name}</span>
<span className="text-yellow-400 font-semibold">{detail.symbol}</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>} {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 */} {/* Participants */}
<div className="flex gap-1 ml-2"> <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 vote = votes.find(v => v.ai_model_id === p.ai_model_id)
const act = vote ? (ACT[vote.action] || ACT.wait) : null const act = vote ? (ACT[vote.action] || ACT.wait) : null
return ( 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} /> <AIAvatar name={p.ai_model_name} size={14} />
{act && <span className={`${act.color}`}>{act.icon}</span>} {act && <span className={`${act.color}`}>{act.icon}</span>}
</div> </div>
@@ -662,8 +664,8 @@ export function DebateArenaPage() {
) : ( ) : (
<> <>
{/* Left - Rounds */} {/* Left - Rounds */}
<div className="flex-1 overflow-y-auto p-4 border-r border-white/5"> <div className="flex-1 overflow-y-auto p-4 border-r border-nofx-gold/20">
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2"> <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> <span className="w-2 h-2 bg-blue-500 rounded-full"></span>
{t('discussionRecords', language)} {t('discussionRecords', language)}
</div> </div>
@@ -681,9 +683,9 @@ export function DebateArenaPage() {
{/* Right - Votes */} {/* Right - Votes */}
{votes.length > 0 && ( {votes.length > 0 && (
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 bg-[#0d1017]/50"> <div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 bg-nofx-bg/30 backdrop-blur-sm">
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2"> <div className="text-sm text-nofx-text-muted font-semibold mb-3 flex items-center gap-2">
<Trophy size={16} className="text-yellow-400" /> <Trophy size={16} className="text-nofx-gold" />
{t('finalVotes', language)} {t('finalVotes', language)}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -709,37 +711,37 @@ export function DebateArenaPage() {
{/* Consensus Bar - Show when votes exist */} {/* Consensus Bar - Show when votes exist */}
{(decision || votes.length > 0) && ( {(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"> <div className="flex items-center gap-2">
<Trophy size={20} className="text-yellow-400" /> <Trophy size={20} className="text-nofx-gold" />
<span className="text-sm text-gray-400">{t('consensus', language)}:</span> <span className="text-sm text-nofx-text-muted">{t('consensus', language)}:</span>
{decision ? ( {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}`}> <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} {(ACT[decision.action] || ACT.wait).icon}
{decision.action.replace('_', ' ').toUpperCase()} {decision.action.replace('_', ' ').toUpperCase()}
</span> </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... <Clock size={14} /> VOTING...
</span> </span>
)} )}
</div> </div>
{decision && ( {decision && (
<div className="flex items-center gap-4 text-sm"> <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> <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-gray-500">{t('leverage', language)}</span> <span className="text-white font-bold">{decision.leverage}x</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-gray-500">{t('position', language)}</span> <span className="text-white font-bold">{((decision.position_pct ?? 0) * 100).toFixed(0)}%</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-gray-500">SL</span> <span className="text-red-400 font-bold">{((decision.stop_loss ?? 0) * 100).toFixed(1)}%</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-gray-500">TP</span> <span className="text-green-400 font-bold">{((decision.take_profit ?? 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>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{decision && !decision.executed && (decision.action === 'open_long' || decision.action === 'open_short') && ( {decision && !decision.executed && (decision.action === 'open_long' || decision.action === 'open_short') && (
<button onClick={() => setExecId(detail.id)} <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)} <Zap size={14} /> {t('execute', language)}
</button> </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-center">
<div className="text-4xl mb-2">🗳</div> <div className="text-4xl mb-2">🗳</div>
<div>{t('selectOrCreate', language)}</div> <div>{t('selectOrCreate', language)}</div>
@@ -763,13 +765,13 @@ export function DebateArenaPage() {
{/* Execute Modal */} {/* Execute Modal */}
{execId && ( {execId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="bg-[#1a1d24] rounded-xl w-full max-w-sm p-4 border border-white/10"> <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-white mb-4 flex items-center gap-2"> <h3 className="text-lg font-bold text-nofx-text mb-4 flex items-center gap-2">
<Zap className="text-yellow-400" /> {t('executeTitle', language)} <Zap className="text-nofx-gold" /> {t('executeTitle', language)}
</h3> </h3>
<select value={traderId} onChange={e => setTraderId(e.target.value)} <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> <option value="">{t('selectTrader', language)}...</option>
{traders?.filter(tr => tr.is_running).map(tr => ( {traders?.filter(tr => tr.is_running).map(tr => (
<option key={tr.trader_id} value={tr.trader_id}> {tr.trader_name}</option> <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> <option key={tr.trader_id} value={tr.trader_id} disabled> {tr.trader_name} ({t('offline', language)})</option>
))} ))}
</select> </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'} {language === 'zh' ? '将使用账户余额执行真实交易' : 'Will execute real trade with account balance'}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => { setExecId(null); setTraderId('') }} <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} <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)} {executing ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('execute', language)}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </DeepVoidBackground>
) )
} }

View 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">-&gt;</span>
</a>
</div>
</div>
</DeepVoidBackground>
)
}

View File

@@ -19,7 +19,8 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext' 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 { interface PublicStrategy {
id: string id: string
@@ -225,291 +226,289 @@ export function StrategyMarketPage() {
} }
return ( return (
<div className="min-h-screen bg-black text-white font-mono relative overflow-hidden flex flex-col items-center py-12"> <DeepVoidBackground className="min-h-screen text-white font-mono py-12">
{/* Background Grid & Scanlines */} <div className="w-full px-4 md:px-8 space-y-8">
<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>
<div className="w-full max-w-7xl relative z-10 px-6"> <div className="w-full relative z-10">
{/* Header Section */} {/* Header Section */}
<div className="mb-12 border-b border-zinc-800 pb-8 relative"> <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"> <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> SYSTEM_STATUS: <span className="text-emerald-500 animate-pulse">ONLINE</span>
<br /> <br />
MARKET_UPLINK: <span className="text-emerald-500">ESTABLISHED</span> 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" />
</div> </div>
<div>
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase glitch-text" data-text={t.title}> <div className="flex items-center gap-4 mb-4">
{t.title} <div className="bg-zinc-900 border border-zinc-700 p-3 rounded-none relative group overflow-hidden">
</h1> <div className="absolute inset-0 bg-nofx-gold/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<p className="text-xs text-nofx-gold tracking-[0.3em] font-bold mt-1"> <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} // {t.subtitle}
</p> </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>
</div> </div>
</div> </div>
<p className="text-sm text-zinc-500 max-w-2xl border-l-2 border-zinc-800 pl-4">
{t.description}
</p>
</div> </div>
{/* Category Filter */} {/* Search and Filter Bar */}
<div className="flex gap-2 bg-zinc-900/50 p-1 border border-zinc-800"> <div className="flex flex-col md:flex-row gap-4 mb-8">
{['all', 'popular', 'recent'].map((cat) => ( {/* Search */}
<button <div className="relative flex-1 group">
key={cat} <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>
onClick={() => setSelectedCategory(cat)} <div className="relative bg-black flex items-center border border-zinc-800 group-hover:border-nofx-gold/50 transition-colors">
className={`px-4 py-2 text-xs font-mono uppercase tracking-wider transition-all relative overflow-hidden ${selectedCategory === cat <div className="pl-4 pr-3 text-zinc-500 group-hover:text-nofx-gold transition-colors">
? 'text-black font-bold' <Terminal size={16} />
: 'text-zinc-500 hover:text-white' </div>
}`} <input
> type="text"
{selectedCategory === cat && ( placeholder={t.search}
<motion.div value={searchQuery}
layoutId="filter-highlight" onChange={(e) => setSearchQuery(e.target.value)}
className="absolute inset-0 bg-nofx-gold" className="w-full bg-transparent py-3 text-sm focus:outline-none placeholder-zinc-700 text-nofx-gold font-mono"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} />
/> <div className="pr-4">
)} <div className="w-2 h-4 bg-nofx-gold animate-pulse"></div>
<span className="relative z-10">{t[cat as keyof typeof t]}</span> </div>
</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" />
</div> </div>
</div> </div>
<p className="text-nofx-gold text-xs tracking-widest animate-pulse">{t.loading}</p>
<div className="flex gap-1"> {/* Category Filter */}
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div> <div className="flex gap-2 bg-zinc-900/50 p-1 border border-zinc-800">
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div> {['all', 'popular', 'recent'].map((cat) => (
<div className="w-1 h-1 bg-nofx-gold rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div> <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>
</div> </div>
)}
{/* Empty State */} {/* Loading State */}
{!isLoading && filteredStrategies.length === 0 && ( {isLoading && (
<div className="flex flex-col items-center justify-center py-32 border border-zinc-800 border-dashed bg-zinc-900/20 rounded"> <div className="flex flex-col items-center justify-center py-32 space-y-4">
<div className="relative mb-6"> <div className="relative w-16 h-16">
<div className="absolute -inset-4 bg-red-500/10 rounded-full blur-xl animate-pulse"></div> <div className="absolute inset-0 border-2 border-zinc-800 rounded-full"></div>
<Activity className="w-16 h-16 text-zinc-700 relative z-10" /> <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> </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 */} {/* Empty State */}
{!isLoading && filteredStrategies.length > 0 && ( {!isLoading && filteredStrategies.length === 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="flex flex-col items-center justify-center py-32 border border-zinc-800 border-dashed bg-zinc-900/20 rounded">
<AnimatePresence> <div className="relative mb-6">
{filteredStrategies.map((strategy, i) => { <div className="absolute -inset-4 bg-red-500/10 rounded-full blur-xl animate-pulse"></div>
const style = getStrategyStyle(strategy.name) <Activity className="w-16 h-16 text-zinc-700 relative z-10" />
const Icon = style.icon </div>
const indicators = strategy.config_visible && strategy.config <h3 className="text-xl font-bold text-zinc-300 font-mono tracking-tight mb-2">
? getIndicatorList(strategy.config) [{t.noStrategies}]
: [] </h3>
<p className="text-zinc-600 text-xs tracking-wide uppercase">{t.noStrategiesDesc}</p>
</div>
)}
return ( {/* Strategy Grid */}
<motion.div {!isLoading && filteredStrategies.length > 0 && (
key={strategy.id} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
initial={{ opacity: 0, scale: 0.95 }} <AnimatePresence>
animate={{ opacity: 1, scale: 1 }} {filteredStrategies.map((strategy, i) => {
exit={{ opacity: 0, scale: 0.95 }} const style = getStrategyStyle(strategy.name)
transition={{ delay: i * 0.05 }} const Icon = style.icon
className={`group relative bg-black border border-zinc-800 hover:border-zinc-600 transition-all duration-300 ${style.shadow}`} const indicators = strategy.config_visible && strategy.config
> ? getIndicatorList(strategy.config)
{/* 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>
{/* Category Side Strip */} return (
<div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div> <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"> {/* Category Side Strip */}
{/* Header */} <div className={`absolute left-0 top-0 bottom-0 w-[2px] ${style.bg.replace('/5', '/50')}`}></div>
<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>
{/* Name and Description */} <div className="p-6 relative">
<h3 className={`text-lg font-bold mb-2 tracking-tight group-hover:${style.color} transition-colors uppercase truncate relative`}> {/* Header */}
{strategy.name} <div className="flex justify-between items-start mb-6">
<span className="absolute -bottom-1 left-0 w-8 h-[2px] bg-zinc-800 group-hover:bg-nofx-gold transition-colors"></span> <div className={`p-2 rounded-none border ${style.border} ${style.bg}`}>
</h3> <Icon className={`w-5 h-5 ${style.color}`} />
<p className="text-xs text-zinc-500 mb-6 line-clamp-2 h-8 leading-relaxed font-sans"> </div>
{strategy.description || 'NO_DESCRIPTION_AVAILABLE'} <div className="text-[10px] font-mono">
</p> {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">
{/* Meta Data */} <Eye size={10} />
<div className="grid grid-cols-2 gap-y-2 mb-6 text-[10px] font-mono text-zinc-600"> PUBLIC_ACCESS
<div className="flex flex-col"> </div>
<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 className="flex items-center gap-1.5 text-zinc-500 border border-zinc-800 bg-zinc-900 px-2 py-1">
</div> <EyeOff size={10} />
<div className="flex flex-col text-right"> RESTRICTED
<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> </div>
) : ( </div>
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
<EyeOff size={16} className="mb-1 opacity-50" /> {/* Name and Description */}
<span className="text-[9px] uppercase tracking-widest">{t.configHiddenDesc}</span> <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>
)} <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> </div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)}
{/* Action Button */} {/* CTA - Share Strategy */}
<div> {user && token && (
{strategy.config_visible && strategy.config ? ( <motion.div
<button initial={{ opacity: 0, y: 20 }}
onClick={() => handleCopyConfig(strategy)} animate={{ opacity: 1, y: 0 }}
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" transition={{ delay: 0.3 }}
> className="mt-16 mb-20 flex justify-center"
{copiedId === strategy.id ? ( >
<> <div className="relative group cursor-pointer" onClick={() => window.location.href = '/strategy'}>
<Check className="w-3 h-3 text-emerald-500" /> <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>
<span className="text-emerald-500">{t.copied}</span> <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>
<Copy className="w-3 h-3 group-hover/btn:scale-110 transition-transform" /> <div className="text-[10px] text-zinc-500 font-mono">CONTRIBUTE TO THE GLOBAL DATABASE</div>
{t.copyConfig} </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">
</button> INITIALIZE_UPLOAD -&gt;
) : ( </div>
<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 -&gt;
</div> </div>
</div> </div>
</div> </motion.div>
</motion.div> )}
)}
</div>
</div> </div>
</div> </DeepVoidBackground>
) )
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { import {
@@ -28,6 +28,7 @@ import {
Send, Send,
Download, Download,
Upload, Upload,
Globe,
} from 'lucide-react' } from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel } from '../types' import type { Strategy, StrategyConfig, AIModel } from '../types'
import { confirmToast, notify } from '../lib/notify' import { confirmToast, notify } from '../lib/notify'
@@ -35,6 +36,8 @@ import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
import { IndicatorEditor } from '../components/strategy/IndicatorEditor' import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
import { DeepVoidBackground } from '../components/DeepVoidBackground'
const API_BASE = import.meta.env.VITE_API_BASE || '' const API_BASE = import.meta.env.VITE_API_BASE || ''
@@ -61,6 +64,7 @@ export function StrategyStudioPage() {
riskControl: false, riskControl: false,
promptSections: false, promptSections: false,
customPrompt: false, customPrompt: false,
publishSettings: false,
}) })
// Right panel states // Right panel states
@@ -147,6 +151,45 @@ export function StrategyStudioPage() {
fetchAiModels() fetchAiModels()
}, [fetchStrategies, 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 // Create new strategy
const handleCreateStrategy = async () => { const handleCreateStrategy = async () => {
if (!token) return if (!token) return
@@ -181,6 +224,8 @@ export function StrategyStudioPage() {
description: '', description: '',
is_active: false, is_active: false,
is_default: false, is_default: false,
is_public: false,
config_visible: true,
config: defaultConfig, config: defaultConfig,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@@ -331,6 +376,11 @@ export function StrategyStudioPage() {
if (!token || !selectedStrategy || !editingConfig) return if (!token || !selectedStrategy || !editingConfig) return
setIsSaving(true) setIsSaving(true)
try { try {
// Always sync the config language with the current interface language
const configWithLanguage = {
...editingConfig,
language: language as 'zh' | 'en',
}
const response = await fetch( const response = await fetch(
`${API_BASE}/api/strategies/${selectedStrategy.id}`, `${API_BASE}/api/strategies/${selectedStrategy.id}`,
{ {
@@ -342,12 +392,15 @@ export function StrategyStudioPage() {
body: JSON.stringify({ body: JSON.stringify({
name: selectedStrategy.name, name: selectedStrategy.name,
description: selectedStrategy.description, 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') if (!response.ok) throw new Error('Failed to save strategy')
setHasChanges(false) setHasChanges(false)
notify.success(language === 'zh' ? '策略已保存' : 'Strategy saved')
await fetchStrategies() await fetchStrategies()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error') setError(err instanceof Error ? err.message : 'Unknown error')
@@ -462,6 +515,7 @@ export function StrategyStudioPage() {
duration: { zh: '耗时', en: 'Duration' }, duration: { zh: '耗时', en: 'Duration' },
noModel: { zh: '请先配置 AI 模型', en: 'Please configure AI model first' }, noModel: { zh: '请先配置 AI 模型', en: 'Please configure AI model first' },
testNote: { zh: '使用真实 AI 模型测试,不执行交易', en: 'Test with real AI, no trading' }, testNote: { zh: '使用真实 AI 模型测试,不执行交易', en: 'Test with real AI, no trading' },
publishSettings: { zh: '发布设置', en: 'Publish' },
} }
return translations[key]?.[language] || key return translations[key]?.[language] || key
} }
@@ -557,24 +611,48 @@ export function StrategyStudioPage() {
</div> </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 ( 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 */} {/* 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 justify-between">
<div className="flex items-center gap-3"> <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" /> <Sparkles className="w-5 h-5 text-black" />
</div> </div>
<div> <div>
<h1 className="text-lg font-bold" style={{ color: '#EAECEF' }}>{t('strategyStudio')}</h1> <h1 className="text-lg font-bold text-nofx-text">{t('strategyStudio')}</h1>
<p className="text-xs" style={{ color: '#848E9C' }}>{t('subtitle')}</p> <p className="text-xs text-nofx-text-muted">{t('subtitle')}</p>
</div> </div>
</div> </div>
{error && ( {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} {error}
<button onClick={() => setError(null)} className="hover:underline">×</button> <button onClick={() => setError(null)} className="hover:underline">×</button>
</div> </div>
@@ -585,13 +663,13 @@ export function StrategyStudioPage() {
{/* Main Content - Three Columns */} {/* Main Content - Three Columns */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Left Column - Strategy List */} {/* 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="p-2">
<div className="flex items-center justify-between mb-2 px-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"> <div className="flex items-center gap-1">
{/* Import button with hidden file input */} {/* 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" /> <Upload className="w-4 h-4" />
<input <input
type="file" type="file"
@@ -602,8 +680,7 @@ export function StrategyStudioPage() {
</label> </label>
<button <button
onClick={handleCreateStrategy} onClick={handleCreateStrategy}
className="p-1 rounded hover:bg-white/10 transition-colors" className="p-1 rounded hover:bg-white/10 transition-colors text-nofx-gold"
style={{ color: '#F0B90B' }}
title={language === 'zh' ? '新建策略' : 'New Strategy'} title={language === 'zh' ? '新建策略' : 'New Strategy'}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -621,54 +698,58 @@ export function StrategyStudioPage() {
setPromptPreview(null) setPromptPreview(null)
setAiTestResult(null) setAiTestResult(null)
}} }}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${ className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
selectedStrategy?.id === strategy.id ? 'ring-1 ring-yellow-500/50' : 'hover:bg-white/5' ? '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'
style={{ }`}
background: selectedStrategy?.id === strategy.id ? 'rgba(240, 185, 11, 0.1)' : 'transparent',
}}
> >
<div className="flex items-center justify-between"> <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"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }} onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
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'} title={language === 'zh' ? '导出' : 'Export'}
> >
<Download className="w-3 h-3" style={{ color: '#848E9C' }} /> <Download className="w-3 h-3" />
</button> </button>
{!strategy.is_default && ( {!strategy.is_default && (
<> <>
<button <button
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }} 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'} title={language === 'zh' ? '复制' : 'Duplicate'}
> >
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} /> <Copy className="w-3 h-3" />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }} 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'} title={language === 'zh' ? '删除' : 'Delete'}
> >
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} /> <Trash2 className="w-3 h-3" />
</button> </button>
</> </>
)} )}
</div> </div>
</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 && ( {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')} {t('active')}
</span> </span>
)} )}
{strategy.is_default && ( {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')} {t('default')}
</span> </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>
</div> </div>
))} ))}
@@ -677,7 +758,7 @@ export function StrategyStudioPage() {
</div> </div>
{/* Middle Column - Config Editor */} {/* 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 ? ( {selectedStrategy && editingConfig ? (
<div className="p-4"> <div className="p-4">
{/* Strategy Name & Actions */} {/* Strategy Name & Actions */}
@@ -691,19 +772,17 @@ export function StrategyStudioPage() {
setHasChanges(true) setHasChanges(true)
}} }}
disabled={selectedStrategy.is_default} disabled={selectedStrategy.is_default}
className="text-lg font-bold bg-transparent border-none outline-none w-full" className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
style={{ color: '#EAECEF' }}
/> />
{hasChanges && ( {hasChanges && (
<span className="text-xs" style={{ color: '#F0B90B' }}> </span> <span className="text-xs text-nofx-gold"> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{!selectedStrategy.is_active && ( {!selectedStrategy.is_active && (
<button <button
onClick={() => handleActivateStrategy(selectedStrategy.id)} onClick={() => handleActivateStrategy(selectedStrategy.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors" 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"
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)', color: '#0ECB81' }}
> >
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
{t('activate')} {t('activate')}
@@ -713,11 +792,8 @@ export function StrategyStudioPage() {
<button <button
onClick={handleSaveStrategy} onClick={handleSaveStrategy}
disabled={isSaving || !hasChanges} 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" className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
style={{ ${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
background: hasChanges ? '#F0B90B' : '#2B3139',
color: hasChanges ? '#0B0E11' : '#848E9C',
}}
> >
<Save className="w-3 h-3" /> <Save className="w-3 h-3" />
{isSaving ? t('saving') : t('save')} {isSaving ? t('saving') : t('save')}
@@ -731,8 +807,7 @@ export function StrategyStudioPage() {
{configSections.map(({ key, icon: Icon, color, title, content }) => ( {configSections.map(({ key, icon: Icon, color, title, content }) => (
<div <div
key={key} key={key}
className="rounded-lg overflow-hidden" className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
> >
<button <button
onClick={() => toggleSection(key)} onClick={() => toggleSection(key)}
@@ -740,12 +815,12 @@ export function StrategyStudioPage() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="w-4 h-4" style={{ color }} /> <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> </div>
{expandedSections[key] ? ( {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> </button>
{expandedSections[key] && ( {expandedSections[key] && (
@@ -760,8 +835,8 @@ export function StrategyStudioPage() {
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center"> <div className="text-center">
<Activity className="w-12 h-12 mx-auto mb-2 opacity-30" style={{ color: '#848E9C' }} /> <Activity className="w-12 h-12 mx-auto mb-2 opacity-30 text-nofx-text-muted" />
<p className="text-sm" style={{ color: '#848E9C' }}> <p className="text-sm text-nofx-text-muted">
{language === 'zh' ? '选择或创建策略' : 'Select or create a strategy'} {language === 'zh' ? '选择或创建策略' : 'Select or create a strategy'}
</p> </p>
</div> </div>
@@ -772,29 +847,19 @@ export function StrategyStudioPage() {
{/* Right Column - Prompt Preview & AI Test */} {/* Right Column - Prompt Preview & AI Test */}
<div className="w-[420px] flex-shrink-0 flex flex-col overflow-hidden"> <div className="w-[420px] flex-shrink-0 flex flex-col overflow-hidden">
{/* Tabs */} {/* 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 <button
onClick={() => setActiveRightTab('prompt')} onClick={() => setActiveRightTab('prompt')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${ 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'
activeRightTab === 'prompt' ? 'border-b-2' : 'opacity-60 hover:opacity-100' }`}
}`}
style={{
borderColor: activeRightTab === 'prompt' ? '#a855f7' : 'transparent',
color: activeRightTab === 'prompt' ? '#a855f7' : '#848E9C',
}}
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
{t('promptPreview')} {t('promptPreview')}
</button> </button>
<button <button
onClick={() => setActiveRightTab('test')} onClick={() => setActiveRightTab('test')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${ 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'
activeRightTab === 'test' ? 'border-b-2' : 'opacity-60 hover:opacity-100' }`}
}`}
style={{
borderColor: activeRightTab === 'test' ? '#22c55e' : 'transparent',
color: activeRightTab === 'test' ? '#22c55e' : '#848E9C',
}}
> >
<Play className="w-4 h-4" /> <Play className="w-4 h-4" />
{t('aiTestRun')} {t('aiTestRun')}
@@ -811,8 +876,7 @@ export function StrategyStudioPage() {
<select <select
value={selectedVariant} value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)} onChange={(e) => setSelectedVariant(e.target.value)}
className="px-2 py-1.5 rounded text-xs" 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"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
> >
<option value="balanced">{t('balanced')}</option> <option value="balanced">{t('balanced')}</option>
<option value="aggressive">{t('aggressive')}</option> <option value="aggressive">{t('aggressive')}</option>
@@ -821,8 +885,7 @@ export function StrategyStudioPage() {
<button <button
onClick={fetchPromptPreview} onClick={fetchPromptPreview}
disabled={isLoadingPrompt || !editingConfig} 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" 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"
style={{ background: '#a855f7', color: '#fff' }}
> >
{isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />} {isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{promptPreview ? t('refreshPrompt') : t('loadPrompt')} {promptPreview ? t('refreshPrompt') : t('loadPrompt')}
@@ -832,16 +895,16 @@ export function StrategyStudioPage() {
{promptPreview ? ( {promptPreview ? (
<> <>
{/* Config Summary */} {/* 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"> <div className="flex items-center gap-1.5 mb-2">
<Code className="w-3 h-3" style={{ color: '#a855f7' }} /> <Code className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium" style={{ color: '#a855f7' }}>Config</span> <span className="text-xs font-medium text-purple-500">Config</span>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs"> <div className="grid grid-cols-3 gap-2 text-xs">
{Object.entries(promptPreview.config_summary || {}).map(([key, value]) => ( {Object.entries(promptPreview.config_summary || {}).map(([key, value]) => (
<div key={key}> <div key={key}>
<div style={{ color: '#848E9C' }}>{key.replace(/_/g, ' ')}</div> <div className="text-nofx-text-muted">{key.replace(/_/g, ' ')}</div>
<div style={{ color: '#EAECEF' }}>{String(value)}</div> <div className="text-nofx-text">{String(value)}</div>
</div> </div>
))} ))}
</div> </div>
@@ -851,23 +914,23 @@ export function StrategyStudioPage() {
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<FileText className="w-3 h-3" style={{ color: '#a855f7' }} /> <FileText className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('systemPrompt')}</span> <span className="text-xs font-medium text-nofx-text">{t('systemPrompt')}</span>
</div> </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 {promptPreview.system_prompt.length.toLocaleString()} chars
</span> </span>
</div> </div>
<pre <pre
className="p-2 rounded-lg text-[11px] font-mono overflow-auto" className="p-2 rounded-lg text-[11px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '400px' }} style={{ maxHeight: '400px' }}
> >
{promptPreview.system_prompt} {promptPreview.system_prompt}
</pre> </pre>
</div> </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" /> <Eye className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">{language === 'zh' ? '点击生成 Prompt 预览' : 'Click to generate prompt preview'}</p> <p className="text-sm">{language === 'zh' ? '点击生成 Prompt 预览' : 'Click to generate prompt preview'}</p>
</div> </div>
@@ -879,15 +942,14 @@ export function StrategyStudioPage() {
{/* Controls */} {/* Controls */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Bot className="w-4 h-4" style={{ color: '#22c55e' }} /> <Bot className="w-4 h-4 text-green-500" />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('selectModel')}</span> <span className="text-xs font-medium text-nofx-text">{t('selectModel')}</span>
</div> </div>
{aiModels.length > 0 ? ( {aiModels.length > 0 ? (
<select <select
value={selectedModelId} value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)} onChange={(e) => setSelectedModelId(e.target.value)}
className="w-full px-3 py-2 rounded-lg text-sm" className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
> >
{aiModels.map((model) => ( {aiModels.map((model) => (
<option key={model.id} value={model.id}> <option key={model.id} value={model.id}>
@@ -896,7 +958,7 @@ export function StrategyStudioPage() {
))} ))}
</select> </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')} {t('noModel')}
</div> </div>
)} )}
@@ -905,8 +967,7 @@ export function StrategyStudioPage() {
<select <select
value={selectedVariant} value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)} onChange={(e) => setSelectedVariant(e.target.value)}
className="px-2 py-1.5 rounded text-xs" className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
> >
<option value="balanced">{t('balanced')}</option> <option value="balanced">{t('balanced')}</option>
<option value="aggressive">{t('aggressive')}</option> <option value="aggressive">{t('aggressive')}</option>
@@ -915,12 +976,7 @@ export function StrategyStudioPage() {
<button <button
onClick={runAiTest} onClick={runAiTest}
disabled={isRunningAiTest || !editingConfig || !selectedModelId} 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" 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"
style={{
background: 'linear-gradient(135deg, #22c55e 0%, #4ade80 100%)',
color: '#fff',
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)',
}}
> >
{isRunningAiTest ? ( {isRunningAiTest ? (
<> <>
@@ -935,22 +991,22 @@ export function StrategyStudioPage() {
)} )}
</button> </button>
</div> </div>
<p className="text-[10px]" style={{ color: '#848E9C' }}>{t('testNote')}</p> <p className="text-[10px] text-nofx-text-muted">{t('testNote')}</p>
</div> </div>
{/* Test Results */} {/* Test Results */}
{aiTestResult ? ( {aiTestResult ? (
<div className="space-y-3"> <div className="space-y-3">
{aiTestResult.error ? ( {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)' }}> <div className="p-3 rounded-lg bg-nofx-danger/10 border border-nofx-danger/30">
<p className="text-sm" style={{ color: '#F6465D' }}>{aiTestResult.error}</p> <p className="text-sm text-nofx-danger">{aiTestResult.error}</p>
</div> </div>
) : ( ) : (
<> <>
{aiTestResult.duration_ms && ( {aiTestResult.duration_ms && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-3 h-3" style={{ color: '#848E9C' }} /> <Clock className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs" style={{ color: '#848E9C' }}> <span className="text-xs text-nofx-text-muted">
{t('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s {t('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s
</span> </span>
</div> </div>
@@ -960,12 +1016,12 @@ export function StrategyStudioPage() {
{aiTestResult.user_prompt && ( {aiTestResult.user_prompt && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Terminal className="w-3 h-3" style={{ color: '#60a5fa' }} /> <Terminal className="w-3 h-3 text-blue-400" />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('userPrompt')} (Input)</span> <span className="text-xs font-medium text-nofx-text">{t('userPrompt')} (Input)</span>
</div> </div>
<pre <pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto" className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '200px' }} style={{ maxHeight: '200px' }}
> >
{aiTestResult.user_prompt} {aiTestResult.user_prompt}
</pre> </pre>
@@ -976,12 +1032,12 @@ export function StrategyStudioPage() {
{aiTestResult.reasoning && ( {aiTestResult.reasoning && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Sparkles className="w-3 h-3" style={{ color: '#F0B90B' }} /> <Sparkles className="w-3 h-3 text-nofx-gold" />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('reasoning')}</span> <span className="text-xs font-medium text-nofx-text">{t('reasoning')}</span>
</div> </div>
<pre <pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap" 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={{ background: '#0B0E11', border: '1px solid rgba(240, 185, 11, 0.3)', color: '#EAECEF', maxHeight: '200px' }} style={{ maxHeight: '200px' }}
> >
{aiTestResult.reasoning} {aiTestResult.reasoning}
</pre> </pre>
@@ -992,12 +1048,12 @@ export function StrategyStudioPage() {
{aiTestResult.decisions && aiTestResult.decisions.length > 0 && ( {aiTestResult.decisions && aiTestResult.decisions.length > 0 && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Activity className="w-3 h-3" style={{ color: '#22c55e' }} /> <Activity className="w-3 h-3 text-green-500" />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('decisions')}</span> <span className="text-xs font-medium text-nofx-text">{t('decisions')}</span>
</div> </div>
<pre <pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto" className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
style={{ background: '#0B0E11', border: '1px solid rgba(34, 197, 94, 0.3)', color: '#EAECEF', maxHeight: '200px' }} style={{ maxHeight: '200px' }}
> >
{JSON.stringify(aiTestResult.decisions, null, 2)} {JSON.stringify(aiTestResult.decisions, null, 2)}
</pre> </pre>
@@ -1008,12 +1064,12 @@ export function StrategyStudioPage() {
{aiTestResult.ai_response && ( {aiTestResult.ai_response && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<FileText className="w-3 h-3" style={{ color: '#848E9C' }} /> <FileText className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('aiOutput')} (Raw)</span> <span className="text-xs font-medium text-nofx-text">{t('aiOutput')} (Raw)</span>
</div> </div>
<pre <pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap" 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={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '300px' }} style={{ maxHeight: '300px' }}
> >
{aiTestResult.ai_response} {aiTestResult.ai_response}
</pre> </pre>
@@ -1023,7 +1079,7 @@ export function StrategyStudioPage() {
)} )}
</div> </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" /> <Play className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">{language === 'zh' ? '点击运行 AI 测试' : 'Click to run AI test'}</p> <p className="text-sm">{language === 'zh' ? '点击运行 AI 测试' : 'Click to run AI test'}</p>
</div> </div>
@@ -1033,7 +1089,7 @@ export function StrategyStudioPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </DeepVoidBackground>
) )
} }

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

View File

@@ -99,7 +99,7 @@ export interface TraderInfo {
strategy_id?: string strategy_id?: string
strategy_name?: string strategy_name?: string
custom_prompt?: string custom_prompt?: string
use_coin_pool?: boolean use_ai500?: boolean
use_oi_top?: boolean use_oi_top?: boolean
system_prompt_template?: string system_prompt_template?: string
} }
@@ -172,7 +172,7 @@ export interface CreateTraderRequest {
custom_prompt?: string custom_prompt?: string
override_base_prompt?: boolean override_base_prompt?: boolean
system_prompt_template?: string system_prompt_template?: string
use_coin_pool?: boolean use_ai500?: boolean
use_oi_top?: boolean use_oi_top?: boolean
} }
@@ -249,7 +249,7 @@ export interface TraderConfigData {
custom_prompt?: string custom_prompt?: string
override_base_prompt?: boolean override_base_prompt?: boolean
system_prompt_template?: string system_prompt_template?: string
use_coin_pool?: boolean use_ai500?: boolean
use_oi_top?: boolean use_oi_top?: boolean
} }
@@ -428,11 +428,32 @@ export interface Strategy {
description: string; description: string;
is_active: boolean; is_active: boolean;
is_default: boolean; is_default: boolean;
is_public: boolean; // 是否在策略市场公开
config_visible: boolean; // 配置参数是否公开可见
config: StrategyConfig; config: StrategyConfig;
created_at: string; created_at: string;
updated_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 { export interface PromptSectionsConfig {
role_definition?: string; role_definition?: string;
trading_frequency?: string; trading_frequency?: string;
@@ -441,6 +462,9 @@ export interface PromptSectionsConfig {
} }
export interface StrategyConfig { 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; coin_source: CoinSourceConfig;
indicators: IndicatorConfig; indicators: IndicatorConfig;
custom_prompt?: string; custom_prompt?: string;
@@ -449,14 +473,14 @@ export interface StrategyConfig {
} }
export interface CoinSourceConfig { export interface CoinSourceConfig {
source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed'; source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
static_coins?: string[]; static_coins?: string[];
use_coin_pool: boolean; excluded_coins?: string[]; // 排除的币种列表
coin_pool_limit?: number; use_ai500: boolean;
coin_pool_api_url?: string; // AI500 币种池 API URL ai500_limit?: number;
use_oi_top: boolean; use_oi_top: boolean;
oi_top_limit?: number; 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 { export interface IndicatorConfig {
@@ -477,16 +501,30 @@ export interface IndicatorConfig {
atr_periods?: number[]; atr_periods?: number[];
boll_periods?: number[]; boll_periods?: number[];
external_data_sources?: ExternalDataSource[]; external_data_sources?: ExternalDataSource[];
// ========== NofxOS 数据源统一配置 ==========
// Unified NofxOS API Key - used for all NofxOS data sources
nofxos_api_key?: string;
// 量化数据源(资金流向、持仓变化、价格变化) // 量化数据源(资金流向、持仓变化、价格变化)
enable_quant_data?: boolean; enable_quant_data?: boolean;
quant_data_api_url?: string;
enable_quant_oi?: boolean; enable_quant_oi?: boolean;
enable_quant_netflow?: boolean; enable_quant_netflow?: boolean;
// OI 排行数据(市场持仓量增减排行) // OI 排行数据(市场持仓量增减排行)
enable_oi_ranking?: boolean; enable_oi_ranking?: boolean;
oi_ranking_api_url?: string;
oi_ranking_duration?: string; // "1h", "4h", "24h" oi_ranking_duration?: string; // "1h", "4h", "24h"
oi_ranking_limit?: number; 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 { export interface KlineConfig {