feat: add AI grid trading and market regime classification

- Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook
- Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter)
- Add grid engine with ATR-based boundary calculation and fund distribution
- Add market regime classification documents (Chinese/English)
- Add GridConfigEditor component for frontend configuration
This commit is contained in:
tinkle-community
2026-01-13 10:33:02 +08:00
parent 705aa641b0
commit 5fb26c17dc
22 changed files with 3901 additions and 32 deletions

View File

@@ -1420,3 +1420,144 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Aster open orders
return []OpenOrder{}, nil
}
// PlaceLimitOrder places a limit order for grid trading
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
if err != nil {
return nil, fmt.Errorf("failed to format price: %w", err)
}
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Get precision information
prec, err := t.getPrecision(req.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get precision: %w", err)
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
// Determine side
side := "BUY"
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
side = "SELL"
}
params := map[string]interface{}{
"symbol": req.Symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": side,
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
// Add reduceOnly if specified
if req.ReduceOnly {
params["reduceOnly"] = "true"
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
// Extract order ID
orderID := ""
if id, ok := result["orderId"].(float64); ok {
orderID = fmt.Sprintf("%.0f", id)
} else if id, ok := result["orderId"].(string); ok {
orderID = id
}
// Extract client order ID
clientOrderID := ""
if cid, ok := result["clientOrderId"].(string); ok {
clientOrderID = cid
}
return &LimitOrderResult{
OrderID: orderID,
ClientID: clientOrderID,
Symbol: req.Symbol,
Side: side,
Price: formattedPrice,
Quantity: formattedQty,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by order ID
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
params := map[string]interface{}{
"symbol": symbol,
"orderId": orderID,
}
_, err := t.request("DELETE", "/fapi/v3/order", params)
if err != nil {
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
}
return nil
}
// GetOrderBook gets the order book for a symbol
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
if depth <= 0 {
depth = 20
}
// Aster uses public endpoint (no signature required)
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Bids [][]string `json:"bids"` // [[price, qty], ...]
Asks [][]string `json:"asks"` // [[price, qty], ...]
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
// Convert string arrays to float64 arrays
bids = make([][]float64, len(result.Bids))
for i, bid := range result.Bids {
if len(bid) >= 2 {
price, _ := strconv.ParseFloat(bid[0], 64)
qty, _ := strconv.ParseFloat(bid[1], 64)
bids[i] = []float64{price, qty}
}
}
asks = make([][]float64, len(result.Asks))
for i, ask := range result.Asks {
if len(ask) >= 2 {
price, _ := strconv.ParseFloat(ask[0], 64)
qty, _ := strconv.ParseFloat(ask[1], 64)
asks[i] = []float64{price, qty}
}
}
return bids, asks, nil
}

View File

@@ -123,6 +123,7 @@ type AutoTrader struct {
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
lastBalanceSyncTime time.Time // Last balance sync time
userID string // User ID
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
}
// NewAutoTrader creates an automatic trader
@@ -419,9 +420,25 @@ func (at *AutoTrader) Run() error {
ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop()
// Check if this is a grid trading strategy
isGridStrategy := at.IsGridStrategy()
if isGridStrategy {
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
if err := at.InitializeGrid(); err != nil {
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
return fmt.Errorf("grid initialization failed: %w", err)
}
}
// Execute immediately on first run
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
if isGridStrategy {
if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
}
for {
@@ -435,8 +452,14 @@ func (at *AutoTrader) Run() error {
select {
case <-ticker.C:
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
if isGridStrategy {
if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
}
case <-at.stopMonitorCh:
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
@@ -1365,6 +1388,12 @@ func (at *AutoTrader) GetID() string {
return at.id
}
// GetUnderlyingTrader returns the underlying Trader interface implementation
// This is used by grid trading and other components that need direct exchange access
func (at *AutoTrader) GetUnderlyingTrader() Trader {
return at.trader
}
// GetName gets trader name
func (at *AutoTrader) GetName() string {
return at.name
@@ -1471,7 +1500,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
isRunning := at.isRunning
at.isRunningMutex.RUnlock()
return map[string]interface{}{
result := map[string]interface{}{
"trader_id": at.id,
"trader_name": at.name,
"ai_model": at.aiModel,
@@ -1486,6 +1515,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
"ai_provider": aiProvider,
}
// Add strategy info
if at.config.StrategyConfig != nil {
result["strategy_type"] = at.config.StrategyConfig.StrategyType
if at.config.StrategyConfig.GridConfig != nil {
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
}
}
return result
}
// GetAccountInfo gets account information (for API)

579
trader/auto_trader_grid.go Normal file
View File

@@ -0,0 +1,579 @@
package trader
import (
"encoding/json"
"fmt"
"math"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"sync"
"time"
)
// ============================================================================
// Grid Trading State Management
// ============================================================================
// GridState holds the runtime state for grid trading
type GridState struct {
mu sync.RWMutex
// Configuration
Config *store.GridStrategyConfig
// Grid levels
Levels []kernel.GridLevelInfo
// Calculated bounds
UpperPrice float64
LowerPrice float64
GridSpacing float64
// State flags
IsPaused bool
IsInitialized bool
// Performance tracking
TotalProfit float64
TotalTrades int
WinningTrades int
MaxDrawdown float64
PeakEquity float64
DailyPnL float64
LastDailyReset time.Time
// Order tracking
OrderBook map[string]int // OrderID -> LevelIndex
}
// NewGridState creates a new grid state
func NewGridState(config *store.GridStrategyConfig) *GridState {
return &GridState{
Config: config,
Levels: make([]kernel.GridLevelInfo, 0),
OrderBook: make(map[string]int),
}
}
// ============================================================================
// AutoTrader Grid Methods
// ============================================================================
// InitializeGrid initializes the grid state and calculates levels
func (at *AutoTrader) InitializeGrid() error {
if at.config.StrategyConfig == nil || at.config.StrategyConfig.GridConfig == nil {
return fmt.Errorf("grid configuration not found")
}
gridConfig := at.config.StrategyConfig.GridConfig
at.gridState = NewGridState(gridConfig)
// Get current market price
price, err := at.trader.GetMarketPrice(gridConfig.Symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
// Calculate grid bounds
if gridConfig.UseATRBounds {
// Get ATR for bound calculation
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"4h"}, "4h", 20)
if err != nil {
logger.Warnf("Failed to get market data for ATR: %v, using default bounds", err)
at.calculateDefaultBounds(price, gridConfig)
} else {
at.calculateATRBounds(price, mktData, gridConfig)
}
} else {
// Use manual bounds
at.gridState.UpperPrice = gridConfig.UpperPrice
at.gridState.LowerPrice = gridConfig.LowerPrice
}
// Calculate grid spacing
at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1)
// Initialize grid levels
at.initializeGridLevels(price, gridConfig)
at.gridState.IsInitialized = true
logger.Infof("📊 [Grid] Initialized: %d levels, $%.2f - $%.2f, spacing $%.2f",
gridConfig.GridCount, at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing)
return nil
}
// 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
}
// RunGridCycle executes one grid trading cycle
func (at *AutoTrader) RunGridCycle() error {
if at.gridState == nil || !at.gridState.IsInitialized {
if err := at.InitializeGrid(); err != nil {
return fmt.Errorf("failed to initialize grid: %w", err)
}
}
gridConfig := at.config.StrategyConfig.GridConfig
lang := at.config.StrategyConfig.Language
if lang == "" {
lang = "en"
}
// Build grid context
gridCtx, err := at.buildGridContext()
if err != nil {
return fmt.Errorf("failed to build grid context: %w", err)
}
// Get AI decisions
decision, err := kernel.GetGridDecisions(gridCtx, at.mcpClient, gridConfig, lang)
if err != nil {
return fmt.Errorf("failed to get grid decisions: %w", err)
}
// Execute decisions
for _, d := range decision.Decisions {
if err := at.executeGridDecision(&d); err != nil {
logger.Warnf("[Grid] Failed to execute decision %s: %v", d.Action, err)
}
}
// Sync state with exchange
at.syncGridState()
// Save decision record
at.saveGridDecisionRecord(decision)
return nil
}
// buildGridContext builds the context for AI grid decisions
func (at *AutoTrader) buildGridContext() (*kernel.GridContext, error) {
gridConfig := at.config.StrategyConfig.GridConfig
// Get market data
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"5m", "4h"}, "5m", 50)
if err != nil {
return nil, fmt.Errorf("failed to get market data: %w", err)
}
// Build base context from market data
ctx := kernel.BuildGridContextFromMarketData(mktData, gridConfig)
// Add grid state
at.gridState.mu.RLock()
ctx.Levels = at.gridState.Levels
ctx.UpperPrice = at.gridState.UpperPrice
ctx.LowerPrice = at.gridState.LowerPrice
ctx.GridSpacing = at.gridState.GridSpacing
ctx.IsPaused = at.gridState.IsPaused
ctx.TotalProfit = at.gridState.TotalProfit
ctx.TotalTrades = at.gridState.TotalTrades
ctx.WinningTrades = at.gridState.WinningTrades
ctx.MaxDrawdown = at.gridState.MaxDrawdown
ctx.DailyPnL = at.gridState.DailyPnL
// Count active orders and filled levels
for _, level := range at.gridState.Levels {
if level.State == "pending" {
ctx.ActiveOrderCount++
} else if level.State == "filled" {
ctx.FilledLevelCount++
}
}
at.gridState.mu.RUnlock()
// Get account info
balance, err := at.trader.GetBalance()
if err == nil {
if equity, ok := balance["total_equity"].(float64); ok {
ctx.TotalEquity = equity
}
if available, ok := balance["availableBalance"].(float64); ok {
ctx.AvailableBalance = available
}
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
ctx.UnrealizedPnL = unrealized
}
}
// Get current position
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
if size, ok := pos["positionAmt"].(float64); ok {
ctx.CurrentPosition = size
}
}
}
}
return ctx, nil
}
// executeGridDecision executes a single grid decision
func (at *AutoTrader) executeGridDecision(d *kernel.Decision) error {
switch d.Action {
case "place_buy_limit":
return at.placeGridLimitOrder(d, "BUY")
case "place_sell_limit":
return at.placeGridLimitOrder(d, "SELL")
case "cancel_order":
return at.cancelGridOrder(d)
case "cancel_all_orders":
return at.cancelAllGridOrders()
case "pause_grid":
return at.pauseGrid(d.Reasoning)
case "resume_grid":
return at.resumeGrid()
case "adjust_grid":
return at.adjustGrid(d)
case "hold":
logger.Infof("[Grid] Holding current state: %s", d.Reasoning)
return nil
// Support standard actions for closing positions
case "close_long":
_, err := at.trader.CloseLong(d.Symbol, d.Quantity)
return err
case "close_short":
_, err := at.trader.CloseShort(d.Symbol, d.Quantity)
return err
default:
logger.Warnf("[Grid] Unknown action: %s", d.Action)
return nil
}
}
// 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
req := &LimitOrderRequest{
Symbol: d.Symbol,
Side: side,
Price: d.Price,
Quantity: d.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
}
// Update levels based on order status
at.gridState.mu.Lock()
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 - might be filled or cancelled
// Mark as filled (we'll need to verify with position data)
level.State = "filled"
level.PositionEntry = level.Price
at.gridState.TotalTrades++
logger.Infof("[Grid] Level %d order filled at $%.2f", i, level.Price)
}
}
}
at.gridState.mu.Unlock()
// Update position info
positions, err := at.trader.GetPositions()
if err != nil {
return
}
var totalPosition float64
for _, pos := range positions {
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
if size, ok := pos["positionAmt"].(float64); ok {
totalPosition = size
}
if pnl, ok := pos["unRealizedProfit"].(float64); ok {
// Update unrealized PnL for filled levels
at.gridState.mu.Lock()
for i := range at.gridState.Levels {
if at.gridState.Levels[i].State == "filled" {
// Distribute PnL (simplified - in production, track per-level)
at.gridState.Levels[i].UnrealizedPnL = pnl / float64(at.gridState.TotalTrades)
}
}
at.gridState.mu.Unlock()
}
}
}
logger.Debugf("[Grid] Synced state: position=%.4f, orders=%d", totalPosition, len(openOrders))
}
// saveGridDecisionRecord saves the grid decision to database
func (at *AutoTrader) saveGridDecisionRecord(decision *kernel.FullDecision) {
if at.store == nil {
return
}
at.cycleNumber++
record := &store.DecisionRecord{
TraderID: at.id,
CycleNumber: at.cycleNumber,
Timestamp: time.Now().UTC(),
SystemPrompt: decision.SystemPrompt,
InputPrompt: decision.UserPrompt,
CoTTrace: decision.CoTTrace,
RawResponse: decision.RawResponse,
AIRequestDurationMs: decision.AIRequestDurationMs,
Success: true,
}
if len(decision.Decisions) > 0 {
decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ")
record.DecisionJSON = string(decisionJSON)
// Convert kernel.Decision to store.DecisionAction for frontend display
for _, d := range decision.Decisions {
actionRecord := store.DecisionAction{
Action: d.Action,
Symbol: d.Symbol,
Quantity: d.Quantity,
Leverage: d.Leverage,
Price: d.Price,
StopLoss: d.StopLoss,
TakeProfit: d.TakeProfit,
Confidence: d.Confidence,
Reasoning: d.Reasoning,
Timestamp: time.Now().UTC(),
Success: true, // Grid decisions are executed inline
}
record.Decisions = append(record.Decisions, actionRecord)
}
}
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("Grid cycle completed with %d decisions", len(decision.Decisions)))
if err := at.store.Decision().LogDecision(record); err != nil {
logger.Warnf("[Grid] Failed to save decision record: %v", err)
}
}
// IsGridStrategy returns true if current strategy is grid trading
func (at *AutoTrader) IsGridStrategy() bool {
if at.config.StrategyConfig == nil {
return false
}
return at.config.StrategyConfig.StrategyType == "grid_trading" && at.config.StrategyConfig.GridConfig != nil
}

View File

@@ -716,6 +716,125 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
return nil
}
// PlaceLimitOrder places a limit order for grid trading
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Format price to correct precision
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
if err != nil {
return nil, fmt.Errorf("failed to format price: %w", err)
}
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("Failed to set leverage: %v", err)
}
}
// Determine side and position side
var side futures.SideType
var positionSide futures.PositionSideType
if req.Side == "BUY" {
side = futures.SideTypeBuy
positionSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeSell
positionSide = futures.PositionSideTypeShort
}
// Build order service with broker ID
orderService := t.client.NewCreateOrderService().
Symbol(req.Symbol).
Side(side).
PositionSide(positionSide).
Type(futures.OrderTypeLimit).
TimeInForce(futures.TimeInForceTypeGTC).
Quantity(quantityStr).
Price(priceStr).
NewClientOrderID(getBrOrderID())
// Execute order
order, err := orderService.Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
return &LimitOrderResult{
OrderID: fmt.Sprintf("%d", order.OrderID),
ClientID: order.ClientOrderID,
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Price: req.Price,
Quantity: req.Quantity,
Status: string(order.Status),
}, nil
}
// CancelOrder cancels a specific order by ID
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
// Parse order ID to int64
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return fmt.Errorf("invalid order ID: %w", err)
}
_, err = t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(orderIDInt).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
book, err := t.client.NewDepthService().
Symbol(symbol).
Limit(depth).
Do(context.Background())
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
// Convert bids
bids = make([][]float64, len(book.Bids))
for i, bid := range book.Bids {
price, _ := strconv.ParseFloat(bid.Price, 64)
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
bids[i] = []float64{price, qty}
}
// Convert asks
asks = make([][]float64, len(book.Asks))
for i, ask := range book.Asks {
price, _ := strconv.ParseFloat(ask.Price, 64)
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
asks[i] = []float64{price, qty}
}
return bids, asks, nil
}
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
@@ -1035,6 +1154,42 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
return fmt.Sprintf(format, quantity), nil
}
// GetSymbolPricePrecision gets the price precision for a trading pair
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get trading rules: %w", err)
}
for _, s := range exchangeInfo.Symbols {
if s.Symbol == symbol {
// Get precision from PRICE_FILTER filter
for _, filter := range s.Filters {
if filter["filterType"] == "PRICE_FILTER" {
tickSize := filter["tickSize"].(string)
precision := calculatePrecision(tickSize)
return precision, nil
}
}
}
}
// Default to 2 decimal places for price
return 2, nil
}
// FormatPrice formats price to correct precision
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
precision, err := t.GetSymbolPricePrecision(symbol)
if err != nil {
// If retrieval fails, use default format
return fmt.Sprintf("%.2f", price), nil
}
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, price), nil
}
// Helper functions
func contains(s, substr string) bool {
return len(s) >= len(substr) && stringContains(s, substr)

View File

@@ -1102,3 +1102,134 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Bitget open orders
return []OpenOrder{}, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
symbol := t.convertSymbol(req.Symbol)
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
}
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
// Determine side
side := "buy"
if req.Side == "SELL" {
side = "sell"
}
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": side,
"orderType": "limit",
"size": qtyStr,
"price": fmt.Sprintf("%.8f", req.Price),
"force": "GTC", // Good Till Cancel
"clientOid": genBitgetClientOid(),
}
// Add reduce only if specified
if req.ReduceOnly {
body["reduceOnly"] = "YES"
}
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
ClientOid string `json:"clientOid"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
symbol, side, req.Price, order.OrderId)
return &LimitOrderResult{
OrderID: order.OrderId,
ClientID: order.ClientOid,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
symbol = t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"orderId": orderID,
}
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
symbol = t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
var result struct {
Bids [][]string `json:"bids"`
Asks [][]string `json:"asks"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
// Parse bids
for _, b := range result.Bids {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result.Asks {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

@@ -1105,3 +1105,159 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format quantity
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Format price
priceStr := fmt.Sprintf("%.8f", req.Price)
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
}
}
// Determine side
side := "Buy"
if req.Side == "SELL" {
side = "Sell"
}
params := map[string]interface{}{
"category": "linear",
"symbol": req.Symbol,
"side": side,
"orderType": "Limit",
"qty": qtyStr,
"price": priceStr,
"timeInForce": "GTC", // Good Till Cancel
"positionIdx": 0, // One-way position mode
}
// Add reduce only if specified
if req.ReduceOnly {
params["reduceOnly"] = true
}
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
// Parse result
orderID := ""
if result.RetCode == 0 {
if resultData, ok := result.Result.(map[string]interface{}); ok {
if id, ok := resultData["orderId"].(string); ok {
orderID = id
}
}
} else {
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
}
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
req.Symbol, side, priceStr, qtyStr, orderID)
return &LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderId": orderID,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
if result.RetCode != 0 {
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
}
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
if depth <= 0 {
depth = 25
}
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
resp, err := http.Get(url)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
RetCode int `json:"retCode"`
RetMsg string `json:"retMsg"`
Result struct {
S string `json:"s"` // symbol
B [][]string `json:"b"` // bids [[price, size], ...]
A [][]string `json:"a"` // asks [[price, size], ...]
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if result.RetCode != 0 {
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
}
// Parse bids
for _, b := range result.Result.B {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result.Result.A {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

@@ -2114,3 +2114,118 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
coin := convertSymbolToHyperliquid(req.Symbol)
// Set leverage if specified and not xyz dex
isXyz := strings.HasPrefix(coin, "xyz:")
if req.Leverage > 0 && !isXyz {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err)
}
}
// Round quantity to allowed decimals
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
// Round price to 5 significant figures
roundedPrice := t.roundPriceToSigfigs(req.Price)
// Determine if buy or sell
isBuy := req.Side == "BUY"
logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity,
Price: roundedPrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders
},
},
ReduceOnly: req.ReduceOnly,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
// Note: Hyperliquid's Order response doesn't return the order ID directly
// We would need to query open orders to get it, but for grid trading
// we can track orders by price level instead
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
coin, req.Side, roundedPrice)
return &LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: roundedPrice,
Quantity: roundedQuantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error {
coin := convertSymbolToHyperliquid(symbol)
// Parse order ID
oid, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return fmt.Errorf("invalid order ID: %w", err)
}
_, err = t.exchange.Cancel(t.ctx, coin, oid)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
coin := convertSymbolToHyperliquid(symbol)
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
if l2Book == nil || len(l2Book.Levels) < 2 {
return nil, nil, fmt.Errorf("invalid order book data")
}
// Parse bids (first level array)
for i, level := range l2Book.Levels[0] {
if i >= depth {
break
}
bids = append(bids, []float64{level.Px, level.Sz})
}
// Parse asks (second level array)
for i, level := range l2Book.Levels[1] {
if i >= depth {
break
}
asks = append(asks, []float64{level.Px, level.Sz})
}
return bids, asks, nil
}

View File

@@ -112,3 +112,95 @@ type OpenOrder struct {
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW
}
// LimitOrderRequest represents a limit order request for grid trading
type LimitOrderRequest struct {
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode)
Price float64 `json:"price"` // Limit price
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
PostOnly bool `json:"post_only"` // Maker only order
ReduceOnly bool `json:"reduce_only"` // Reduce position only
ClientID string `json:"client_id"` // Client order ID for tracking
}
// LimitOrderResult represents the result of placing a limit order
type LimitOrderResult struct {
OrderID string `json:"order_id"`
ClientID string `json:"client_id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PositionSide string `json:"position_side"`
Price float64 `json:"price"`
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED
}
// GridTrader extends Trader interface with limit order support for grid trading
// Exchanges that support grid trading should implement this interface
type GridTrader interface {
Trader
// PlaceLimitOrder places a limit order at specified price
// Returns order ID and status
PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error)
// CancelOrder cancels a specific order by ID
CancelOrder(symbol, orderID string) error
// GetOrderBook gets current order book (for price validation)
// Returns best bid/ask prices
GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error)
}
// GridTraderAdapter wraps a basic Trader to provide GridTrader interface
// Uses stop orders as a fallback when limit orders aren't directly available
type GridTraderAdapter struct {
Trader
}
// NewGridTraderAdapter creates an adapter for basic Trader
func NewGridTraderAdapter(t Trader) *GridTraderAdapter {
return &GridTraderAdapter{Trader: t}
}
// PlaceLimitOrder implements limit order using available methods
// For exchanges without native limit order support, this uses conditional orders
func (a *GridTraderAdapter) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Use SetStopLoss/SetTakeProfit as conditional limit orders
// For buy orders below current price, use stop-loss mechanism
// For sell orders above current price, use take-profit mechanism
var err error
if req.Side == "BUY" {
err = a.Trader.SetStopLoss(req.Symbol, "SHORT", req.Quantity, req.Price)
} else {
err = a.Trader.SetTakeProfit(req.Symbol, "LONG", req.Quantity, req.Price)
}
if err != nil {
return nil, err
}
return &LimitOrderResult{
OrderID: req.ClientID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order
func (a *GridTraderAdapter) CancelOrder(symbol, orderID string) error {
// Fallback: cancel all orders for the symbol
return a.Trader.CancelAllOrders(symbol)
}
// GetOrderBook returns empty order book (not supported in basic Trader)
func (a *GridTraderAdapter) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
// Not supported, return empty
return nil, nil, nil
}

View File

@@ -328,12 +328,13 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin
return fmt.Sprintf("%.4f", quantity), nil
}
// GetOrderBook Get order book with best bid/ask prices
func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64, err error) {
// GetOrderBook Get order book (implements GridTrader interface)
// Returns bids and asks as [][]float64 where each element is [price, quantity]
func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
// Get market_id first
marketID, err := t.getMarketIndex(symbol)
if err != nil {
return 0, 0, fmt.Errorf("failed to get market ID: %w", err)
return nil, nil, fmt.Errorf("failed to get market ID: %w", err)
}
// Get order book from Lighter API
@@ -341,22 +342,22 @@ func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64,
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return 0, 0, err
return nil, nil, err
}
resp, err := t.client.Do(req)
if err != nil {
return 0, 0, err
return nil, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, err
return nil, nil, err
}
if resp.StatusCode != http.StatusOK {
return 0, 0, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
return nil, nil, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
@@ -369,35 +370,61 @@ func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64,
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return 0, 0, fmt.Errorf("failed to parse order book: %w", err)
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if apiResp.Code != 200 {
return 0, 0, fmt.Errorf("API error code: %d", apiResp.Code)
return nil, nil, fmt.Errorf("API error code: %d", apiResp.Code)
}
// Get best bid (highest buy price)
if len(apiResp.Data.Bids) > 0 && len(apiResp.Data.Bids[0]) >= 1 {
if price, ok := apiResp.Data.Bids[0][0].(float64); ok {
bestBid = price
} else if priceStr, ok := apiResp.Data.Bids[0][0].(string); ok {
bestBid, _ = strconv.ParseFloat(priceStr, 64)
// Helper to parse price/quantity from interface{}
parseFloat := func(v interface{}) float64 {
if f, ok := v.(float64); ok {
return f
}
if s, ok := v.(string); ok {
f, _ := strconv.ParseFloat(s, 64)
return f
}
return 0
}
// Convert bids to [][]float64
maxBids := len(apiResp.Data.Bids)
if depth > 0 && depth < maxBids {
maxBids = depth
}
bids = make([][]float64, 0, maxBids)
for i := 0; i < maxBids; i++ {
if len(apiResp.Data.Bids[i]) >= 2 {
price := parseFloat(apiResp.Data.Bids[i][0])
qty := parseFloat(apiResp.Data.Bids[i][1])
if price > 0 && qty > 0 {
bids = append(bids, []float64{price, qty})
}
}
}
// Get best ask (lowest sell price)
if len(apiResp.Data.Asks) > 0 && len(apiResp.Data.Asks[0]) >= 1 {
if price, ok := apiResp.Data.Asks[0][0].(float64); ok {
bestAsk = price
} else if priceStr, ok := apiResp.Data.Asks[0][0].(string); ok {
bestAsk, _ = strconv.ParseFloat(priceStr, 64)
// Convert asks to [][]float64
maxAsks := len(apiResp.Data.Asks)
if depth > 0 && depth < maxAsks {
maxAsks = depth
}
asks = make([][]float64, 0, maxAsks)
for i := 0; i < maxAsks; i++ {
if len(apiResp.Data.Asks[i]) >= 2 {
price := parseFloat(apiResp.Data.Asks[i][0])
qty := parseFloat(apiResp.Data.Asks[i][1])
if price > 0 && qty > 0 {
asks = append(asks, []float64{price, qty})
}
}
}
if bestBid <= 0 || bestAsk <= 0 {
return 0, 0, fmt.Errorf("invalid order book prices: bid=%.2f, ask=%.2f", bestBid, bestAsk)
if len(bids) > 0 && len(asks) > 0 {
logger.Infof("✓ Lighter order book: %s best_bid=%.2f, best_ask=%.2f, depth=%d/%d",
symbol, bids[0][0], asks[0][0], len(bids), len(asks))
}
logger.Infof("✓ Lighter order book: %s bid=%.2f, ask=%.2f", symbol, bestBid, bestAsk)
return bestBid, bestAsk, nil
return bids, asks, nil
}

View File

@@ -692,3 +692,45 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Lighter open orders
return []OpenOrder{}, nil
}
// PlaceLimitOrder implements GridTrader interface for grid trading
// Places a limit order at the specified price
func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
if t.txClient == nil {
return nil, fmt.Errorf("TxClient not initialized")
}
// Determine if this is a sell (ask) order
isAsk := req.Side == "SELL"
logger.Infof("📝 LIGHTER placing limit order: %s %s @ %.4f, qty=%.4f",
req.Symbol, req.Side, req.Price, req.Quantity)
// Create limit order using existing CreateOrder function
orderResult, err := t.CreateOrder(req.Symbol, isAsk, req.Quantity, req.Price, "limit", req.ReduceOnly)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
// Extract order ID from result
orderID := ""
if id, ok := orderResult["orderId"]; ok {
orderID = fmt.Sprintf("%v", id)
} else if txHash, ok := orderResult["tx_hash"]; ok {
orderID = fmt.Sprintf("%v", txHash)
}
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
req.Symbol, req.Side, req.Price, orderID)
return &LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}

View File

@@ -1393,3 +1393,155 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement OKX open orders
return []OpenOrder{}, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
instId := t.convertSymbol(req.Symbol)
// Get instrument info
inst, err := t.getInstrument(req.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[OKX] Failed to set leverage: %v", err)
}
}
// Convert quantity to contract size
sz := req.Quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine side and position side
side := "buy"
posSide := "long"
if req.Side == "SELL" {
side = "sell"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "limit",
"sz": szStr,
"px": fmt.Sprintf("%.8f", req.Price),
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Add reduce only if specified
if req.ReduceOnly {
body["reduceOnly"] = true
}
logger.Infof("[OKX] PlaceLimitOrder: %s %s @ %.4f, sz=%s", instId, side, req.Price, szStr)
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 {
return nil, fmt.Errorf("empty order response")
}
if orders[0].SCode != "0" {
return nil, fmt.Errorf("OKX order failed: %s", orders[0].SMsg)
}
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
instId, side, req.Price, orders[0].OrdId)
return &LimitOrderResult{
OrderID: orders[0].OrdId,
ClientID: orders[0].ClOrdId,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *OKXTrader) CancelOrder(symbol, orderID string) error {
instId := t.convertSymbol(symbol)
body := map[string]interface{}{
"instId": instId,
"ordId": orderID,
}
_, err := t.doRequest("POST", "/api/v5/trade/cancel-order", body)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [OKX] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *OKXTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v5/market/books?instId=%s&sz=%d", instId, depth)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
var result []struct {
Bids [][]string `json:"bids"`
Asks [][]string `json:"asks"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if len(result) == 0 {
return nil, nil, nil
}
// Parse bids
for _, b := range result[0].Bids {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result[0].Asks {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}