mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
cb31782be4
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
420 lines
13 KiB
Go
420 lines
13 KiB
Go
package trader
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"nofx/kernel"
|
|
"nofx/logger"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Grid Order Placement and Management
|
|
// ============================================================================
|
|
|
|
// checkTotalPositionLimit checks if adding a new position would exceed total limits
|
|
// Returns: (allowed bool, currentPositionValue float64, maxAllowed float64)
|
|
func (at *AutoTrader) checkTotalPositionLimit(symbol string, additionalValue float64) (bool, float64, float64) {
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
|
|
// Calculate max allowed total position value
|
|
// Total position should not exceed: TotalInvestment * Leverage
|
|
maxTotalPositionValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage)
|
|
|
|
// Get current position value from exchange
|
|
currentPositionValue := 0.0
|
|
positions, err := at.trader.GetPositions()
|
|
if err == nil {
|
|
for _, pos := range positions {
|
|
if sym, ok := pos["symbol"].(string); ok && sym == symbol {
|
|
if size, ok := pos["positionAmt"].(float64); ok {
|
|
if price, ok := pos["markPrice"].(float64); ok {
|
|
currentPositionValue = math.Abs(size) * price
|
|
} else if entryPrice, ok := pos["entryPrice"].(float64); ok {
|
|
currentPositionValue = math.Abs(size) * entryPrice
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also count pending orders as potential position
|
|
at.gridState.mu.RLock()
|
|
pendingValue := 0.0
|
|
for _, level := range at.gridState.Levels {
|
|
if level.State == "pending" {
|
|
pendingValue += level.OrderQuantity * level.Price
|
|
}
|
|
}
|
|
at.gridState.mu.RUnlock()
|
|
|
|
totalAfterOrder := currentPositionValue + pendingValue + additionalValue
|
|
allowed := totalAfterOrder <= maxTotalPositionValue
|
|
|
|
return allowed, currentPositionValue + pendingValue, maxTotalPositionValue
|
|
}
|
|
|
|
// placeGridLimitOrder places a limit order for grid trading
|
|
func (at *AutoTrader) placeGridLimitOrder(d *kernel.Decision, side string) error {
|
|
// Check if trader supports GridTrader interface
|
|
gridTrader, ok := at.trader.(GridTrader)
|
|
if !ok {
|
|
// Fallback to adapter
|
|
gridTrader = NewGridTraderAdapter(at.trader)
|
|
}
|
|
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
|
|
// CRITICAL: Validate and cap quantity to prevent excessive position sizes
|
|
// This protects against AI miscalculations or leverage misconfigurations
|
|
quantity := d.Quantity
|
|
if d.Price > 0 && gridConfig.TotalInvestment > 0 {
|
|
// Calculate max allowed position value per grid level
|
|
// Each level gets proportional share of total investment
|
|
maxMarginPerLevel := gridConfig.TotalInvestment / float64(gridConfig.GridCount)
|
|
maxPositionValuePerLevel := maxMarginPerLevel * float64(gridConfig.Leverage)
|
|
maxQuantityPerLevel := maxPositionValuePerLevel / d.Price
|
|
|
|
// Also get the level's allocated USD for additional validation
|
|
at.gridState.mu.RLock()
|
|
var levelAllocatedUSD float64
|
|
if d.LevelIndex >= 0 && d.LevelIndex < len(at.gridState.Levels) {
|
|
levelAllocatedUSD = at.gridState.Levels[d.LevelIndex].AllocatedUSD
|
|
}
|
|
at.gridState.mu.RUnlock()
|
|
|
|
// Use level-specific allocation if available
|
|
if levelAllocatedUSD > 0 {
|
|
levelMaxPositionValue := levelAllocatedUSD * float64(gridConfig.Leverage)
|
|
levelMaxQuantity := levelMaxPositionValue / d.Price
|
|
if levelMaxQuantity < maxQuantityPerLevel {
|
|
maxQuantityPerLevel = levelMaxQuantity
|
|
}
|
|
}
|
|
|
|
// Cap quantity if it exceeds the maximum allowed
|
|
if quantity > maxQuantityPerLevel {
|
|
logger.Warnf("[Grid] Quantity %.4f exceeds max allowed %.4f (position_value $%.2f > max $%.2f), capping",
|
|
quantity, maxQuantityPerLevel, quantity*d.Price, maxPositionValuePerLevel)
|
|
quantity = maxQuantityPerLevel
|
|
}
|
|
|
|
// Safety check: ensure position value is reasonable (within 2x of intended max as absolute limit)
|
|
positionValue := quantity * d.Price
|
|
absoluteMaxValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage) * 2 // 2x safety margin
|
|
if positionValue > absoluteMaxValue {
|
|
logger.Errorf("[Grid] CRITICAL: Position value $%.2f exceeds absolute max $%.2f! Rejecting order.",
|
|
positionValue, absoluteMaxValue)
|
|
return fmt.Errorf("position value $%.2f exceeds safety limit $%.2f", positionValue, absoluteMaxValue)
|
|
}
|
|
}
|
|
|
|
// CRITICAL: Check total position limit before placing order
|
|
orderValue := quantity * d.Price
|
|
allowed, currentValue, maxValue := at.checkTotalPositionLimit(d.Symbol, orderValue)
|
|
if !allowed {
|
|
logger.Errorf("[Grid] TOTAL POSITION LIMIT EXCEEDED: current=$%.2f + order=$%.2f > max=$%.2f. Rejecting order.",
|
|
currentValue, orderValue, maxValue)
|
|
return fmt.Errorf("total position value $%.2f would exceed limit $%.2f", currentValue+orderValue, maxValue)
|
|
}
|
|
|
|
req := &LimitOrderRequest{
|
|
Symbol: d.Symbol,
|
|
Side: side,
|
|
Price: d.Price,
|
|
Quantity: quantity, // Use validated/capped quantity
|
|
Leverage: gridConfig.Leverage,
|
|
PostOnly: gridConfig.UseMakerOnly,
|
|
ReduceOnly: false,
|
|
ClientID: fmt.Sprintf("grid-%d-%d", d.LevelIndex, time.Now().UnixNano()%1000000),
|
|
}
|
|
|
|
result, err := gridTrader.PlaceLimitOrder(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to place limit order: %w", err)
|
|
}
|
|
|
|
// Update grid level state
|
|
at.gridState.mu.Lock()
|
|
if d.LevelIndex >= 0 && d.LevelIndex < len(at.gridState.Levels) {
|
|
at.gridState.Levels[d.LevelIndex].State = "pending"
|
|
at.gridState.Levels[d.LevelIndex].OrderID = result.OrderID
|
|
at.gridState.Levels[d.LevelIndex].OrderQuantity = d.Quantity
|
|
at.gridState.OrderBook[result.OrderID] = d.LevelIndex
|
|
}
|
|
at.gridState.mu.Unlock()
|
|
|
|
logger.Infof("[Grid] Placed %s limit order at $%.2f, qty=%.4f, level=%d, orderID=%s",
|
|
side, d.Price, d.Quantity, d.LevelIndex, result.OrderID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// cancelGridOrder cancels a specific grid order
|
|
func (at *AutoTrader) cancelGridOrder(d *kernel.Decision) error {
|
|
gridTrader, ok := at.trader.(GridTrader)
|
|
if !ok {
|
|
gridTrader = NewGridTraderAdapter(at.trader)
|
|
}
|
|
|
|
if err := gridTrader.CancelOrder(d.Symbol, d.OrderID); err != nil {
|
|
return fmt.Errorf("failed to cancel order: %w", err)
|
|
}
|
|
|
|
// Update state
|
|
at.gridState.mu.Lock()
|
|
if levelIdx, ok := at.gridState.OrderBook[d.OrderID]; ok {
|
|
if levelIdx >= 0 && levelIdx < len(at.gridState.Levels) {
|
|
at.gridState.Levels[levelIdx].State = "empty"
|
|
at.gridState.Levels[levelIdx].OrderID = ""
|
|
at.gridState.Levels[levelIdx].OrderQuantity = 0
|
|
}
|
|
delete(at.gridState.OrderBook, d.OrderID)
|
|
}
|
|
at.gridState.mu.Unlock()
|
|
|
|
logger.Infof("[Grid] Cancelled order: %s", d.OrderID)
|
|
return nil
|
|
}
|
|
|
|
// cancelAllGridOrders cancels all grid orders
|
|
func (at *AutoTrader) cancelAllGridOrders() error {
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
|
|
if err := at.trader.CancelAllOrders(gridConfig.Symbol); err != nil {
|
|
return fmt.Errorf("failed to cancel all orders: %w", err)
|
|
}
|
|
|
|
// Reset all pending levels
|
|
at.gridState.mu.Lock()
|
|
for i := range at.gridState.Levels {
|
|
if at.gridState.Levels[i].State == "pending" {
|
|
at.gridState.Levels[i].State = "empty"
|
|
at.gridState.Levels[i].OrderID = ""
|
|
at.gridState.Levels[i].OrderQuantity = 0
|
|
}
|
|
}
|
|
at.gridState.OrderBook = make(map[string]int)
|
|
at.gridState.mu.Unlock()
|
|
|
|
logger.Infof("[Grid] Cancelled all orders")
|
|
return nil
|
|
}
|
|
|
|
// pauseGrid pauses grid trading
|
|
func (at *AutoTrader) pauseGrid(reason string) error {
|
|
at.cancelAllGridOrders()
|
|
|
|
at.gridState.mu.Lock()
|
|
at.gridState.IsPaused = true
|
|
at.gridState.mu.Unlock()
|
|
|
|
logger.Infof("[Grid] Paused: %s", reason)
|
|
return nil
|
|
}
|
|
|
|
// resumeGrid resumes grid trading
|
|
func (at *AutoTrader) resumeGrid() error {
|
|
at.gridState.mu.Lock()
|
|
at.gridState.IsPaused = false
|
|
at.gridState.mu.Unlock()
|
|
|
|
logger.Infof("[Grid] Resumed")
|
|
return nil
|
|
}
|
|
|
|
// adjustGrid adjusts grid parameters
|
|
func (at *AutoTrader) adjustGrid(d *kernel.Decision) error {
|
|
// Cancel existing orders first
|
|
at.cancelAllGridOrders()
|
|
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
|
|
// Get current price
|
|
price, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get market price: %w", err)
|
|
}
|
|
|
|
// Reinitialize grid levels
|
|
at.initializeGridLevels(price, gridConfig)
|
|
|
|
logger.Infof("[Grid] Adjusted grid bounds around price $%.2f", price)
|
|
return nil
|
|
}
|
|
|
|
// syncGridState syncs grid state with exchange
|
|
func (at *AutoTrader) syncGridState() {
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
|
|
// Get open orders from exchange
|
|
openOrders, err := at.trader.GetOpenOrders(gridConfig.Symbol)
|
|
if err != nil {
|
|
logger.Warnf("[Grid] Failed to get open orders: %v", err)
|
|
return
|
|
}
|
|
|
|
// Build set of active order IDs
|
|
activeOrderIDs := make(map[string]bool)
|
|
for _, order := range openOrders {
|
|
activeOrderIDs[order.OrderID] = true
|
|
}
|
|
|
|
// Get current positions to verify fills
|
|
positions, err := at.trader.GetPositions()
|
|
currentPositionSize := 0.0
|
|
if err != nil {
|
|
logger.Warnf("[Grid] Failed to get positions for state sync: %v", err)
|
|
} else {
|
|
for _, pos := range positions {
|
|
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
|
|
if size, ok := pos["positionAmt"].(float64); ok {
|
|
currentPositionSize = size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update levels based on order status
|
|
at.gridState.mu.Lock()
|
|
expectedPositionSize := 0.0
|
|
for _, level := range at.gridState.Levels {
|
|
if level.State == "filled" {
|
|
expectedPositionSize += level.PositionSize
|
|
}
|
|
}
|
|
|
|
for i := range at.gridState.Levels {
|
|
level := &at.gridState.Levels[i]
|
|
if level.State == "pending" && level.OrderID != "" {
|
|
if !activeOrderIDs[level.OrderID] {
|
|
// Order no longer exists - check if position changed to determine fill vs cancel
|
|
// This is a heuristic - ideally we'd query order history
|
|
// If current position is larger than expected filled positions, this order was likely filled
|
|
if math.Abs(currentPositionSize) > math.Abs(expectedPositionSize) {
|
|
// Position increased, likely filled
|
|
level.State = "filled"
|
|
level.PositionEntry = level.Price
|
|
level.PositionSize = level.OrderQuantity
|
|
at.gridState.TotalTrades++
|
|
logger.Infof("[Grid] Level %d order filled at $%.2f", i, level.Price)
|
|
} else {
|
|
// Position didn't increase as expected, likely cancelled
|
|
level.State = "empty"
|
|
level.OrderID = ""
|
|
level.OrderQuantity = 0
|
|
logger.Infof("[Grid] Level %d order cancelled/expired", i)
|
|
}
|
|
delete(at.gridState.OrderBook, level.OrderID)
|
|
}
|
|
}
|
|
}
|
|
at.gridState.mu.Unlock()
|
|
|
|
logger.Debugf("[Grid] Synced state: position=%.4f, orders=%d", currentPositionSize, len(openOrders))
|
|
|
|
// Check stop loss
|
|
at.checkAndExecuteStopLoss()
|
|
|
|
// Check grid skew
|
|
at.autoAdjustGrid()
|
|
}
|
|
|
|
// closeAllPositions closes all open positions for the grid symbol
|
|
func (at *AutoTrader) closeAllPositions() error {
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
if gridConfig == nil {
|
|
return nil
|
|
}
|
|
|
|
positions, err := at.trader.GetPositions()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get positions: %w", err)
|
|
}
|
|
|
|
for _, pos := range positions {
|
|
symbol, _ := pos["symbol"].(string)
|
|
if symbol != gridConfig.Symbol {
|
|
continue
|
|
}
|
|
|
|
size, _ := pos["positionAmt"].(float64)
|
|
if size == 0 {
|
|
continue
|
|
}
|
|
|
|
if size > 0 {
|
|
_, err = at.trader.CloseLong(symbol, size)
|
|
} else {
|
|
_, err = at.trader.CloseShort(symbol, -size)
|
|
}
|
|
if err != nil {
|
|
logger.Infof("Failed to close position: %v", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkAndExecuteStopLoss checks if any filled level has exceeded stop loss and closes it
|
|
func (at *AutoTrader) checkAndExecuteStopLoss() {
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
if gridConfig.StopLossPct <= 0 {
|
|
return // Stop loss not configured
|
|
}
|
|
|
|
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
|
if err != nil {
|
|
logger.Warnf("[Grid] Failed to get market price for stop loss check: %v", err)
|
|
return
|
|
}
|
|
|
|
at.gridState.mu.Lock()
|
|
defer at.gridState.mu.Unlock()
|
|
|
|
for i := range at.gridState.Levels {
|
|
level := &at.gridState.Levels[i]
|
|
if level.State != "filled" || level.PositionEntry <= 0 {
|
|
continue
|
|
}
|
|
|
|
// Calculate loss percentage
|
|
var lossPct float64
|
|
if level.Side == "buy" {
|
|
// Long position: loss when price drops
|
|
lossPct = (level.PositionEntry - currentPrice) / level.PositionEntry * 100
|
|
} else {
|
|
// Short position: loss when price rises
|
|
lossPct = (currentPrice - level.PositionEntry) / level.PositionEntry * 100
|
|
}
|
|
|
|
// Check if stop loss triggered
|
|
if lossPct >= gridConfig.StopLossPct {
|
|
logger.Warnf("[Grid] STOP LOSS TRIGGERED: Level %d, entry=$%.2f, current=$%.2f, loss=%.2f%%",
|
|
i, level.PositionEntry, currentPrice, lossPct)
|
|
|
|
// Close the position
|
|
var closeErr error
|
|
if level.Side == "buy" {
|
|
_, closeErr = at.trader.CloseLong(gridConfig.Symbol, level.PositionSize)
|
|
} else {
|
|
_, closeErr = at.trader.CloseShort(gridConfig.Symbol, level.PositionSize)
|
|
}
|
|
|
|
if closeErr != nil {
|
|
logger.Errorf("[Grid] Failed to execute stop loss for level %d: %v", i, closeErr)
|
|
} else {
|
|
level.State = "stopped"
|
|
realizedLoss := -lossPct * level.AllocatedUSD / 100
|
|
level.UnrealizedPnL = realizedLoss
|
|
at.gridState.TotalTrades++
|
|
// Update daily PnL tracking (lock already held, update directly)
|
|
at.gridState.DailyPnL += realizedLoss
|
|
at.gridState.TotalProfit += realizedLoss
|
|
logger.Infof("[Grid] Stop loss executed: Level %d closed at $%.2f (loss %.2f%%)",
|
|
i, currentPrice, lossPct)
|
|
}
|
|
}
|
|
}
|
|
}
|