Files
nofx/trader/auto_trader_orders.go
shinchan-zhai cd6fe62e16 feat(agent): add market hours check for stock trades
- execute_trade: warn user when US market is closed (order queued for next open)
- get_market_price: include market_open/market_status for stock quotes
- Uses Alpaca's /v2/clock endpoint via IsMarketOpen() interface
- Prevents surprise queued orders during off-hours
2026-03-25 01:05:54 +08:00

424 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 posString(pos, "symbol") == decision.Symbol && posString(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 (skip if AI didn't specify a price)
if decision.StopLoss > 0 {
if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil {
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
}
}
if decision.TakeProfit > 0 {
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 posString(pos, "symbol") == decision.Symbol && posString(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 decision.StopLoss > 0 {
if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil {
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
}
}
if decision.TakeProfit > 0 {
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 posString(pos, "symbol") == decision.Symbol && posString(pos, "side") == "long" {
entryPrice = posFloat64(pos, "entryPrice")
if amt := posFloat64(pos, "positionAmt"); amt > 0 {
quantity = amt
}
break
}
}
}
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
// Cancel existing stop-loss and take-profit orders BEFORE closing position
// Critical: orphaned SL/TP orders could trigger after position is closed and create unintended positions
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
logger.Warnf(" ⚠️ Failed to cancel stop orders for %s: %v (proceeding with close)", decision.Symbol, err)
} else {
logger.Infof(" 🗑️ Cancelled stop/TP orders for %s", decision.Symbol)
}
// Record quantity in action record (was missing — close decisions showed 0 quantity in history)
actionRecord.Quantity = quantity
// 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)
// Clear peak PnL cache for this position (prevent stale data if same symbol is reopened)
at.ClearPeakPnLCache(decision.Symbol, "long")
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 posString(pos, "symbol") == decision.Symbol && posString(pos, "side") == "short" {
entryPrice = posFloat64(pos, "entryPrice")
if amt := posFloat64(pos, "positionAmt"); amt != 0 {
quantity = -amt // positionAmt is negative for short
}
break
}
}
}
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
// Record quantity in action record (was missing — close decisions showed 0 quantity in history)
actionRecord.Quantity = quantity
// Cancel existing stop-loss and take-profit orders BEFORE closing position
// Critical: orphaned SL/TP orders could trigger after position is closed and create unintended positions
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
logger.Warnf(" ⚠️ Failed to cancel stop orders for %s: %v (proceeding with close)", decision.Symbol, err)
} else {
logger.Infof(" 🗑️ Cancelled stop/TP orders for %s", decision.Symbol)
}
// 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)
// Clear peak PnL cache for this position (prevent stale data if same symbol is reopened)
at.ClearPeakPnLCache(decision.Symbol, "short")
logger.Infof(" ✓ Position closed successfully")
return nil
}