mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
+33
-1771
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user