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)
528 lines
17 KiB
Go
528 lines
17 KiB
Go
package trader
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"nofx/telemetry"
|
|
"nofx/kernel"
|
|
"nofx/logger"
|
|
"nofx/market"
|
|
"nofx/store"
|
|
"time"
|
|
)
|
|
|
|
// saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision)
|
|
func (at *AutoTrader) saveEquitySnapshot(ctx *kernel.Context) {
|
|
if at.store == nil || ctx == nil {
|
|
return
|
|
}
|
|
|
|
snapshot := &store.EquitySnapshot{
|
|
TraderID: at.id,
|
|
Timestamp: time.Now().UTC(),
|
|
TotalEquity: ctx.Account.TotalEquity,
|
|
Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
|
|
UnrealizedPnL: ctx.Account.UnrealizedPnL,
|
|
PositionCount: ctx.Account.PositionCount,
|
|
MarginUsedPct: ctx.Account.MarginUsedPct,
|
|
}
|
|
|
|
if err := at.store.Equity().Save(snapshot); err != nil {
|
|
logger.Infof("⚠️ Failed to save equity snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
// saveDecision saves AI decision log to database (only records AI input/output, for debugging)
|
|
func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error {
|
|
if at.store == nil {
|
|
return nil
|
|
}
|
|
|
|
at.cycleNumber++
|
|
record.CycleNumber = at.cycleNumber
|
|
record.TraderID = at.id
|
|
|
|
if record.Timestamp.IsZero() {
|
|
record.Timestamp = time.Now().UTC()
|
|
}
|
|
|
|
if err := at.store.Decision().LogDecision(record); err != nil {
|
|
logger.Infof("⚠️ Failed to save decision record: %v", err)
|
|
return err
|
|
}
|
|
|
|
logger.Infof("📝 Decision record saved: trader=%s, cycle=%d", at.id, at.cycleNumber)
|
|
return nil
|
|
}
|
|
|
|
// GetStatus gets system status (for API)
|
|
func (at *AutoTrader) GetStatus() map[string]interface{} {
|
|
aiProvider := "DeepSeek"
|
|
if at.config.UseQwen {
|
|
aiProvider = "Qwen"
|
|
}
|
|
|
|
at.isRunningMutex.RLock()
|
|
isRunning := at.isRunning
|
|
at.isRunningMutex.RUnlock()
|
|
|
|
result := map[string]interface{}{
|
|
"trader_id": at.id,
|
|
"trader_name": at.name,
|
|
"ai_model": at.aiModel,
|
|
"exchange": at.exchange,
|
|
"is_running": isRunning,
|
|
"start_time": at.startTime.Format(time.RFC3339),
|
|
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
|
"call_count": at.callCount,
|
|
"initial_balance": at.initialBalance,
|
|
"scan_interval": at.config.ScanInterval.String(),
|
|
"stop_until": at.stopUntil.Format(time.RFC3339),
|
|
"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)
|
|
func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
|
|
balance, err := at.trader.GetBalance()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get balance: %w", err)
|
|
}
|
|
|
|
// Get account fields
|
|
totalWalletBalance := 0.0
|
|
totalUnrealizedProfit := 0.0
|
|
availableBalance := 0.0
|
|
totalEquity := 0.0
|
|
|
|
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
|
totalWalletBalance = wallet
|
|
}
|
|
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
|
totalUnrealizedProfit = unrealized
|
|
}
|
|
if avail, ok := balance["availableBalance"].(float64); ok {
|
|
availableBalance = avail
|
|
}
|
|
|
|
// Use totalEquity directly if provided by trader (more accurate)
|
|
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
|
totalEquity = eq
|
|
} else {
|
|
// Fallback: Total Equity = Wallet balance + Unrealized profit
|
|
totalEquity = totalWalletBalance + totalUnrealizedProfit
|
|
}
|
|
|
|
// Get positions to calculate total margin
|
|
positions, err := at.trader.GetPositions()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get positions: %w", err)
|
|
}
|
|
|
|
totalMarginUsed := 0.0
|
|
totalUnrealizedPnLCalculated := 0.0
|
|
for _, pos := range positions {
|
|
markPrice := pos["markPrice"].(float64)
|
|
quantity := pos["positionAmt"].(float64)
|
|
if quantity < 0 {
|
|
quantity = -quantity
|
|
}
|
|
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
|
totalUnrealizedPnLCalculated += unrealizedPnl
|
|
|
|
leverage := 10
|
|
if lev, ok := pos["leverage"].(float64); ok {
|
|
leverage = int(lev)
|
|
}
|
|
marginUsed := (quantity * markPrice) / float64(leverage)
|
|
totalMarginUsed += marginUsed
|
|
}
|
|
|
|
// Verify unrealized P&L consistency (API value vs calculated from positions)
|
|
// Note: Lighter API may return 0 for unrealized PnL, this is a known limitation
|
|
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
|
if diff > 5.0 { // Only warn if difference is significant (> 5 USDT)
|
|
logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f",
|
|
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
|
}
|
|
|
|
totalPnL := totalEquity - at.initialBalance
|
|
totalPnLPct := 0.0
|
|
if at.initialBalance > 0 {
|
|
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
|
} else {
|
|
logger.Infof("⚠️ Initial Balance abnormal: %.2f, cannot calculate P&L percentage", at.initialBalance)
|
|
}
|
|
|
|
marginUsedPct := 0.0
|
|
if totalEquity > 0 {
|
|
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
// Core fields
|
|
"total_equity": totalEquity, // Account equity = wallet + unrealized
|
|
"wallet_balance": totalWalletBalance, // Wallet balance (excluding unrealized P&L)
|
|
"unrealized_profit": totalUnrealizedProfit, // Unrealized P&L (official value from exchange API)
|
|
"available_balance": availableBalance, // Available balance
|
|
|
|
// P&L statistics
|
|
"total_pnl": totalPnL, // Total P&L = equity - initial
|
|
"total_pnl_pct": totalPnLPct, // Total P&L percentage
|
|
"initial_balance": at.initialBalance, // Initial balance
|
|
"daily_pnl": at.dailyPnL, // Daily P&L
|
|
|
|
// Position information
|
|
"position_count": len(positions), // Position count
|
|
"margin_used": totalMarginUsed, // Margin used
|
|
"margin_used_pct": marginUsedPct, // Margin usage rate
|
|
}, nil
|
|
}
|
|
|
|
// GetPositions gets position list (for API)
|
|
func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
|
|
positions, err := at.trader.GetPositions()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get positions: %w", err)
|
|
}
|
|
|
|
var result []map[string]interface{}
|
|
for _, pos := range positions {
|
|
symbol := pos["symbol"].(string)
|
|
side := pos["side"].(string)
|
|
entryPrice := pos["entryPrice"].(float64)
|
|
markPrice := pos["markPrice"].(float64)
|
|
quantity := pos["positionAmt"].(float64)
|
|
if quantity < 0 {
|
|
quantity = -quantity
|
|
}
|
|
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
|
liquidationPrice := pos["liquidationPrice"].(float64)
|
|
|
|
leverage := 10
|
|
if lev, ok := pos["leverage"].(float64); ok {
|
|
leverage = int(lev)
|
|
}
|
|
|
|
// Calculate margin used
|
|
marginUsed := (quantity * markPrice) / float64(leverage)
|
|
|
|
// Calculate P&L percentage (based on margin)
|
|
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
|
|
|
result = append(result, map[string]interface{}{
|
|
"symbol": symbol,
|
|
"side": side,
|
|
"entry_price": entryPrice,
|
|
"mark_price": markPrice,
|
|
"quantity": quantity,
|
|
"leverage": leverage,
|
|
"unrealized_pnl": unrealizedPnl,
|
|
"unrealized_pnl_pct": pnlPct,
|
|
"liquidation_price": liquidationPrice,
|
|
"margin_used": marginUsed,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// recordAndConfirmOrder polls order status for actual fill data and records position
|
|
// action: open_long, open_short, close_long, close_short
|
|
// entryPrice: entry price when closing (0 when opening)
|
|
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
|
|
if at.store == nil {
|
|
return
|
|
}
|
|
|
|
// Get order ID (supports multiple types)
|
|
var orderID string
|
|
switch v := orderResult["orderId"].(type) {
|
|
case int64:
|
|
orderID = fmt.Sprintf("%d", v)
|
|
case float64:
|
|
orderID = fmt.Sprintf("%.0f", v)
|
|
case string:
|
|
orderID = v
|
|
default:
|
|
orderID = fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
if orderID == "" || orderID == "0" {
|
|
logger.Infof(" ⚠️ Order ID is empty, skipping record")
|
|
return
|
|
}
|
|
|
|
// Determine positionSide
|
|
var positionSide string
|
|
switch action {
|
|
case "open_long", "close_long":
|
|
positionSide = "LONG"
|
|
case "open_short", "close_short":
|
|
positionSide = "SHORT"
|
|
}
|
|
|
|
var actualPrice = price
|
|
var actualQty = quantity
|
|
var fee float64
|
|
|
|
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
|
// This ensures accurate data from GetTrades API and avoids duplicate records
|
|
switch at.exchange {
|
|
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
|
|
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
|
return
|
|
}
|
|
|
|
// For exchanges without OrderSync (e.g., Binance): record immediately and poll for fill data
|
|
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
|
|
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
|
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
|
} else {
|
|
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
|
|
}
|
|
|
|
// Wait for order to be filled and get actual fill data
|
|
time.Sleep(500 * time.Millisecond)
|
|
for i := 0; i < 5; i++ {
|
|
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
|
if err == nil {
|
|
statusStr, _ := status["status"].(string)
|
|
if statusStr == "FILLED" {
|
|
// Get actual fill price
|
|
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
|
actualPrice = avgPrice
|
|
}
|
|
// Get actual executed quantity
|
|
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
|
actualQty = execQty
|
|
}
|
|
// Get commission/fee
|
|
if commission, ok := status["commission"].(float64); ok {
|
|
fee = commission
|
|
}
|
|
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
|
|
|
// Update order status to FILLED
|
|
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
|
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
|
}
|
|
|
|
// Record fill details
|
|
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
|
|
break
|
|
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
|
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
|
// Update order status
|
|
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
|
|
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
// Normalize symbol for position record consistency
|
|
normalizedSymbolForPosition := market.Normalize(symbol)
|
|
|
|
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
|
|
orderID, action, actualPrice, actualQty, fee)
|
|
|
|
// Record position change with actual fill data (use normalized symbol)
|
|
at.recordPositionChange(orderID, normalizedSymbolForPosition, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
|
|
|
|
// Send anonymous trade statistics for experience improvement (async, non-blocking)
|
|
// This helps us understand overall product usage across all deployments
|
|
telemetry.TrackTrade(telemetry.TradeEvent{
|
|
Exchange: at.exchange,
|
|
TradeType: action,
|
|
Symbol: symbol,
|
|
AmountUSD: actualPrice * actualQty,
|
|
Leverage: leverage,
|
|
UserID: at.userID,
|
|
TraderID: at.id,
|
|
})
|
|
}
|
|
|
|
// recordPositionChange records position change (create record on open, update record on close)
|
|
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) {
|
|
if at.store == nil {
|
|
return
|
|
}
|
|
|
|
switch action {
|
|
case "open_long", "open_short":
|
|
// Open position: create new position record
|
|
nowMs := time.Now().UTC().UnixMilli()
|
|
pos := &store.TraderPosition{
|
|
TraderID: at.id,
|
|
ExchangeID: at.exchangeID, // Exchange account UUID
|
|
ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc
|
|
Symbol: symbol,
|
|
Side: side, // LONG or SHORT
|
|
Quantity: quantity,
|
|
EntryPrice: price,
|
|
EntryOrderID: orderID,
|
|
EntryTime: nowMs,
|
|
Leverage: leverage,
|
|
Status: "OPEN",
|
|
CreatedAt: nowMs,
|
|
UpdatedAt: nowMs,
|
|
}
|
|
if err := at.store.Position().Create(pos); err != nil {
|
|
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
|
} else {
|
|
logger.Infof(" 📊 Position recorded [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
|
|
}
|
|
|
|
case "close_long", "close_short":
|
|
// Close position using PositionBuilder for consistent handling
|
|
// PositionBuilder will handle both cases:
|
|
// 1. If open position exists: close it properly
|
|
// 2. If no open position (e.g., table cleared): create a closed position record
|
|
posBuilder := store.NewPositionBuilder(at.store.Position())
|
|
if err := posBuilder.ProcessTrade(
|
|
at.id, at.exchangeID, at.exchange,
|
|
symbol, side, action,
|
|
quantity, price, fee, 0, // realizedPnL will be calculated
|
|
time.Now().UTC().UnixMilli(), orderID,
|
|
); err != nil {
|
|
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
|
} else {
|
|
logger.Infof(" ✅ Position closed [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
|
|
}
|
|
}
|
|
}
|
|
|
|
// createOrderRecord creates an order record struct from order details
|
|
func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide string, quantity, price float64, leverage int) *store.TraderOrder {
|
|
// Determine order type (market for auto trader)
|
|
orderType := "MARKET"
|
|
|
|
// Determine side (BUY/SELL)
|
|
var side string
|
|
switch action {
|
|
case "open_long", "close_short":
|
|
side = "BUY"
|
|
case "open_short", "close_long":
|
|
side = "SELL"
|
|
}
|
|
|
|
// Use action as orderAction directly (keep lowercase format)
|
|
orderAction := action
|
|
|
|
// Determine if it's a reduce only order
|
|
reduceOnly := (action == "close_long" || action == "close_short")
|
|
|
|
// Normalize symbol for consistency
|
|
normalizedSymbol := market.Normalize(symbol)
|
|
|
|
return &store.TraderOrder{
|
|
TraderID: at.id,
|
|
ExchangeID: at.exchangeID,
|
|
ExchangeType: at.exchange,
|
|
ExchangeOrderID: orderID,
|
|
Symbol: normalizedSymbol,
|
|
Side: side,
|
|
PositionSide: positionSide,
|
|
Type: orderType,
|
|
TimeInForce: "GTC",
|
|
Quantity: quantity,
|
|
Price: price,
|
|
Status: "NEW",
|
|
FilledQuantity: 0,
|
|
AvgFillPrice: 0,
|
|
Commission: 0,
|
|
CommissionAsset: "USDT",
|
|
Leverage: leverage,
|
|
ReduceOnly: reduceOnly,
|
|
ClosePosition: reduceOnly,
|
|
OrderAction: orderAction,
|
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
|
UpdatedAt: time.Now().UTC().UnixMilli(),
|
|
}
|
|
}
|
|
|
|
// recordOrderFill records order fill/trade details
|
|
func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symbol, action string, price, quantity, fee float64) {
|
|
if at.store == nil {
|
|
return
|
|
}
|
|
|
|
// Determine side (BUY/SELL)
|
|
var side string
|
|
switch action {
|
|
case "open_long", "close_short":
|
|
side = "BUY"
|
|
case "open_short", "close_long":
|
|
side = "SELL"
|
|
}
|
|
|
|
// Generate a simple trade ID (exchange doesn't always provide one)
|
|
tradeID := fmt.Sprintf("%s-%d", exchangeOrderID, time.Now().UnixNano())
|
|
|
|
// Normalize symbol for consistency
|
|
normalizedSymbol := market.Normalize(symbol)
|
|
|
|
fill := &store.TraderFill{
|
|
TraderID: at.id,
|
|
ExchangeID: at.exchangeID,
|
|
ExchangeType: at.exchange,
|
|
OrderID: orderRecordID,
|
|
ExchangeOrderID: exchangeOrderID,
|
|
ExchangeTradeID: tradeID,
|
|
Symbol: normalizedSymbol,
|
|
Side: side,
|
|
Price: price,
|
|
Quantity: quantity,
|
|
QuoteQuantity: price * quantity,
|
|
Commission: fee,
|
|
CommissionAsset: "USDT",
|
|
RealizedPnL: 0, // Will be calculated for close orders
|
|
IsMaker: false, // Market orders are usually taker
|
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
|
}
|
|
|
|
// Calculate realized PnL for close orders
|
|
if action == "close_long" || action == "close_short" {
|
|
// Try to get the entry price from the open position
|
|
var positionSide string
|
|
if action == "close_long" {
|
|
positionSide = "LONG"
|
|
} else {
|
|
positionSide = "SHORT"
|
|
}
|
|
|
|
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, positionSide); err == nil && openPos != nil {
|
|
if positionSide == "LONG" {
|
|
fill.RealizedPnL = (price - openPos.EntryPrice) * quantity
|
|
} else {
|
|
fill.RealizedPnL = (openPos.EntryPrice - price) * quantity
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := at.store.Order().CreateFill(fill); err != nil {
|
|
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
|
} else {
|
|
logger.Infof(" 📋 Fill recorded: %.4f @ %.6f, fee: %.4f", quantity, price, fee)
|
|
}
|
|
}
|
|
|
|
// GetOpenOrders returns open orders (pending SL/TP) from exchange
|
|
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|
return at.trader.GetOpenOrders(symbol)
|
|
}
|