refactor: restructure project directories for better modularity

- Delete llm/ dead code (3 files, zero references)
- Split mcp/ into sub-packages: mcp/provider/ (8 providers) and
  mcp/payment/ (4 payment clients) with registry pattern
- Export Client internal fields and ClientHooks interface for
  sub-package access
- Split api/server.go (3892 lines) into 8 domain-specific handler files
- Split trader/auto_trader.go (2296 lines) into 5 focused files
- Reorganize web/src/components/ flat files into auth/, charts/,
  trader/, common/, modals/, backtest/ subdirectories
- Update all consumer imports to use registry-based provider creation
This commit is contained in:
tinkle-community
2026-03-11 23:58:13 +08:00
parent 6a30e11ee5
commit 8e294a5eed
103 changed files with 6391 additions and 8984 deletions
+33 -1771
View File
File diff suppressed because it is too large Load Diff
+527
View File
@@ -0,0 +1,527 @@
package trader
import (
"fmt"
"math"
"nofx/experience"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"time"
)
// saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision)
func (at *AutoTrader) saveEquitySnapshot(ctx *kernel.Context) {
if at.store == nil || ctx == nil {
return
}
snapshot := &store.EquitySnapshot{
TraderID: at.id,
Timestamp: time.Now().UTC(),
TotalEquity: ctx.Account.TotalEquity,
Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
UnrealizedPnL: ctx.Account.UnrealizedPnL,
PositionCount: ctx.Account.PositionCount,
MarginUsedPct: ctx.Account.MarginUsedPct,
}
if err := at.store.Equity().Save(snapshot); err != nil {
logger.Infof("⚠️ Failed to save equity snapshot: %v", err)
}
}
// saveDecision saves AI decision log to database (only records AI input/output, for debugging)
func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error {
if at.store == nil {
return nil
}
at.cycleNumber++
record.CycleNumber = at.cycleNumber
record.TraderID = at.id
if record.Timestamp.IsZero() {
record.Timestamp = time.Now().UTC()
}
if err := at.store.Decision().LogDecision(record); err != nil {
logger.Infof("⚠️ Failed to save decision record: %v", err)
return err
}
logger.Infof("📝 Decision record saved: trader=%s, cycle=%d", at.id, at.cycleNumber)
return nil
}
// GetStatus gets system status (for API)
func (at *AutoTrader) GetStatus() map[string]interface{} {
aiProvider := "DeepSeek"
if at.config.UseQwen {
aiProvider = "Qwen"
}
at.isRunningMutex.RLock()
isRunning := at.isRunning
at.isRunningMutex.RUnlock()
result := map[string]interface{}{
"trader_id": at.id,
"trader_name": at.name,
"ai_model": at.aiModel,
"exchange": at.exchange,
"is_running": isRunning,
"start_time": at.startTime.Format(time.RFC3339),
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
"call_count": at.callCount,
"initial_balance": at.initialBalance,
"scan_interval": at.config.ScanInterval.String(),
"stop_until": at.stopUntil.Format(time.RFC3339),
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
"ai_provider": aiProvider,
}
// Add strategy info
if at.config.StrategyConfig != nil {
result["strategy_type"] = at.config.StrategyConfig.StrategyType
if at.config.StrategyConfig.GridConfig != nil {
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
}
}
return result
}
// GetAccountInfo gets account information (for API)
func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
balance, err := at.trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
// Get account fields
totalWalletBalance := 0.0
totalUnrealizedProfit := 0.0
availableBalance := 0.0
totalEquity := 0.0
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
totalWalletBalance = wallet
}
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
totalUnrealizedProfit = unrealized
}
if avail, ok := balance["availableBalance"].(float64); ok {
availableBalance = avail
}
// Use totalEquity directly if provided by trader (more accurate)
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
totalEquity = eq
} else {
// Fallback: Total Equity = Wallet balance + Unrealized profit
totalEquity = totalWalletBalance + totalUnrealizedProfit
}
// Get positions to calculate total margin
positions, err := at.trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
totalMarginUsed := 0.0
totalUnrealizedPnLCalculated := 0.0
for _, pos := range positions {
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
totalUnrealizedPnLCalculated += unrealizedPnl
leverage := 10
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
marginUsed := (quantity * markPrice) / float64(leverage)
totalMarginUsed += marginUsed
}
// Verify unrealized P&L consistency (API value vs calculated from positions)
// Note: Lighter API may return 0 for unrealized PnL, this is a known limitation
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
if diff > 5.0 { // Only warn if difference is significant (> 5 USDT)
logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f",
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
}
totalPnL := totalEquity - at.initialBalance
totalPnLPct := 0.0
if at.initialBalance > 0 {
totalPnLPct = (totalPnL / at.initialBalance) * 100
} else {
logger.Infof("⚠️ Initial Balance abnormal: %.2f, cannot calculate P&L percentage", at.initialBalance)
}
marginUsedPct := 0.0
if totalEquity > 0 {
marginUsedPct = (totalMarginUsed / totalEquity) * 100
}
return map[string]interface{}{
// Core fields
"total_equity": totalEquity, // Account equity = wallet + unrealized
"wallet_balance": totalWalletBalance, // Wallet balance (excluding unrealized P&L)
"unrealized_profit": totalUnrealizedProfit, // Unrealized P&L (official value from exchange API)
"available_balance": availableBalance, // Available balance
// P&L statistics
"total_pnl": totalPnL, // Total P&L = equity - initial
"total_pnl_pct": totalPnLPct, // Total P&L percentage
"initial_balance": at.initialBalance, // Initial balance
"daily_pnl": at.dailyPnL, // Daily P&L
// Position information
"position_count": len(positions), // Position count
"margin_used": totalMarginUsed, // Margin used
"margin_used_pct": marginUsedPct, // Margin usage rate
}, nil
}
// GetPositions gets position list (for API)
func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
positions, err := at.trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
leverage := 10
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
// Calculate margin used
marginUsed := (quantity * markPrice) / float64(leverage)
// Calculate P&L percentage (based on margin)
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
result = append(result, map[string]interface{}{
"symbol": symbol,
"side": side,
"entry_price": entryPrice,
"mark_price": markPrice,
"quantity": quantity,
"leverage": leverage,
"unrealized_pnl": unrealizedPnl,
"unrealized_pnl_pct": pnlPct,
"liquidation_price": liquidationPrice,
"margin_used": marginUsed,
})
}
return result, nil
}
// recordAndConfirmOrder polls order status for actual fill data and records position
// action: open_long, open_short, close_long, close_short
// entryPrice: entry price when closing (0 when opening)
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
if at.store == nil {
return
}
// Get order ID (supports multiple types)
var orderID string
switch v := orderResult["orderId"].(type) {
case int64:
orderID = fmt.Sprintf("%d", v)
case float64:
orderID = fmt.Sprintf("%.0f", v)
case string:
orderID = v
default:
orderID = fmt.Sprintf("%v", v)
}
if orderID == "" || orderID == "0" {
logger.Infof(" ⚠️ Order ID is empty, skipping record")
return
}
// Determine positionSide
var positionSide string
switch action {
case "open_long", "close_long":
positionSide = "LONG"
case "open_short", "close_short":
positionSide = "SHORT"
}
var actualPrice = price
var actualQty = quantity
var fee float64
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// This ensures accurate data from GetTrades API and avoids duplicate records
switch at.exchange {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return
}
// For exchanges without OrderSync (e.g., Binance): record immediately and poll for fill data
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to record order: %v", err)
} else {
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
}
// Wait for order to be filled and get actual fill data
time.Sleep(500 * time.Millisecond)
for i := 0; i < 5; i++ {
status, err := at.trader.GetOrderStatus(symbol, orderID)
if err == nil {
statusStr, _ := status["status"].(string)
if statusStr == "FILLED" {
// Get actual fill price
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
actualPrice = avgPrice
}
// Get actual executed quantity
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
actualQty = execQty
}
// Get commission/fee
if commission, ok := status["commission"].(float64); ok {
fee = commission
}
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
// Update order status to FILLED
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
}
// Record fill details
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
break
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
// Update order status
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
}
return
}
}
time.Sleep(500 * time.Millisecond)
}
// Normalize symbol for position record consistency
normalizedSymbolForPosition := market.Normalize(symbol)
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
orderID, action, actualPrice, actualQty, fee)
// Record position change with actual fill data (use normalized symbol)
at.recordPositionChange(orderID, normalizedSymbolForPosition, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
// Send anonymous trade statistics for experience improvement (async, non-blocking)
// This helps us understand overall product usage across all deployments
experience.TrackTrade(experience.TradeEvent{
Exchange: at.exchange,
TradeType: action,
Symbol: symbol,
AmountUSD: actualPrice * actualQty,
Leverage: leverage,
UserID: at.userID,
TraderID: at.id,
})
}
// recordPositionChange records position change (create record on open, update record on close)
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) {
if at.store == nil {
return
}
switch action {
case "open_long", "open_short":
// Open position: create new position record
nowMs := time.Now().UTC().UnixMilli()
pos := &store.TraderPosition{
TraderID: at.id,
ExchangeID: at.exchangeID, // Exchange account UUID
ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc
Symbol: symbol,
Side: side, // LONG or SHORT
Quantity: quantity,
EntryPrice: price,
EntryOrderID: orderID,
EntryTime: nowMs,
Leverage: leverage,
Status: "OPEN",
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
if err := at.store.Position().Create(pos); err != nil {
logger.Infof(" ⚠️ Failed to record position: %v", err)
} else {
logger.Infof(" 📊 Position recorded [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
}
case "close_long", "close_short":
// Close position using PositionBuilder for consistent handling
// PositionBuilder will handle both cases:
// 1. If open position exists: close it properly
// 2. If no open position (e.g., table cleared): create a closed position record
posBuilder := store.NewPositionBuilder(at.store.Position())
if err := posBuilder.ProcessTrade(
at.id, at.exchangeID, at.exchange,
symbol, side, action,
quantity, price, fee, 0, // realizedPnL will be calculated
time.Now().UTC().UnixMilli(), orderID,
); err != nil {
logger.Infof(" ⚠️ Failed to process close position: %v", err)
} else {
logger.Infof(" ✅ Position closed [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
}
}
}
// createOrderRecord creates an order record struct from order details
func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide string, quantity, price float64, leverage int) *store.TraderOrder {
// Determine order type (market for auto trader)
orderType := "MARKET"
// Determine side (BUY/SELL)
var side string
switch action {
case "open_long", "close_short":
side = "BUY"
case "open_short", "close_long":
side = "SELL"
}
// Use action as orderAction directly (keep lowercase format)
orderAction := action
// Determine if it's a reduce only order
reduceOnly := (action == "close_long" || action == "close_short")
// Normalize symbol for consistency
normalizedSymbol := market.Normalize(symbol)
return &store.TraderOrder{
TraderID: at.id,
ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
ExchangeOrderID: orderID,
Symbol: normalizedSymbol,
Side: side,
PositionSide: positionSide,
Type: orderType,
TimeInForce: "GTC",
Quantity: quantity,
Price: price,
Status: "NEW",
FilledQuantity: 0,
AvgFillPrice: 0,
Commission: 0,
CommissionAsset: "USDT",
Leverage: leverage,
ReduceOnly: reduceOnly,
ClosePosition: reduceOnly,
OrderAction: orderAction,
CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now().UTC().UnixMilli(),
}
}
// recordOrderFill records order fill/trade details
func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symbol, action string, price, quantity, fee float64) {
if at.store == nil {
return
}
// Determine side (BUY/SELL)
var side string
switch action {
case "open_long", "close_short":
side = "BUY"
case "open_short", "close_long":
side = "SELL"
}
// Generate a simple trade ID (exchange doesn't always provide one)
tradeID := fmt.Sprintf("%s-%d", exchangeOrderID, time.Now().UnixNano())
// Normalize symbol for consistency
normalizedSymbol := market.Normalize(symbol)
fill := &store.TraderFill{
TraderID: at.id,
ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID,
Symbol: normalizedSymbol,
Side: side,
Price: price,
Quantity: quantity,
QuoteQuantity: price * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now().UTC().UnixMilli(),
}
// Calculate realized PnL for close orders
if action == "close_long" || action == "close_short" {
// Try to get the entry price from the open position
var positionSide string
if action == "close_long" {
positionSide = "LONG"
} else {
positionSide = "SHORT"
}
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, positionSide); err == nil && openPos != nil {
if positionSide == "LONG" {
fill.RealizedPnL = (price - openPos.EntryPrice) * quantity
} else {
fill.RealizedPnL = (openPos.EntryPrice - price) * quantity
}
}
}
if err := at.store.Order().CreateFill(fill); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" 📋 Fill recorded: %.4f @ %.6f, fee: %.4f", quantity, price, fee)
}
}
// GetOpenOrders returns open orders (pending SL/TP) from exchange
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return at.trader.GetOpenOrders(symbol)
}
+560
View File
@@ -0,0 +1,560 @@
package trader
import (
"encoding/json"
"fmt"
"nofx/kernel"
"nofx/logger"
"nofx/store"
"strings"
"time"
)
// runCycle runs one trading cycle (using AI full decision-making)
func (at *AutoTrader) runCycle() error {
at.callCount++
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
logger.Info(strings.Repeat("=", 70))
// 0. Check if trader is stopped (early exit to prevent trades after Stop() is called)
at.isRunningMutex.RLock()
running := at.isRunning
at.isRunningMutex.RUnlock()
if !running {
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
return nil
}
// Create decision record
record := &store.DecisionRecord{
ExecutionLog: []string{},
Success: true,
}
// 1. Check if trading needs to be stopped
if time.Now().Before(at.stopUntil) {
remaining := at.stopUntil.Sub(time.Now())
logger.Infof("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes())
record.Success = false
record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes())
at.saveDecision(record)
return nil
}
// 2. Reset daily P&L (reset every day)
if time.Since(at.lastResetTime) > 24*time.Hour {
at.dailyPnL = 0
at.lastResetTime = time.Now()
logger.Info("📅 Daily P&L reset")
}
// 4. Collect trading context
ctx, err := at.buildTradingContext()
if err != nil {
record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err)
at.saveDecision(record)
return fmt.Errorf("failed to build trading context: %w", err)
}
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
at.saveEquitySnapshot(ctx)
// 如果没有候选币种,记录但不报错
if len(ctx.CandidateCoins) == 0 {
logger.Infof("️ No candidate coins available, skipping this cycle")
record.Success = true // 不是错误,只是没有候选币
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
record.AccountState = store.AccountSnapshot{
TotalBalance: ctx.Account.TotalEquity,
AvailableBalance: ctx.Account.AvailableBalance,
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
PositionCount: ctx.Account.PositionCount,
InitialBalance: at.initialBalance,
}
at.saveDecision(record)
return nil
}
logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
}
logger.Infof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d",
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
// 5. Use strategy engine to call AI for decision
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
logger.Infof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000)
record.ExecutionLog = append(record.ExecutionLog,
fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs))
}
// Save chain of thought, decisions, and input prompt even if there's an error (for debugging)
if aiDecision != nil {
record.SystemPrompt = aiDecision.SystemPrompt // Save system prompt
record.InputPrompt = aiDecision.UserPrompt
record.CoTTrace = aiDecision.CoTTrace
record.RawResponse = aiDecision.RawResponse // Save raw AI response for debugging
if len(aiDecision.Decisions) > 0 {
decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ")
record.DecisionJSON = string(decisionJSON)
}
}
if err != nil {
record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
// Print system prompt and AI chain of thought (output even with errors for debugging)
if aiDecision != nil {
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
logger.Infof("📋 System prompt (error case)")
logger.Info(strings.Repeat("=", 70))
logger.Info(aiDecision.SystemPrompt)
logger.Info(strings.Repeat("=", 70))
if aiDecision.CoTTrace != "" {
logger.Info("\n" + strings.Repeat("-", 70) + "\n")
logger.Info("💭 AI chain of thought analysis (error case):")
logger.Info(strings.Repeat("-", 70))
logger.Info(aiDecision.CoTTrace)
logger.Info(strings.Repeat("-", 70))
}
}
at.saveDecision(record)
return fmt.Errorf("failed to get AI decision: %w", err)
}
// // 5. Print system prompt
// logger.Infof("\n" + strings.Repeat("=", 70))
// logger.Infof("📋 System prompt [template: %s]", at.systemPromptTemplate)
// logger.Info(strings.Repeat("=", 70))
// logger.Info(decision.SystemPrompt)
// logger.Infof(strings.Repeat("=", 70) + "\n")
// 6. Print AI chain of thought
// logger.Infof("\n" + strings.Repeat("-", 70))
// logger.Info("💭 AI chain of thought analysis:")
// logger.Info(strings.Repeat("-", 70))
// logger.Info(decision.CoTTrace)
// logger.Infof(strings.Repeat("-", 70) + "\n")
// 7. Print AI decisions
// logger.Infof("📋 AI decision list (%d items):\n", len(kernel.Decisions))
// for i, d := range kernel.Decisions {
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
// if d.Action == "open_long" || d.Action == "open_short" {
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f",
// d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit)
// }
// }
logger.Info()
logger.Info(strings.Repeat("-", 70))
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
logger.Info(strings.Repeat("-", 70))
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
for i, d := range sortedDecisions {
logger.Infof(" [%d] %s %s", i+1, d.Symbol, d.Action)
}
logger.Info()
// Check if trader is stopped before executing any decisions (prevent trades after Stop())
at.isRunningMutex.RLock()
running = at.isRunning
at.isRunningMutex.RUnlock()
if !running {
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
return nil
}
// Execute decisions and record results
for _, d := range sortedDecisions {
// Check if trader is stopped before each decision (allow immediate stop during execution)
at.isRunningMutex.RLock()
running = at.isRunning
at.isRunningMutex.RUnlock()
if !running {
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
break
}
actionRecord := store.DecisionAction{
Action: d.Action,
Symbol: d.Symbol,
Quantity: 0,
Leverage: d.Leverage,
Price: 0,
StopLoss: d.StopLoss,
TakeProfit: d.TakeProfit,
Confidence: d.Confidence,
Reasoning: d.Reasoning,
Timestamp: time.Now().UTC(),
Success: false,
}
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
logger.Infof("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
actionRecord.Error = err.Error()
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err))
} else {
actionRecord.Success = true
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("✓ %s %s succeeded", d.Symbol, d.Action))
// Brief delay after successful execution
time.Sleep(1 * time.Second)
}
record.Decisions = append(record.Decisions, actionRecord)
}
// 9. Save decision record
if err := at.saveDecision(record); err != nil {
logger.Infof("⚠ Failed to save decision record: %v", err)
}
return nil
}
// buildTradingContext builds trading context
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// 1. Get account information
balance, err := at.trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
// Get account fields
totalWalletBalance := 0.0
totalUnrealizedProfit := 0.0
availableBalance := 0.0
totalEquity := 0.0
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
totalWalletBalance = wallet
}
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
totalUnrealizedProfit = unrealized
}
if avail, ok := balance["availableBalance"].(float64); ok {
availableBalance = avail
}
// Use totalEquity directly if provided by trader (more accurate)
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
totalEquity = eq
} else {
// Fallback: Total Equity = Wallet balance + Unrealized profit
totalEquity = totalWalletBalance + totalUnrealizedProfit
}
// 2. Get position information
positions, err := at.trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positionInfos []kernel.PositionInfo
totalMarginUsed := 0.0
// Current position key set (for cleaning up closed position records)
currentPositionKeys := make(map[string]bool)
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}
// Skip closed positions (quantity = 0), prevent "ghost positions" from being passed to AI
if quantity == 0 {
continue
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
// Calculate margin used (estimated)
leverage := 10 // Default value, should actually be fetched from position info
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
marginUsed := (quantity * markPrice) / float64(leverage)
totalMarginUsed += marginUsed
// Calculate P&L percentage (based on margin, considering leverage)
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
// Get position open time from exchange (preferred) or fallback to local tracking
posKey := symbol + "_" + side
currentPositionKeys[posKey] = true
var updateTime int64
// Priority 1: Get from database (trader_positions table) - most accurate
if at.store != nil {
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
if dbPos.EntryTime > 0 {
updateTime = dbPos.EntryTime
}
}
}
// Priority 2: Get from exchange API (Bybit: createdTime, OKX: createdTime)
if updateTime == 0 {
if createdTime, ok := pos["createdTime"].(int64); ok && createdTime > 0 {
updateTime = createdTime
}
}
// Priority 3: Fallback to local tracking
if updateTime == 0 {
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
}
updateTime = at.positionFirstSeenTime[posKey]
}
// Get peak profit rate for this position
at.peakPnLCacheMutex.RLock()
peakPnlPct := at.peakPnLCache[posKey]
at.peakPnLCacheMutex.RUnlock()
positionInfos = append(positionInfos, kernel.PositionInfo{
Symbol: symbol,
Side: side,
EntryPrice: entryPrice,
MarkPrice: markPrice,
Quantity: quantity,
Leverage: leverage,
UnrealizedPnL: unrealizedPnl,
UnrealizedPnLPct: pnlPct,
PeakPnLPct: peakPnlPct,
LiquidationPrice: liquidationPrice,
MarginUsed: marginUsed,
UpdateTime: updateTime,
})
}
// Clean up closed position records
for key := range at.positionFirstSeenTime {
if !currentPositionKeys[key] {
delete(at.positionFirstSeenTime, key)
}
}
// 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil {
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
} else {
coins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
// Log warning but don't fail - equity snapshot should still be saved
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
} else {
candidateCoins = coins
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
}
}
// 4. Calculate total P&L
totalPnL := totalEquity - at.initialBalance
totalPnLPct := 0.0
if at.initialBalance > 0 {
totalPnLPct = (totalPnL / at.initialBalance) * 100
}
marginUsedPct := 0.0
if totalEquity > 0 {
marginUsedPct = (totalMarginUsed / totalEquity) * 100
}
// 5. Get leverage from strategy config
strategyConfig := at.strategyEngine.GetConfig()
btcEthLeverage := strategyConfig.RiskControl.BTCETHMaxLeverage
altcoinLeverage := strategyConfig.RiskControl.AltcoinMaxLeverage
logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
// 6. Build context
ctx := &kernel.Context{
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
CallCount: at.callCount,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
Account: kernel.AccountInfo{
TotalEquity: totalEquity,
AvailableBalance: availableBalance,
UnrealizedPnL: totalUnrealizedProfit,
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
MarginUsed: totalMarginUsed,
MarginUsedPct: marginUsedPct,
PositionCount: len(positionInfos),
},
Positions: positionInfos,
CandidateCoins: candidateCoins,
}
// 7. Add recent closed trades (if store is available)
if at.store != nil {
// Get recent 10 closed trades for AI context
recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
if err != nil {
logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err)
} else {
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
for _, trade := range recentTrades {
// Convert Unix timestamps to formatted strings for AI readability
entryTimeStr := ""
if trade.EntryTime > 0 {
entryTimeStr = time.Unix(trade.EntryTime, 0).UTC().Format("01-02 15:04 UTC")
}
exitTimeStr := ""
if trade.ExitTime > 0 {
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
}
ctx.RecentOrders = append(ctx.RecentOrders, kernel.RecentOrder{
Symbol: trade.Symbol,
Side: trade.Side,
EntryPrice: trade.EntryPrice,
ExitPrice: trade.ExitPrice,
RealizedPnL: trade.RealizedPnL,
PnLPct: trade.PnLPct,
EntryTime: entryTimeStr,
ExitTime: exitTimeStr,
HoldDuration: trade.HoldDuration,
})
}
}
// Get trading statistics for AI context
stats, err := at.store.Position().GetFullStats(at.id)
if err != nil {
logger.Infof("⚠️ [%s] Failed to get trading stats: %v", at.name, err)
} else if stats == nil {
logger.Infof("⚠️ [%s] GetFullStats returned nil", at.name)
} else if stats.TotalTrades == 0 {
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
} else {
ctx.TradingStats = &kernel.TradingStats{
TotalTrades: stats.TotalTrades,
WinRate: stats.WinRate,
ProfitFactor: stats.ProfitFactor,
SharpeRatio: stats.SharpeRatio,
TotalPnL: stats.TotalPnL,
AvgWin: stats.AvgWin,
AvgLoss: stats.AvgLoss,
MaxDrawdownPct: stats.MaxDrawdownPct,
}
logger.Infof("📈 [%s] Trading stats: %d trades, %.1f%% win rate, PF=%.2f, Sharpe=%.2f, DD=%.1f%%",
at.name, stats.TotalTrades, stats.WinRate, stats.ProfitFactor, stats.SharpeRatio, stats.MaxDrawdownPct)
}
} else {
logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name)
}
// 8. Get quantitative data (if enabled in strategy config)
if strategyConfig.Indicators.EnableQuantData {
// Collect symbols to query (candidate coins + position coins)
symbolsToQuery := make(map[string]bool)
for _, coin := range candidateCoins {
symbolsToQuery[coin.Symbol] = true
}
for _, pos := range positionInfos {
symbolsToQuery[pos.Symbol] = true
}
symbols := make([]string, 0, len(symbolsToQuery))
for sym := range symbolsToQuery {
symbols = append(symbols, sym)
}
logger.Infof("📊 [%s] Fetching quantitative data for %d symbols...", at.name, len(symbols))
ctx.QuantDataMap = at.strategyEngine.FetchQuantDataBatch(symbols)
logger.Infof("📊 [%s] Successfully fetched quantitative data for %d symbols", at.name, len(ctx.QuantDataMap))
}
// 9. Get OI ranking data (market-wide position changes)
if strategyConfig.Indicators.EnableOIRanking {
logger.Infof("📊 [%s] Fetching OI ranking data...", at.name)
ctx.OIRankingData = at.strategyEngine.FetchOIRankingData()
if ctx.OIRankingData != nil {
logger.Infof("📊 [%s] OI ranking data ready: %d top, %d low positions",
at.name, len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
}
}
// 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
}
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
// This avoids position stacking overflow when changing positions
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
if len(decisions) <= 1 {
return decisions
}
// Define priority
getActionPriority := func(action string) int {
switch action {
case "close_long", "close_short":
return 1 // Highest priority: close positions first
case "open_long", "open_short":
return 2 // Second priority: open positions later
case "hold", "wait":
return 3 // Lowest priority: wait
default:
return 999 // Unknown actions at the end
}
}
// Copy decision list
sorted := make([]kernel.Decision, len(decisions))
copy(sorted, decisions)
// Sort by priority
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if getActionPriority(sorted[i].Action) > getActionPriority(sorted[j].Action) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
}
+391
View File
@@ -0,0 +1,391 @@
package trader
import (
"fmt"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"time"
)
// executeDecisionWithRecord executes AI decision and records detailed information
func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
switch decision.Action {
case "open_long":
return at.executeOpenLongWithRecord(decision, actionRecord)
case "open_short":
return at.executeOpenShortWithRecord(decision, actionRecord)
case "close_long":
return at.executeCloseLongWithRecord(decision, actionRecord)
case "close_short":
return at.executeCloseShortWithRecord(decision, actionRecord)
case "hold", "wait":
// No execution needed, just record
return nil
default:
return fmt.Errorf("unknown action: %s", decision.Action)
}
}
// executeOpenLongWithRecord executes open long position and records detailed information
func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 📈 Open long: %s", decision.Symbol)
// ⚠️ Get current positions for multiple checks
positions, err := at.trader.GetPositions()
if err != nil {
return fmt.Errorf("failed to get positions: %w", err)
}
// [CODE ENFORCED] Check max positions limit
if err := at.enforceMaxPositions(len(positions)); err != nil {
return err
}
// Check if there's already a position in the same symbol and direction
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol)
}
}
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
// Get balance (needed for multiple checks)
balance, err := at.trader.GetBalance()
if err != nil {
return fmt.Errorf("failed to get account balance: %w", err)
}
availableBalance := 0.0
if avail, ok := balance["availableBalance"].(float64); ok {
availableBalance = avail
}
// Get equity for position value ratio check
equity := 0.0
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
equity = eq
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
equity = eq
} else {
equity = availableBalance // Fallback to available balance
}
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
if wasCapped {
decision.PositionSizeUSD = adjustedPositionSize
}
// ⚠️ Auto-adjust position size if insufficient margin
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
// = positionSize * (1.01/leverage + 0.001)
marginFactor := 1.01/float64(decision.Leverage) + 0.001
maxAffordablePositionSize := availableBalance / marginFactor
actualPositionSize := decision.PositionSizeUSD
if actualPositionSize > maxAffordablePositionSize {
// Use 98% of max to leave buffer for price fluctuation
adjustedSize := maxAffordablePositionSize * 0.98
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
actualPositionSize, maxAffordablePositionSize, adjustedSize)
actualPositionSize = adjustedSize
decision.PositionSizeUSD = actualPositionSize
}
// [CODE ENFORCED] Minimum position size check
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
return err
}
// Calculate quantity with adjusted position size
quantity := actualPositionSize / marketData.CurrentPrice
actionRecord.Quantity = quantity
actionRecord.Price = marketData.CurrentPrice
// Set margin mode
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Continue execution, doesn't affect trading
}
// Open position
order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage)
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity)
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0)
// Record position opening time
posKey := decision.Symbol + "_long"
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
// Set stop loss and take profit
if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil {
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
}
if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil {
logger.Infof(" ⚠ Failed to set take profit: %v", err)
}
return nil
}
// executeOpenShortWithRecord executes open short position and records detailed information
func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 📉 Open short: %s", decision.Symbol)
// ⚠️ Get current positions for multiple checks
positions, err := at.trader.GetPositions()
if err != nil {
return fmt.Errorf("failed to get positions: %w", err)
}
// [CODE ENFORCED] Check max positions limit
if err := at.enforceMaxPositions(len(positions)); err != nil {
return err
}
// Check if there's already a position in the same symbol and direction
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol)
}
}
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
// Get balance (needed for multiple checks)
balance, err := at.trader.GetBalance()
if err != nil {
return fmt.Errorf("failed to get account balance: %w", err)
}
availableBalance := 0.0
if avail, ok := balance["availableBalance"].(float64); ok {
availableBalance = avail
}
// Get equity for position value ratio check
equity := 0.0
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
equity = eq
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
equity = eq
} else {
equity = availableBalance // Fallback to available balance
}
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
if wasCapped {
decision.PositionSizeUSD = adjustedPositionSize
}
// ⚠️ Auto-adjust position size if insufficient margin
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
// = positionSize * (1.01/leverage + 0.001)
marginFactor := 1.01/float64(decision.Leverage) + 0.001
maxAffordablePositionSize := availableBalance / marginFactor
actualPositionSize := decision.PositionSizeUSD
if actualPositionSize > maxAffordablePositionSize {
// Use 98% of max to leave buffer for price fluctuation
adjustedSize := maxAffordablePositionSize * 0.98
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
actualPositionSize, maxAffordablePositionSize, adjustedSize)
actualPositionSize = adjustedSize
decision.PositionSizeUSD = actualPositionSize
}
// [CODE ENFORCED] Minimum position size check
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
return err
}
// Calculate quantity with adjusted position size
quantity := actualPositionSize / marketData.CurrentPrice
actionRecord.Quantity = quantity
actionRecord.Price = marketData.CurrentPrice
// Set margin mode
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Continue execution, doesn't affect trading
}
// Open position
order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage)
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity)
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0)
// Record position opening time
posKey := decision.Symbol + "_short"
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
// Set stop loss and take profit
if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil {
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
}
if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil {
logger.Infof(" ⚠ Failed to set take profit: %v", err)
}
return nil
}
// executeCloseLongWithRecord executes close long position and records detailed information
func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// Normalize symbol for database lookup
normalizedSymbol := market.Normalize(decision.Symbol)
// Get entry price and quantity - prioritize local database for accurate quantity
var entryPrice float64
var quantity float64
// First try to get from local database (more accurate for quantity)
if at.store != nil {
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "LONG"); err == nil && openPos != nil {
quantity = openPos.Quantity
entryPrice = openPos.EntryPrice
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
}
// Fallback to exchange API if local data not found
if quantity == 0 {
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
if ep, ok := pos["entryPrice"].(float64); ok {
entryPrice = ep
}
if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 {
quantity = amt
}
break
}
}
}
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
// Close position
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice)
logger.Infof(" ✓ Position closed successfully")
return nil
}
// executeCloseShortWithRecord executes close short position and records detailed information
func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// Normalize symbol for database lookup
normalizedSymbol := market.Normalize(decision.Symbol)
// Get entry price and quantity - prioritize local database for accurate quantity
var entryPrice float64
var quantity float64
// First try to get from local database (more accurate for quantity)
if at.store != nil {
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "SHORT"); err == nil && openPos != nil {
quantity = openPos.Quantity
entryPrice = openPos.EntryPrice
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
}
// Fallback to exchange API if local data not found
if quantity == 0 {
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
if ep, ok := pos["entryPrice"].(float64); ok {
entryPrice = ep
}
if amt, ok := pos["positionAmt"].(float64); ok {
quantity = -amt // positionAmt is negative for short
}
break
}
}
}
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
// Close position
order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice)
logger.Infof(" ✓ Position closed successfully")
return nil
}
+263
View File
@@ -0,0 +1,263 @@
package trader
import (
"fmt"
"nofx/logger"
"strings"
"time"
)
// startDrawdownMonitor starts drawdown monitoring
func (at *AutoTrader) startDrawdownMonitor() {
at.monitorWg.Add(1)
go func() {
defer at.monitorWg.Done()
ticker := time.NewTicker(1 * time.Minute) // Check every minute
defer ticker.Stop()
logger.Info("📊 Started position drawdown monitoring (check every minute)")
for {
select {
case <-ticker.C:
at.checkPositionDrawdown()
case <-at.stopMonitorCh:
logger.Info("⏹ Stopped position drawdown monitoring")
return
}
}
}()
}
// checkPositionDrawdown checks position drawdown situation
func (at *AutoTrader) checkPositionDrawdown() {
// Get current positions
positions, err := at.trader.GetPositions()
if err != nil {
logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err)
return
}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}
// Calculate current P&L percentage
leverage := 10 // Default value
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
var currentPnLPct float64
if side == "long" {
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
} else {
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
}
// Construct unique position identifier (distinguish long/short)
posKey := symbol + "_" + side
// Get historical peak profit for this position
at.peakPnLCacheMutex.RLock()
peakPnLPct, exists := at.peakPnLCache[posKey]
at.peakPnLCacheMutex.RUnlock()
if !exists {
// If no historical peak record, use current P&L as initial value
peakPnLPct = currentPnLPct
at.UpdatePeakPnL(symbol, side, currentPnLPct)
} else {
// Update peak cache
at.UpdatePeakPnL(symbol, side, currentPnLPct)
}
// Calculate drawdown (magnitude of decline from peak)
var drawdownPct float64
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
}
// Check close position condition: profit > 5% and drawdown >= 40%
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
// Execute close position
if err := at.emergencyClosePosition(symbol, side); err != nil {
logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err)
} else {
logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side)
// Clear cache for this position after closing
at.ClearPeakPnLCache(symbol, side)
}
} else if currentPnLPct > 5.0 {
// Record situations close to close position condition (for debugging)
logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
}
}
}
// emergencyClosePosition emergency close position function
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
switch side {
case "long":
order, err := at.trader.CloseLong(symbol, 0) // 0 = close all
if err != nil {
return err
}
logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"])
case "short":
order, err := at.trader.CloseShort(symbol, 0) // 0 = close all
if err != nil {
return err
}
logger.Infof("✅ Emergency close short position succeeded, order ID: %v", order["orderId"])
default:
return fmt.Errorf("unknown position direction: %s", side)
}
return nil
}
// GetPeakPnLCache gets peak profit cache
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
at.peakPnLCacheMutex.RLock()
defer at.peakPnLCacheMutex.RUnlock()
// Return a copy of the cache
cache := make(map[string]float64)
for k, v := range at.peakPnLCache {
cache[k] = v
}
return cache
}
// UpdatePeakPnL updates peak profit cache
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
at.peakPnLCacheMutex.Lock()
defer at.peakPnLCacheMutex.Unlock()
posKey := symbol + "_" + side
if peak, exists := at.peakPnLCache[posKey]; exists {
// Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare)
if currentPnLPct > peak {
at.peakPnLCache[posKey] = currentPnLPct
}
} else {
// First time recording
at.peakPnLCache[posKey] = currentPnLPct
}
}
// ClearPeakPnLCache clears peak cache for specified position
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
at.peakPnLCacheMutex.Lock()
defer at.peakPnLCacheMutex.Unlock()
posKey := symbol + "_" + side
delete(at.peakPnLCache, posKey)
}
// ============================================================================
// Risk Control Helpers
// ============================================================================
// isBTCETH checks if a symbol is BTC or ETH
func isBTCETH(symbol string) bool {
symbol = strings.ToUpper(symbol)
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
}
// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED)
// Returns the adjusted position size (capped if necessary) and whether the position was capped
// positionSizeUSD: the original position size in USD
// equity: the account equity
// symbol: the trading symbol
func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) {
if at.config.StrategyConfig == nil {
return positionSizeUSD, false
}
riskControl := at.config.StrategyConfig.RiskControl
// Get the appropriate position value ratio limit
var maxPositionValueRatio float64
if isBTCETH(symbol) {
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
if maxPositionValueRatio <= 0 {
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH
}
} else {
maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio
if maxPositionValueRatio <= 0 {
maxPositionValueRatio = 1.0 // Default: 1x for altcoins
}
}
// Calculate max allowed position value = equity × ratio
maxPositionValue := equity * maxPositionValueRatio
// Check if position size exceeds limit
if positionSizeUSD > maxPositionValue {
logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping",
positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol)
return maxPositionValue, true
}
return positionSizeUSD, false
}
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
if at.config.StrategyConfig == nil {
return nil
}
minSize := at.config.StrategyConfig.RiskControl.MinPositionSize
if minSize <= 0 {
minSize = 12 // Default: 12 USDT
}
if positionSizeUSD < minSize {
return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize)
}
return nil
}
// enforceMaxPositions checks maximum positions count (CODE ENFORCED)
func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
if at.config.StrategyConfig == nil {
return nil
}
maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions
if maxPositions <= 0 {
maxPositions = 3 // Default: 3 positions
}
if currentPositionCount >= maxPositions {
return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions)
}
return nil
}
// getSideFromAction converts order action to side (BUY/SELL)
func getSideFromAction(action string) string {
switch action {
case "open_long", "close_short":
return "BUY"
case "open_short", "close_long":
return "SELL"
default:
return "BUY"
}
}