mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +08:00
refactor: split large files and clean up project structure
- 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)
This commit is contained in:
419
trader/auto_trader_grid_orders.go
Normal file
419
trader/auto_trader_grid_orders.go
Normal file
@@ -0,0 +1,419 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user