mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
8e294a5eed
- 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
392 lines
13 KiB
Go
392 lines
13 KiB
Go
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
|
||
}
|