mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +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:
391
trader/auto_trader_orders.go
Normal file
391
trader/auto_trader_orders.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user