mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 14:00:57 +08:00
* 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 * fix: implement GetOpenOrders for Lighter exchange * debug: add logging for Lighter GetActiveOrders API call * fix: correct Lighter API response parsing for GetOpenOrders - Changed response field from 'data' to 'orders' to match Lighter API - Updated OrderResponse struct to match Lighter's actual field names - Fixed field types: price/quantity as strings, is_ask for side * feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges - Aster: uses /fapi/v3/openOrders endpoint - OKX: uses /api/v5/trade/orders-pending and orders-algo-pending - Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending * fix: address code review issues for GetOpenOrders - Add error logging for OKX/Bitget API failures (was silently swallowed) - Fix Lighter position side logic to handle reduce-only orders - Change verbose debug logs from Infof to Debugf level * fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck * fix: use auth query parameter instead of Authorization header for Lighter API * test: add Lighter API authentication tests and diagnostic tools * fix(grid): add leverage setting before order placement CRITICAL BUG FIX: - Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder() - Set leverage during grid initialization - Log leverage setting results * fix(grid): prevent CancelOrder from canceling all orders CRITICAL BUG FIX: - CancelOrder no longer calls CancelAllOrders - Try exchange-specific CancelOrder if available - Return error if individual cancellation not supported * fix(grid): add total position value limit check CRITICAL: Prevent excessive position accumulation - New checkTotalPositionLimit() function - Checks current + pending + new order value - Rejects orders that would exceed TotalInvestment x Leverage - Logs clear error messages when limit exceeded * feat(grid): implement stop loss execution CRITICAL: Add code-level stop loss protection - New checkAndExecuteStopLoss() function - Checks each filled level against StopLossPct - Automatically closes positions exceeding stop loss - Called during every grid state sync * feat(grid): add breakout detection and auto-pause CRITICAL: Detect price breakout from grid range - New checkBreakout() function to detect upper/lower breakouts - Auto-pause grid on significant breakout (>2%) - Cancel all orders when breakout detected - Prevent continued losses in trending market - Minor breakouts (1-2%) logged for AI consideration * feat(grid): enforce max drawdown limit with emergency exit CRITICAL: Add drawdown protection - New checkMaxDrawdown() function tracks peak equity - emergencyExit() closes all positions and cancels orders - Auto-pause grid when MaxDrawdownPct exceeded - Protect capital from excessive losses * feat(grid): enforce daily loss limit - Add checkDailyLossLimit() function to check if daily loss exceeds limit - Track daily PnL with auto-reset at midnight - Pause grid when DailyLossLimitPct exceeded - Add updateDailyPnL() helper for realized PnL tracking - Prevent excessive single-day losses * fix(grid): update daily PnL when stop loss is executed The updateDailyPnL() function was added but never called, leaving DailyPnL always at 0 and preventing daily loss limit checks from triggering. This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss() when a stop loss is executed. We update directly rather than calling updateDailyPnL() because the mutex is already held in that function. * feat(grid): add automatic grid adjustment - New checkGridSkew() detects imbalanced grid - autoAdjustGrid() reinitializes around current price - Prevents grid from becoming ineffective after drift - Triggers when one side is 3x more filled than other * fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels Critical fix for grid auto-adjustment: - Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered on current price before reinitializing grid levels - Preserve filled positions during adjustment by saving and restoring them to the closest new level after reinitialization - Hold mutex lock for the entire adjustment operation to ensure atomicity - Add locked variants of calculateDefaultBounds, calculateATRBounds, and initializeGridLevels to use during adjustment Without this fix, autoAdjustGrid was using old boundaries when creating new grid levels, defeating the purpose of auto-adjustment when price moved significantly. * fix(grid): improve order state sync logic - Don't assume missing orders are filled - Compare position size to determine fill vs cancel - Properly reset cancelled orders to empty state - More accurate grid state tracking * fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity` which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution (gaussian, pyramid, uniform) where orders have different quantities, this could lead to incorrect fill detection. Now sums the actual PositionSize from filled levels for accurate comparison. Also adds warning log when GetPositions() fails. * docs: add grid market regime detection design Design for enhanced market state recognition with: - Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI) - Multi-period box indicators (72/240/500 1h candles) - 4-level ranging classification - Breakout detection and handling - Frontend risk control panel * docs: add grid market regime implementation plan 20 tasks covering: - Donchian channel calculation - Box data types and API - Regime classification (4 levels) - Breakout detection and handling - False breakout recovery - Frontend risk panel - AI prompt updates * feat(market): add Donchian channel calculation Add calculateDonchian function to compute highest high and lowest low over a specified period. This is the foundation for box (range) detection in the multi-period box indicator system for grid trading. * fix(market): handle invalid period in calculateDonchian * feat(market): add BoxData and RegimeLevel types * feat(market): add GetBoxData for multi-period box calculation Adds calculateBoxData internal function and GetBoxData public API that fetches 1h klines and computes three Donchian box levels (short/mid/long). This will be used by the grid trading system to detect market regime. * feat(store): add box and regime fields to grid models * feat(trader): add regime classification and breakout detection Implements Tasks 6-9 for grid market regime awareness: - Task 6: classifyRegimeLevel with Bollinger/ATR thresholds - Task 7: detectBoxBreakout for multi-period box breakouts - Task 8: confirmBreakout with 3-candle confirmation logic - Task 9: getBreakoutAction mapping breakout levels to actions * feat(trader): integrate box breakout detection into grid cycle - Task 10: Add checkBoxBreakout with 3-candle confirmation - Task 11: Add checkFalseBreakoutRecovery for 50% position recovery - Task 12: Add box/breakout/regime fields to GridState * feat: add grid risk panel with API endpoint - Task 13: Add GridRiskInfo type to frontend - Task 14: Add /traders/:id/grid-risk API endpoint - Task 15: Add GetGridRiskInfo method to AutoTrader - Task 16: Create GridRiskPanel component with i18n * feat(kernel): add box indicators to AI prompt - Add BoxData field to GridContext - Add box indicator table to both zh/en prompts - Show breakout/warning alerts based on price position * feat(web): integrate GridRiskPanel into TraderDashboardPage * feat(lighter): improve API key validation and market caching - Add API key validation status tracking - Add market list caching to reduce API calls - Improve logging (debug vs info levels) - Add comprehensive integration tests - Update trader manager and store for lighter support * fix: remove hardcoded test wallet address * fix(grid): improve GridRiskPanel layout and fix liquidation data - Make panel collapsible with summary badges when collapsed - Use compact 2-column grid layout for detailed info - Fix auth token key (token -> auth_token) - Only calculate liquidation distance when position exists * fix(grid): add isRunning checks to prevent trades after Stop() is called
691 lines
22 KiB
Go
691 lines
22 KiB
Go
package trader
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"nofx/logger"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
lighterClient "github.com/elliottech/lighter-go/client"
|
|
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
|
"github.com/ethereum/go-ethereum/common/hexutil"
|
|
)
|
|
|
|
// AccountInfo LIGHTER account information
|
|
type AccountInfo struct {
|
|
AccountIndex int64 `json:"account_index"`
|
|
Index int64 `json:"index"` // Same as account_index
|
|
L1Address string `json:"l1_address"`
|
|
AvailableBalance string `json:"available_balance"`
|
|
Collateral string `json:"collateral"`
|
|
CrossAssetValue string `json:"cross_asset_value"`
|
|
TotalEquity string `json:"total_equity"`
|
|
UnrealizedPnl string `json:"unrealized_pnl"`
|
|
Positions []LighterPositionInfo `json:"positions"`
|
|
}
|
|
|
|
// LighterPositionInfo Position info from Lighter account API
|
|
type LighterPositionInfo struct {
|
|
MarketID int `json:"market_id"`
|
|
Symbol string `json:"symbol"`
|
|
Sign int `json:"sign"` // 1 = long, -1 = short
|
|
Position string `json:"position"` // Position size
|
|
AvgEntryPrice string `json:"avg_entry_price"` // Entry price
|
|
PositionValue string `json:"position_value"` // Position value in USD
|
|
LiquidationPrice string `json:"liquidation_price"`
|
|
UnrealizedPnl string `json:"unrealized_pnl"`
|
|
RealizedPnl string `json:"realized_pnl"`
|
|
InitialMarginFraction string `json:"initial_margin_fraction"` // e.g. "5.00" means 5% = 20x leverage
|
|
AllocatedMargin string `json:"allocated_margin"`
|
|
MarginMode int `json:"margin_mode"` // 0 = cross, 1 = isolated
|
|
}
|
|
|
|
// AccountResponse LIGHTER account API response
|
|
// API may return accounts in "accounts" or "sub_accounts" field
|
|
type AccountResponse struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Accounts []AccountInfo `json:"accounts"`
|
|
SubAccounts []AccountInfo `json:"sub_accounts"` // Sub-accounts field
|
|
}
|
|
|
|
// LighterTraderV2 New implementation using official lighter-go SDK
|
|
type LighterTraderV2 struct {
|
|
ctx context.Context
|
|
walletAddr string // Ethereum wallet address
|
|
|
|
client *http.Client
|
|
baseURL string
|
|
testnet bool
|
|
chainID uint32
|
|
|
|
// SDK clients
|
|
httpClient lighterClient.MinimalHTTPClient
|
|
txClient *lighterClient.TxClient
|
|
|
|
// API Key management
|
|
apiKeyPrivateKey string // 40-byte API Key private key (for signing transactions)
|
|
apiKeyIndex uint8 // API Key index (default 0)
|
|
accountIndex int64 // Account index
|
|
apiKeyValid bool // Whether API key has been validated against server
|
|
|
|
// Authentication token
|
|
authToken string
|
|
tokenExpiry time.Time
|
|
accountMutex sync.RWMutex
|
|
|
|
// Market info cache
|
|
symbolPrecision map[string]SymbolPrecision
|
|
precisionMutex sync.RWMutex
|
|
|
|
// Market index cache
|
|
marketIndexMap map[string]uint16 // symbol -> market_id
|
|
marketMutex sync.RWMutex
|
|
marketListCache []MarketInfo // Cached market list
|
|
marketListCacheTime time.Time // Time when cache was populated
|
|
}
|
|
|
|
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
|
// Parameters:
|
|
// - walletAddr: Ethereum wallet address (required)
|
|
// - apiKeyPrivateKeyHex: API Key private key (40 bytes, for signing transactions)
|
|
// - apiKeyIndex: API Key index (0-255)
|
|
// - testnet: Whether to use testnet
|
|
func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int, testnet bool) (*LighterTraderV2, error) {
|
|
// 1. Validate wallet address
|
|
if walletAddr == "" {
|
|
return nil, fmt.Errorf("wallet address is required")
|
|
}
|
|
|
|
// Convert to checksum address (Lighter API is case-sensitive)
|
|
walletAddr = ToChecksumAddress(walletAddr)
|
|
logger.Infof("Using checksum address: %s", walletAddr)
|
|
|
|
// 2. Validate API Key
|
|
if apiKeyPrivateKeyHex == "" {
|
|
return nil, fmt.Errorf("API Key private key is required")
|
|
}
|
|
|
|
// 3. Determine API URL and Chain ID
|
|
// Note: Python SDK uses 304 for mainnet, 300 for testnet (not the L1 chain IDs)
|
|
baseURL := "https://mainnet.zklighter.elliot.ai"
|
|
chainID := uint32(304) // Mainnet Lighter Chain ID (from Python SDK)
|
|
if testnet {
|
|
baseURL = "https://testnet.zklighter.elliot.ai"
|
|
chainID = uint32(300) // Testnet Lighter Chain ID (from Python SDK)
|
|
}
|
|
|
|
// 4. Create HTTP client
|
|
httpClient := lighterHTTP.NewClient(baseURL)
|
|
|
|
trader := &LighterTraderV2{
|
|
ctx: context.Background(),
|
|
walletAddr: walletAddr,
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
baseURL: baseURL,
|
|
testnet: testnet,
|
|
chainID: chainID,
|
|
httpClient: httpClient,
|
|
apiKeyPrivateKey: apiKeyPrivateKeyHex,
|
|
apiKeyIndex: uint8(apiKeyIndex),
|
|
symbolPrecision: make(map[string]SymbolPrecision),
|
|
marketIndexMap: make(map[string]uint16),
|
|
}
|
|
|
|
// 5. Initialize account (get account index)
|
|
if err := trader.initializeAccount(); err != nil {
|
|
return nil, fmt.Errorf("failed to initialize account: %w", err)
|
|
}
|
|
|
|
// 6. Create TxClient (for signing transactions)
|
|
txClient, err := lighterClient.NewTxClient(
|
|
httpClient,
|
|
apiKeyPrivateKeyHex,
|
|
trader.accountIndex,
|
|
trader.apiKeyIndex,
|
|
trader.chainID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create TxClient: %w", err)
|
|
}
|
|
|
|
trader.txClient = txClient
|
|
|
|
// 7. Verify API Key is correct
|
|
if err := trader.checkClient(); err != nil {
|
|
trader.apiKeyValid = false
|
|
logger.Warnf("⚠️ API Key verification FAILED: %v", err)
|
|
logger.Warnf("⚠️ ❌ The API key stored in NOFX does NOT match the API key registered on Lighter.")
|
|
logger.Warnf("⚠️ ❌ ALL trading operations (open/close positions, cancel orders) WILL FAIL with 'invalid signature' error.")
|
|
logger.Warnf("⚠️ 🔧 To fix: Update your Lighter API key in NOFX Exchange settings with the correct key from app.lighter.xyz")
|
|
// Don't fail here, allow trader to continue for read operations (balance, positions)
|
|
} else {
|
|
trader.apiKeyValid = true
|
|
}
|
|
|
|
logger.Infof("✓ LIGHTER trader initialized (account=%d, apiKey=%d, testnet=%v, apiKeyValid=%v)",
|
|
trader.accountIndex, trader.apiKeyIndex, testnet, trader.apiKeyValid)
|
|
|
|
return trader, nil
|
|
}
|
|
|
|
// initializeAccount Initialize account information (get account index)
|
|
func (t *LighterTraderV2) initializeAccount() error {
|
|
// Get account info by L1 address
|
|
accountInfo, err := t.getAccountByL1Address()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get account info: %w", err)
|
|
}
|
|
|
|
t.accountMutex.Lock()
|
|
t.accountIndex = accountInfo.AccountIndex
|
|
t.accountMutex.Unlock()
|
|
|
|
logger.Infof("✓ Account index: %d", t.accountIndex)
|
|
return nil
|
|
}
|
|
|
|
// getAccountByL1Address Get LIGHTER account info by L1 wallet address
|
|
// Supports both main accounts and sub-accounts
|
|
func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
|
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
|
|
|
req, err := http.NewRequest("GET", endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log raw response for debugging
|
|
logger.Debugf("LIGHTER account API response: %s", string(body))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts"
|
|
var accountResp AccountResponse
|
|
if err := json.Unmarshal(body, &accountResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
|
}
|
|
|
|
// Check for API error
|
|
if accountResp.Code != 0 && accountResp.Code != 200 {
|
|
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
|
}
|
|
|
|
// Try accounts first, then sub_accounts
|
|
var allAccounts []AccountInfo
|
|
allAccounts = append(allAccounts, accountResp.Accounts...)
|
|
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
|
|
|
if len(allAccounts) == 0 {
|
|
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
|
}
|
|
|
|
// Log account summary
|
|
logger.Infof("Found %d account(s) (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
|
for i, acc := range allAccounts {
|
|
logger.Debugf(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
|
}
|
|
|
|
account := &allAccounts[0]
|
|
// Use index field if account_index is 0
|
|
if account.AccountIndex == 0 && account.Index != 0 {
|
|
account.AccountIndex = account.Index
|
|
}
|
|
|
|
return account, nil
|
|
}
|
|
|
|
// ApiKeyResponse API key query response
|
|
type ApiKeyResponse struct {
|
|
Code int `json:"code"`
|
|
ApiKeys []struct {
|
|
AccountIndex int64 `json:"account_index"`
|
|
ApiKeyIndex uint8 `json:"api_key_index"`
|
|
Nonce int64 `json:"nonce"`
|
|
PublicKey string `json:"public_key"`
|
|
} `json:"api_keys"`
|
|
}
|
|
|
|
// getApiKeyFromServer Get API Key public key from Lighter server
|
|
// Uses our own HTTP client instead of SDK's global client to avoid connection issues
|
|
func (t *LighterTraderV2) getApiKeyFromServer() (string, error) {
|
|
endpoint := fmt.Sprintf("%s/api/v1/apikeys?account_index=%d&api_key_index=%d",
|
|
t.baseURL, t.accountIndex, t.apiKeyIndex)
|
|
|
|
req, err := http.NewRequest("GET", endpoint, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result ApiKeyResponse
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
if result.Code != 200 {
|
|
return "", fmt.Errorf("API error (code %d)", result.Code)
|
|
}
|
|
|
|
if len(result.ApiKeys) == 0 {
|
|
return "", fmt.Errorf("no API keys found for account %d", t.accountIndex)
|
|
}
|
|
|
|
return result.ApiKeys[0].PublicKey, nil
|
|
}
|
|
|
|
// checkClient Verify if API Key is correct
|
|
func (t *LighterTraderV2) checkClient() error {
|
|
if t.txClient == nil {
|
|
return fmt.Errorf("TxClient not initialized")
|
|
}
|
|
|
|
// Get API Key public key registered on server (using our own HTTP client)
|
|
serverPubKey, err := t.getApiKeyFromServer()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get API Key: %w", err)
|
|
}
|
|
|
|
// Get local API Key public key from SDK
|
|
pubKeyBytes := t.txClient.GetKeyManager().PubKeyBytes()
|
|
localPubKey := hexutil.Encode(pubKeyBytes[:])
|
|
localPubKey = strings.TrimPrefix(localPubKey, "0x")
|
|
|
|
// Compare public keys
|
|
if serverPubKey != localPubKey {
|
|
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, serverPubKey)
|
|
}
|
|
|
|
logger.Infof("✓ API Key verification passed")
|
|
return nil
|
|
}
|
|
|
|
// GenerateAndRegisterAPIKey Generate new API Key and register to LIGHTER
|
|
// Note: This requires L1 private key signature, so must be called with L1 private key available
|
|
func (t *LighterTraderV2) GenerateAndRegisterAPIKey(seed string) (privateKey, publicKey string, err error) {
|
|
// This function needs to call the official SDK's GenerateAPIKey function
|
|
// But this is a CGO function in sharedlib, cannot be called directly in pure Go code
|
|
//
|
|
// Solutions:
|
|
// 1. Let users generate API Key from LIGHTER website
|
|
// 2. Or we can implement a simple API Key generation wrapper
|
|
|
|
return "", "", fmt.Errorf("GenerateAndRegisterAPIKey feature not implemented yet, please generate API Key from LIGHTER website")
|
|
}
|
|
|
|
// refreshAuthToken Refresh authentication token (using official SDK)
|
|
func (t *LighterTraderV2) refreshAuthToken() error {
|
|
if t.txClient == nil {
|
|
return fmt.Errorf("TxClient not initialized, please set API Key first")
|
|
}
|
|
|
|
// Generate auth token using official SDK (valid for 7 hours)
|
|
deadline := time.Now().Add(7 * time.Hour)
|
|
authToken, err := t.txClient.GetAuthToken(deadline)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate auth token: %w", err)
|
|
}
|
|
|
|
t.accountMutex.Lock()
|
|
t.authToken = authToken
|
|
t.tokenExpiry = deadline
|
|
t.accountMutex.Unlock()
|
|
|
|
logger.Infof("✓ Auth token generated (valid until: %s)", t.tokenExpiry.Format(time.RFC3339))
|
|
return nil
|
|
}
|
|
|
|
// ensureAuthToken Ensure authentication token is valid
|
|
func (t *LighterTraderV2) ensureAuthToken() error {
|
|
t.accountMutex.RLock()
|
|
expired := time.Now().After(t.tokenExpiry.Add(-30 * time.Minute)) // Refresh 30 minutes early
|
|
t.accountMutex.RUnlock()
|
|
|
|
if expired {
|
|
logger.Info("🔄 Auth token about to expire, refreshing...")
|
|
return t.refreshAuthToken()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetExchangeType Get exchange type
|
|
func (t *LighterTraderV2) GetExchangeType() string {
|
|
return "lighter"
|
|
}
|
|
|
|
// Cleanup Clean up resources
|
|
func (t *LighterTraderV2) Cleanup() error {
|
|
logger.Info("⏹ LIGHTER trader cleanup completed")
|
|
return nil
|
|
}
|
|
|
|
// GetClosedPnL gets closed position PnL records from exchange
|
|
// LIGHTER does not have a direct closed PnL API, returns empty slice
|
|
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
|
trades, err := t.GetTrades(startTime, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter only closing trades (realizedPnl != 0)
|
|
var records []ClosedPnLRecord
|
|
for _, trade := range trades {
|
|
if trade.RealizedPnL == 0 {
|
|
continue
|
|
}
|
|
|
|
side := "long"
|
|
if trade.Side == "SELL" || trade.Side == "Sell" {
|
|
side = "long"
|
|
} else {
|
|
side = "short"
|
|
}
|
|
|
|
var entryPrice float64
|
|
if trade.Quantity > 0 {
|
|
if side == "long" {
|
|
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
|
|
} else {
|
|
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
|
|
}
|
|
}
|
|
|
|
records = append(records, ClosedPnLRecord{
|
|
Symbol: trade.Symbol,
|
|
Side: side,
|
|
EntryPrice: entryPrice,
|
|
ExitPrice: trade.Price,
|
|
Quantity: trade.Quantity,
|
|
RealizedPnL: trade.RealizedPnL,
|
|
Fee: trade.Fee,
|
|
ExitTime: trade.Time,
|
|
EntryTime: trade.Time,
|
|
OrderID: trade.TradeID,
|
|
ExchangeID: trade.TradeID,
|
|
CloseType: "unknown",
|
|
})
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
// GetTrades retrieves trade history from Lighter
|
|
func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
|
// Ensure we have account index
|
|
if t.accountIndex == 0 {
|
|
if err := t.initializeAccount(); err != nil {
|
|
return nil, fmt.Errorf("failed to get account index: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build request URL with correct parameters
|
|
// Required: sort_by, limit
|
|
// Optional: account_index, from (timestamp in milliseconds, -1 for no filter)
|
|
// Note: OpenAPI spec uses "from" not "var_from"
|
|
// Authentication: Use "auth" query parameter (not Authorization header)
|
|
if err := t.ensureAuthToken(); err != nil {
|
|
return nil, fmt.Errorf("failed to get auth token: %w", err)
|
|
}
|
|
|
|
// URL encode auth token (contains colons that need encoding)
|
|
encodedAuth := url.QueryEscape(t.authToken)
|
|
// Build endpoint - use from=-1 to get all trades (no time filter)
|
|
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=%d&auth=%s",
|
|
t.baseURL, t.accountIndex, limit, encodedAuth)
|
|
|
|
logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint[:min(len(endpoint), 150)]+"...")
|
|
|
|
req, err := http.NewRequest("GET", endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get trades: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
|
|
return []TradeRecord{}, nil
|
|
}
|
|
|
|
// Debug: log raw response
|
|
logger.Debugf("Lighter trades API response: %s", string(body))
|
|
|
|
var response LighterTradeResponse
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
logger.Infof("⚠️ Failed to parse trades response as object: %v", err)
|
|
var trades []LighterTrade
|
|
if err := json.Unmarshal(body, &trades); err != nil {
|
|
logger.Infof("⚠️ Failed to parse trades response as array: %v", err)
|
|
return []TradeRecord{}, nil
|
|
}
|
|
response.Trades = trades
|
|
}
|
|
|
|
if response.Code != 200 && response.Code != 0 {
|
|
logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code)
|
|
return []TradeRecord{}, nil
|
|
}
|
|
|
|
// Build market_id -> symbol map
|
|
marketMap := make(map[int]string)
|
|
markets, err := t.fetchMarketList()
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to fetch market list: %v, using fallback", err)
|
|
// Fallback market IDs (common ones)
|
|
marketMap[0] = "BTC"
|
|
marketMap[1] = "ETH"
|
|
marketMap[2] = "SOL"
|
|
} else {
|
|
for _, m := range markets {
|
|
marketMap[int(m.MarketID)] = m.Symbol
|
|
}
|
|
}
|
|
|
|
// Convert to unified TradeRecord format
|
|
var result []TradeRecord
|
|
for _, lt := range response.Trades {
|
|
price, _ := parseFloat(lt.Price)
|
|
qty, _ := parseFloat(lt.Size)
|
|
|
|
// Calculate fee from taker_fee or maker_fee (they are int64, need conversion)
|
|
var fee float64
|
|
if lt.TakerFee > 0 {
|
|
fee = float64(lt.TakerFee) / 1e6 // Convert from smallest units (6 decimals for USDT)
|
|
} else if lt.MakerFee > 0 {
|
|
fee = float64(lt.MakerFee) / 1e6
|
|
}
|
|
|
|
// Get symbol from market_id
|
|
symbol := marketMap[lt.MarketID]
|
|
if symbol == "" {
|
|
symbol = fmt.Sprintf("MARKET%d", lt.MarketID)
|
|
}
|
|
|
|
// Determine side based on our account being bid (buyer) or ask (seller)
|
|
// IsMakerAsk: true = ask (seller) is maker, false = bid (buyer) is maker
|
|
var side string
|
|
var isTaker bool
|
|
if lt.BidAccountID == t.accountIndex {
|
|
side = "BUY"
|
|
isTaker = lt.IsMakerAsk // If maker is ask, then we (bid) are taker
|
|
} else if lt.AskAccountID == t.accountIndex {
|
|
side = "SELL"
|
|
isTaker = !lt.IsMakerAsk // If maker is NOT ask, then we (ask) are taker
|
|
} else {
|
|
// Neither bid nor ask is our account - skip this trade
|
|
continue
|
|
}
|
|
|
|
// Determine position side and action from position change
|
|
var positionSide, orderAction string
|
|
var posBefore float64
|
|
var signChanged bool
|
|
|
|
if isTaker {
|
|
posBefore, _ = parseFloat(lt.TakerPositionSizeBefore)
|
|
signChanged = lt.TakerPositionSignChanged
|
|
} else {
|
|
posBefore, _ = parseFloat(lt.MakerPositionSizeBefore)
|
|
signChanged = lt.MakerPositionSignChanged
|
|
}
|
|
|
|
// Determine order action based on:
|
|
// 1. posBefore: position BEFORE this trade (positive=LONG, negative=SHORT, 0=no position)
|
|
// 2. side: BUY or SELL
|
|
// 3. signChanged: whether position flipped direction
|
|
//
|
|
// Logic:
|
|
// - BUY when no position (posBefore ≈ 0): open_long
|
|
// - SELL when no position (posBefore ≈ 0): open_short
|
|
// - BUY when LONG (posBefore > 0): open_long (adding to long)
|
|
// - SELL when LONG (posBefore > 0): close_long (reducing long)
|
|
// - BUY when SHORT (posBefore < 0): close_short (reducing short)
|
|
// - SELL when SHORT (posBefore < 0): open_short (adding to short)
|
|
// - signChanged with position flip: split into close + open
|
|
|
|
const EPSILON = 0.0001
|
|
tradeTime := time.UnixMilli(lt.Timestamp).UTC()
|
|
|
|
// Calculate position after trade
|
|
var posAfter float64
|
|
if side == "SELL" {
|
|
posAfter = posBefore - qty
|
|
} else {
|
|
posAfter = posBefore + qty
|
|
}
|
|
|
|
// Check for position flip (signChanged AND both before/after have meaningful size)
|
|
if signChanged && math.Abs(posBefore) > EPSILON && math.Abs(posAfter) > EPSILON {
|
|
// Position FLIPPED - split into close + open
|
|
closeQty := math.Abs(posBefore)
|
|
openQty := math.Abs(posAfter)
|
|
|
|
var closeAction, closeSide, openAction, openSide string
|
|
if posBefore > 0 {
|
|
closeSide, closeAction = "LONG", "close_long"
|
|
openSide, openAction = "SHORT", "open_short"
|
|
} else {
|
|
closeSide, closeAction = "SHORT", "close_short"
|
|
openSide, openAction = "LONG", "open_long"
|
|
}
|
|
|
|
closeTrade := TradeRecord{
|
|
TradeID: fmt.Sprintf("%d_close", lt.TradeID),
|
|
Symbol: symbol,
|
|
Side: side,
|
|
PositionSide: closeSide,
|
|
OrderAction: closeAction,
|
|
Price: price,
|
|
Quantity: closeQty,
|
|
RealizedPnL: 0,
|
|
Fee: fee * (closeQty / qty),
|
|
Time: tradeTime.Add(-time.Millisecond),
|
|
}
|
|
result = append(result, closeTrade)
|
|
|
|
openTrade := TradeRecord{
|
|
TradeID: fmt.Sprintf("%d_open", lt.TradeID),
|
|
Symbol: symbol,
|
|
Side: side,
|
|
PositionSide: openSide,
|
|
OrderAction: openAction,
|
|
Price: price,
|
|
Quantity: openQty,
|
|
RealizedPnL: 0,
|
|
Fee: fee * (openQty / qty),
|
|
Time: tradeTime,
|
|
}
|
|
result = append(result, openTrade)
|
|
|
|
logger.Infof(" 🔄 Flip: %s %.4f → %s %.4f", closeSide, closeQty, openSide, openQty)
|
|
continue
|
|
}
|
|
|
|
// Determine action based on position direction and trade side
|
|
if math.Abs(posBefore) < EPSILON {
|
|
// No position before → opening new position
|
|
if side == "BUY" {
|
|
positionSide, orderAction = "LONG", "open_long"
|
|
} else {
|
|
positionSide, orderAction = "SHORT", "open_short"
|
|
}
|
|
} else if posBefore > 0 {
|
|
// Was LONG
|
|
if side == "BUY" {
|
|
positionSide, orderAction = "LONG", "open_long" // Adding to long
|
|
} else {
|
|
positionSide, orderAction = "LONG", "close_long" // Reducing long
|
|
}
|
|
} else {
|
|
// Was SHORT (posBefore < 0)
|
|
if side == "BUY" {
|
|
positionSide, orderAction = "SHORT", "close_short" // Reducing short
|
|
} else {
|
|
positionSide, orderAction = "SHORT", "open_short" // Adding to short
|
|
}
|
|
}
|
|
|
|
trade := TradeRecord{
|
|
TradeID: fmt.Sprintf("%d", lt.TradeID),
|
|
Symbol: symbol,
|
|
Side: side,
|
|
PositionSide: positionSide,
|
|
OrderAction: orderAction,
|
|
Price: price,
|
|
Quantity: qty,
|
|
RealizedPnL: 0, // Not available in API
|
|
Fee: fee,
|
|
Time: time.UnixMilli(lt.Timestamp).UTC(),
|
|
}
|
|
result = append(result, trade)
|
|
}
|
|
|
|
return result, nil
|
|
}
|