mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
ed557cd7ac
- Add OpenOrderInfo struct and OpenOrders field to trading context - Support new AI actions: place_buy_limit, place_sell_limit, cancel_order, cancel_all_orders - Include existing open orders in AI prompt to avoid duplicate orders - Add open orders display table in trader dashboard - Fix Hyperliquid symbol conversion and order status parsing - Add i18n translations for open orders
531 lines
17 KiB
Go
531 lines
17 KiB
Go
package trader
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"nofx/kernel"
|
||
"nofx/logger"
|
||
"nofx/market"
|
||
"nofx/store"
|
||
"strconv"
|
||
"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 "place_buy_limit":
|
||
return at.executeLimitOrderWithRecord(decision, actionRecord, "BUY")
|
||
case "place_sell_limit":
|
||
return at.executeLimitOrderWithRecord(decision, actionRecord, "SELL")
|
||
case "cancel_order":
|
||
return at.executeCancelOrderWithRecord(decision, actionRecord)
|
||
case "cancel_all_orders":
|
||
return at.executeCancelAllOrdersWithRecord(decision, actionRecord)
|
||
case "hold", "wait":
|
||
// No execution needed, just record
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("unknown action: %s", decision.Action)
|
||
}
|
||
}
|
||
|
||
func (at *AutoTrader) executeLimitOrderWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction, side string) error {
|
||
if decision.Price <= 0 {
|
||
return fmt.Errorf("limit order price must be greater than 0")
|
||
}
|
||
|
||
quantity := decision.Quantity
|
||
if quantity <= 0 && decision.PositionSizeUSD > 0 {
|
||
quantity = decision.PositionSizeUSD / decision.Price
|
||
}
|
||
if quantity <= 0 {
|
||
return fmt.Errorf("limit order quantity must be greater than 0")
|
||
}
|
||
|
||
gridTrader, ok := at.trader.(GridTrader)
|
||
if !ok {
|
||
gridTrader = NewGridTraderAdapter(at.trader)
|
||
}
|
||
|
||
if duplicated, existingOrderID, err := at.hasSimilarOpenOrder(decision.Symbol, side, decision.Price); err != nil {
|
||
logger.Warnf(" ⚠️ Failed to check existing open orders: %v", err)
|
||
} else if duplicated {
|
||
actionRecord.OrderID = parseOrderIDInt64(existingOrderID)
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = decision.Price
|
||
logger.Infof(" ℹ️ Similar %s limit order already exists for %s at %.4f (orderID=%s), skipping duplicate",
|
||
side, decision.Symbol, decision.Price, existingOrderID)
|
||
return nil
|
||
}
|
||
|
||
leverage := decision.Leverage
|
||
if leverage <= 0 {
|
||
leverage = at.defaultLeverageForSymbol(decision.Symbol)
|
||
}
|
||
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
|
||
}
|
||
|
||
req := &LimitOrderRequest{
|
||
Symbol: decision.Symbol,
|
||
Side: side,
|
||
Price: decision.Price,
|
||
Quantity: quantity,
|
||
Leverage: leverage,
|
||
PostOnly: true,
|
||
ReduceOnly: false,
|
||
ClientID: fmt.Sprintf("ai-limit-%d", time.Now().UnixNano()%1000000000),
|
||
}
|
||
|
||
result, err := gridTrader.PlaceLimitOrder(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
actionRecord.OrderID = parseOrderIDInt64(result.OrderID)
|
||
actionRecord.Quantity = result.Quantity
|
||
actionRecord.Price = result.Price
|
||
actionRecord.Leverage = leverage
|
||
|
||
logger.Infof(" ✓ Limit order placed: %s %s %.6f @ %.4f orderID=%s",
|
||
decision.Symbol, side, result.Quantity, result.Price, result.OrderID)
|
||
return nil
|
||
}
|
||
|
||
func (at *AutoTrader) executeCancelOrderWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
gridTrader, ok := at.trader.(GridTrader)
|
||
if !ok {
|
||
gridTrader = NewGridTraderAdapter(at.trader)
|
||
}
|
||
|
||
if err := gridTrader.CancelOrder(decision.Symbol, decision.OrderID); err != nil {
|
||
return err
|
||
}
|
||
|
||
actionRecord.OrderID = parseOrderIDInt64(decision.OrderID)
|
||
logger.Infof(" ✓ Cancelled order: %s %s", decision.Symbol, decision.OrderID)
|
||
return nil
|
||
}
|
||
|
||
func (at *AutoTrader) executeCancelAllOrdersWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
if err := at.trader.CancelAllOrders(decision.Symbol); err != nil {
|
||
return err
|
||
}
|
||
|
||
logger.Infof(" ✓ Cancelled all open orders for %s", decision.Symbol)
|
||
return nil
|
||
}
|
||
|
||
func (at *AutoTrader) hasSimilarOpenOrder(symbol, side string, price float64) (bool, string, error) {
|
||
orders, err := at.trader.GetOpenOrders(symbol)
|
||
if err != nil {
|
||
return false, "", err
|
||
}
|
||
|
||
tolerance := price * 0.001
|
||
if tolerance < 0.01 {
|
||
tolerance = 0.01
|
||
}
|
||
for _, order := range orders {
|
||
if order.Side == side && math.Abs(order.Price-price) <= tolerance {
|
||
return true, order.OrderID, nil
|
||
}
|
||
}
|
||
|
||
return false, "", nil
|
||
}
|
||
|
||
func (at *AutoTrader) defaultLeverageForSymbol(symbol string) int {
|
||
if at.config.StrategyConfig == nil {
|
||
return 3
|
||
}
|
||
risk := at.config.StrategyConfig.RiskControl
|
||
if symbol == "BTCUSDT" || symbol == "ETHUSDT" {
|
||
if risk.BTCETHMaxLeverage > 0 {
|
||
return risk.BTCETHMaxLeverage
|
||
}
|
||
return 3
|
||
}
|
||
if risk.AltcoinMaxLeverage > 0 {
|
||
return risk.AltcoinMaxLeverage
|
||
}
|
||
return 3
|
||
}
|
||
|
||
func parseOrderIDInt64(orderID string) int64 {
|
||
id, _ := strconv.ParseInt(orderID, 10, 64)
|
||
return id
|
||
}
|
||
|
||
// 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
|
||
}
|