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)
486 lines
15 KiB
Go
486 lines
15 KiB
Go
package trader
|
|
|
|
import (
|
|
"math"
|
|
"nofx/kernel"
|
|
"nofx/logger"
|
|
"nofx/market"
|
|
"nofx/store"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Grid Level Calculation and Rebalancing
|
|
// ============================================================================
|
|
|
|
// calculateDefaultBounds calculates default bounds based on price
|
|
func (at *AutoTrader) calculateDefaultBounds(price float64, config *store.GridStrategyConfig) {
|
|
// Default: +/-3% from current price
|
|
multiplier := 0.03 * float64(config.GridCount) / 10
|
|
at.gridState.UpperPrice = price * (1 + multiplier)
|
|
at.gridState.LowerPrice = price * (1 - multiplier)
|
|
}
|
|
|
|
// calculateATRBounds calculates bounds using ATR
|
|
func (at *AutoTrader) calculateATRBounds(price float64, mktData *market.Data, config *store.GridStrategyConfig) {
|
|
atr := 0.0
|
|
if mktData.LongerTermContext != nil {
|
|
atr = mktData.LongerTermContext.ATR14
|
|
}
|
|
|
|
if atr <= 0 {
|
|
at.calculateDefaultBounds(price, config)
|
|
return
|
|
}
|
|
|
|
multiplier := config.ATRMultiplier
|
|
if multiplier <= 0 {
|
|
multiplier = 2.0
|
|
}
|
|
|
|
halfRange := atr * multiplier
|
|
at.gridState.UpperPrice = price + halfRange
|
|
at.gridState.LowerPrice = price - halfRange
|
|
}
|
|
|
|
// initializeGridLevels creates the grid level structure
|
|
func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.GridStrategyConfig) {
|
|
levels := make([]kernel.GridLevelInfo, config.GridCount)
|
|
totalWeight := 0.0
|
|
weights := make([]float64, config.GridCount)
|
|
|
|
// Calculate weights based on distribution
|
|
for i := 0; i < config.GridCount; i++ {
|
|
switch config.Distribution {
|
|
case "gaussian":
|
|
// Gaussian distribution - more weight in the middle
|
|
center := float64(config.GridCount-1) / 2
|
|
sigma := float64(config.GridCount) / 4
|
|
weights[i] = math.Exp(-math.Pow(float64(i)-center, 2) / (2 * sigma * sigma))
|
|
case "pyramid":
|
|
// Pyramid - more weight at bottom
|
|
weights[i] = float64(config.GridCount - i)
|
|
default: // uniform
|
|
weights[i] = 1.0
|
|
}
|
|
totalWeight += weights[i]
|
|
}
|
|
|
|
// Create levels
|
|
for i := 0; i < config.GridCount; i++ {
|
|
price := at.gridState.LowerPrice + float64(i)*at.gridState.GridSpacing
|
|
allocatedUSD := config.TotalInvestment * weights[i] / totalWeight
|
|
|
|
// Determine initial side (below current price = buy, above = sell)
|
|
side := "buy"
|
|
if price > currentPrice {
|
|
side = "sell"
|
|
}
|
|
|
|
levels[i] = kernel.GridLevelInfo{
|
|
Index: i,
|
|
Price: price,
|
|
State: "empty",
|
|
Side: side,
|
|
AllocatedUSD: allocatedUSD,
|
|
}
|
|
}
|
|
|
|
at.gridState.Levels = levels
|
|
|
|
// Apply direction-based side assignment if enabled
|
|
if config.EnableDirectionAdjust {
|
|
at.applyGridDirection(currentPrice)
|
|
}
|
|
}
|
|
|
|
// applyGridDirection adjusts grid level sides based on the current direction
|
|
// This redistributes buy/sell levels according to the direction bias ratio
|
|
func (at *AutoTrader) applyGridDirection(currentPrice float64) {
|
|
config := at.gridState.Config
|
|
direction := at.gridState.CurrentDirection
|
|
|
|
// Get bias ratio from config, default to 0.7 (70%/30%)
|
|
biasRatio := config.DirectionBiasRatio
|
|
if biasRatio <= 0 || biasRatio > 1 {
|
|
biasRatio = 0.7
|
|
}
|
|
|
|
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
|
|
|
|
// Calculate how many levels should be buy vs sell based on direction
|
|
totalLevels := len(at.gridState.Levels)
|
|
targetBuyLevels := int(float64(totalLevels) * buyRatio)
|
|
|
|
// For neutral: use price-based assignment (buy below, sell above)
|
|
if direction == market.GridDirectionNeutral {
|
|
for i := range at.gridState.Levels {
|
|
if at.gridState.Levels[i].Price <= currentPrice {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
} else {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// For long/long_bias: more buy levels
|
|
// For short/short_bias: more sell levels
|
|
switch direction {
|
|
case market.GridDirectionLong:
|
|
// 100% buy - all levels are buy
|
|
for i := range at.gridState.Levels {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
}
|
|
|
|
case market.GridDirectionShort:
|
|
// 100% sell - all levels are sell
|
|
for i := range at.gridState.Levels {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
}
|
|
|
|
case market.GridDirectionLongBias, market.GridDirectionShortBias:
|
|
// Assign sides based on position relative to current price
|
|
// For long_bias: keep all below as buy, convert some above to buy
|
|
// For short_bias: keep all above as sell, convert some below to sell
|
|
buyCount := 0
|
|
sellCount := 0
|
|
|
|
for i := range at.gridState.Levels {
|
|
needMoreBuys := buyCount < targetBuyLevels
|
|
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
|
|
|
|
if at.gridState.Levels[i].Price <= currentPrice {
|
|
// Level below or at current price
|
|
if needMoreBuys {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
buyCount++
|
|
} else {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
sellCount++
|
|
}
|
|
} else {
|
|
// Level above current price
|
|
if needMoreSells && direction == market.GridDirectionShortBias {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
sellCount++
|
|
} else if needMoreBuys && direction == market.GridDirectionLongBias {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
buyCount++
|
|
} else if needMoreSells {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
sellCount++
|
|
} else {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
buyCount++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Infof("[Grid] Applied direction %s: buy_ratio=%.0f%%, levels reconfigured",
|
|
direction, buyRatio*100)
|
|
}
|
|
|
|
// checkGridSkew checks if grid is heavily skewed (too many fills on one side)
|
|
// Returns: (skewed bool, buyFilledCount int, sellFilledCount int)
|
|
func (at *AutoTrader) checkGridSkew() (bool, int, int) {
|
|
at.gridState.mu.RLock()
|
|
defer at.gridState.mu.RUnlock()
|
|
|
|
buyFilled := 0
|
|
sellFilled := 0
|
|
buyEmpty := 0
|
|
sellEmpty := 0
|
|
|
|
for _, level := range at.gridState.Levels {
|
|
if level.Side == "buy" {
|
|
if level.State == "filled" {
|
|
buyFilled++
|
|
} else if level.State == "empty" {
|
|
buyEmpty++
|
|
}
|
|
} else {
|
|
if level.State == "filled" {
|
|
sellFilled++
|
|
} else if level.State == "empty" {
|
|
sellEmpty++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Grid is skewed if one side has 3x more fills than the other
|
|
// or if one side is completely empty
|
|
skewed := false
|
|
if buyFilled > 0 && sellFilled == 0 && sellEmpty > 5 {
|
|
skewed = true // All buys filled, no sells
|
|
} else if sellFilled > 0 && buyFilled == 0 && buyEmpty > 5 {
|
|
skewed = true // All sells filled, no buys
|
|
} else if buyFilled >= 3*sellFilled && buyFilled > 5 {
|
|
skewed = true
|
|
} else if sellFilled >= 3*buyFilled && sellFilled > 5 {
|
|
skewed = true
|
|
}
|
|
|
|
return skewed, buyFilled, sellFilled
|
|
}
|
|
|
|
// autoAdjustGrid automatically adjusts grid when heavily skewed
|
|
func (at *AutoTrader) autoAdjustGrid() {
|
|
skewed, buyFilled, sellFilled := at.checkGridSkew()
|
|
if !skewed {
|
|
return
|
|
}
|
|
|
|
logger.Warnf("[Grid] Grid heavily skewed: buy_filled=%d, sell_filled=%d. Auto-adjusting...",
|
|
buyFilled, sellFilled)
|
|
|
|
gridConfig := at.config.StrategyConfig.GridConfig
|
|
|
|
// Get current price
|
|
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
|
if err != nil {
|
|
logger.Errorf("[Grid] Failed to get price for auto-adjust: %v", err)
|
|
return
|
|
}
|
|
|
|
// Check if price is near grid boundary
|
|
at.gridState.mu.RLock()
|
|
upper := at.gridState.UpperPrice
|
|
lower := at.gridState.LowerPrice
|
|
at.gridState.mu.RUnlock()
|
|
|
|
// Only adjust if price has moved significantly (>30% of grid range)
|
|
gridRange := upper - lower
|
|
midPrice := (upper + lower) / 2
|
|
priceDeviation := math.Abs(currentPrice - midPrice)
|
|
|
|
if priceDeviation < gridRange*0.3 {
|
|
return // Price still near center, don't adjust
|
|
}
|
|
|
|
logger.Infof("[Grid] Adjusting grid around new price $%.2f", currentPrice)
|
|
|
|
// Cancel existing orders first (before taking the lock for state modification)
|
|
if err := at.cancelAllGridOrders(); err != nil {
|
|
logger.Errorf("[Grid] Failed to cancel orders during auto-adjust: %v", err)
|
|
// Continue with adjustment anyway
|
|
}
|
|
|
|
// CRITICAL FIX: Hold lock for the entire adjustment operation to ensure atomicity
|
|
at.gridState.mu.Lock()
|
|
defer at.gridState.mu.Unlock()
|
|
|
|
// Preserve filled positions before reinitializing
|
|
filledPositions := make(map[int]kernel.GridLevelInfo)
|
|
for i, level := range at.gridState.Levels {
|
|
if level.State == "filled" {
|
|
filledPositions[i] = level
|
|
}
|
|
}
|
|
|
|
// CRITICAL FIX: Recalculate grid bounds centered on current price
|
|
// Use the same logic as InitializeGrid() - either ATR-based or default percentage
|
|
if gridConfig.UseATRBounds {
|
|
// Try to get ATR for bound calculation
|
|
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"4h"}, "4h", 20)
|
|
if err != nil {
|
|
logger.Warnf("[Grid] Failed to get market data for ATR during adjust: %v, using default bounds", err)
|
|
at.calculateDefaultBoundsLocked(currentPrice, gridConfig)
|
|
} else {
|
|
at.calculateATRBoundsLocked(currentPrice, mktData, gridConfig)
|
|
}
|
|
} else {
|
|
// Use default bounds calculation (scaled by grid count)
|
|
at.calculateDefaultBoundsLocked(currentPrice, gridConfig)
|
|
}
|
|
|
|
// Recalculate grid spacing based on new bounds
|
|
at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1)
|
|
|
|
logger.Infof("[Grid] New bounds: $%.2f - $%.2f, spacing: $%.2f",
|
|
at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing)
|
|
|
|
// Initialize new grid levels (without lock since we already hold it)
|
|
at.initializeGridLevelsLocked(currentPrice, gridConfig)
|
|
|
|
// CRITICAL FIX: Restore filled positions - find closest new level for each filled position
|
|
for _, filledLevel := range filledPositions {
|
|
closestIdx := -1
|
|
closestDist := math.MaxFloat64
|
|
|
|
for i, newLevel := range at.gridState.Levels {
|
|
dist := math.Abs(newLevel.Price - filledLevel.PositionEntry)
|
|
if dist < closestDist {
|
|
closestDist = dist
|
|
closestIdx = i
|
|
}
|
|
}
|
|
|
|
if closestIdx >= 0 {
|
|
// Restore the filled state to the closest level
|
|
at.gridState.Levels[closestIdx].State = "filled"
|
|
at.gridState.Levels[closestIdx].PositionEntry = filledLevel.PositionEntry
|
|
at.gridState.Levels[closestIdx].PositionSize = filledLevel.PositionSize
|
|
at.gridState.Levels[closestIdx].UnrealizedPnL = filledLevel.UnrealizedPnL
|
|
at.gridState.Levels[closestIdx].OrderID = filledLevel.OrderID
|
|
at.gridState.Levels[closestIdx].OrderQuantity = filledLevel.OrderQuantity
|
|
logger.Infof("[Grid] Restored filled position at level %d (entry $%.2f)", closestIdx, filledLevel.PositionEntry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// calculateDefaultBoundsLocked calculates default bounds (caller must hold lock)
|
|
func (at *AutoTrader) calculateDefaultBoundsLocked(price float64, config *store.GridStrategyConfig) {
|
|
// Default: +/-3% from current price, scaled by grid count
|
|
multiplier := 0.03 * float64(config.GridCount) / 10
|
|
at.gridState.UpperPrice = price * (1 + multiplier)
|
|
at.gridState.LowerPrice = price * (1 - multiplier)
|
|
}
|
|
|
|
// calculateATRBoundsLocked calculates bounds using ATR (caller must hold lock)
|
|
func (at *AutoTrader) calculateATRBoundsLocked(price float64, mktData *market.Data, config *store.GridStrategyConfig) {
|
|
atr := 0.0
|
|
if mktData.LongerTermContext != nil {
|
|
atr = mktData.LongerTermContext.ATR14
|
|
}
|
|
|
|
if atr <= 0 {
|
|
at.calculateDefaultBoundsLocked(price, config)
|
|
return
|
|
}
|
|
|
|
multiplier := config.ATRMultiplier
|
|
if multiplier <= 0 {
|
|
multiplier = 2.0
|
|
}
|
|
|
|
halfRange := atr * multiplier
|
|
at.gridState.UpperPrice = price + halfRange
|
|
at.gridState.LowerPrice = price - halfRange
|
|
}
|
|
|
|
// initializeGridLevelsLocked creates the grid level structure (caller must hold lock)
|
|
func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *store.GridStrategyConfig) {
|
|
levels := make([]kernel.GridLevelInfo, config.GridCount)
|
|
totalWeight := 0.0
|
|
weights := make([]float64, config.GridCount)
|
|
|
|
// Calculate weights based on distribution
|
|
for i := 0; i < config.GridCount; i++ {
|
|
switch config.Distribution {
|
|
case "gaussian":
|
|
// Gaussian distribution - more weight in the middle
|
|
center := float64(config.GridCount-1) / 2
|
|
sigma := float64(config.GridCount) / 4
|
|
weights[i] = math.Exp(-math.Pow(float64(i)-center, 2) / (2 * sigma * sigma))
|
|
case "pyramid":
|
|
// Pyramid - more weight at bottom
|
|
weights[i] = float64(config.GridCount - i)
|
|
default: // uniform
|
|
weights[i] = 1.0
|
|
}
|
|
totalWeight += weights[i]
|
|
}
|
|
|
|
// Create levels
|
|
for i := 0; i < config.GridCount; i++ {
|
|
price := at.gridState.LowerPrice + float64(i)*at.gridState.GridSpacing
|
|
allocatedUSD := config.TotalInvestment * weights[i] / totalWeight
|
|
|
|
// Determine initial side (below current price = buy, above = sell)
|
|
side := "buy"
|
|
if price > currentPrice {
|
|
side = "sell"
|
|
}
|
|
|
|
levels[i] = kernel.GridLevelInfo{
|
|
Index: i,
|
|
Price: price,
|
|
State: "empty",
|
|
Side: side,
|
|
AllocatedUSD: allocatedUSD,
|
|
}
|
|
}
|
|
|
|
at.gridState.Levels = levels
|
|
|
|
// Apply direction-based side assignment if enabled (note: caller holds lock)
|
|
if config.EnableDirectionAdjust {
|
|
at.applyGridDirectionLocked(currentPrice)
|
|
}
|
|
}
|
|
|
|
// applyGridDirectionLocked adjusts grid level sides based on the current direction (caller must hold lock)
|
|
func (at *AutoTrader) applyGridDirectionLocked(currentPrice float64) {
|
|
config := at.gridState.Config
|
|
direction := at.gridState.CurrentDirection
|
|
|
|
// Get bias ratio from config, default to 0.7 (70%/30%)
|
|
biasRatio := config.DirectionBiasRatio
|
|
if biasRatio <= 0 || biasRatio > 1 {
|
|
biasRatio = 0.7
|
|
}
|
|
|
|
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
|
|
|
|
// For neutral: use price-based assignment (buy below, sell above)
|
|
if direction == market.GridDirectionNeutral {
|
|
for i := range at.gridState.Levels {
|
|
if at.gridState.Levels[i].Price <= currentPrice {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
} else {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
totalLevels := len(at.gridState.Levels)
|
|
targetBuyLevels := int(float64(totalLevels) * buyRatio)
|
|
|
|
switch direction {
|
|
case market.GridDirectionLong:
|
|
for i := range at.gridState.Levels {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
}
|
|
|
|
case market.GridDirectionShort:
|
|
for i := range at.gridState.Levels {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
}
|
|
|
|
case market.GridDirectionLongBias, market.GridDirectionShortBias:
|
|
buyCount := 0
|
|
sellCount := 0
|
|
|
|
for i := range at.gridState.Levels {
|
|
needMoreBuys := buyCount < targetBuyLevels
|
|
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
|
|
|
|
if at.gridState.Levels[i].Price <= currentPrice {
|
|
if needMoreBuys {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
buyCount++
|
|
} else {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
sellCount++
|
|
}
|
|
} else {
|
|
if needMoreSells && direction == market.GridDirectionShortBias {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
sellCount++
|
|
} else if needMoreBuys && direction == market.GridDirectionLongBias {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
buyCount++
|
|
} else if needMoreSells {
|
|
at.gridState.Levels[i].Side = "sell"
|
|
sellCount++
|
|
} else {
|
|
at.gridState.Levels[i].Side = "buy"
|
|
buyCount++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|