feat: migrate to CoinAnk API and improve chart UI

- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend
- Data source migration: use CoinAnk API exclusively for all kline data
- Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go)
- Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold
- Lighter integration: add order sync functionality, fix market order precision
- Remove ticker merge logic for simplicity
This commit is contained in:
tinkle-community
2025-12-26 00:58:12 +08:00
parent 54b24167a7
commit 1744e7f38e
38 changed files with 6498 additions and 964 deletions

View File

@@ -40,7 +40,7 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int
}
// 4. Create market buy order (open long)
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market")
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", false)
if err != nil {
return nil, fmt.Errorf("failed to open long: %w", err)
}
@@ -81,7 +81,7 @@ func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage in
}
// 4. Create market sell order (open short)
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market")
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", false)
if err != nil {
return nil, fmt.Errorf("failed to open short: %w", err)
}
@@ -120,21 +120,22 @@ func (t *LighterTraderV2) CloseLong(symbol string, quantity float64) (map[string
logger.Infof("🔻 LIGHTER closing long: %s, qty=%.4f", symbol, quantity)
// Create market sell order to close (reduceOnly=true)
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market")
if err != nil {
return nil, fmt.Errorf("failed to close long: %w", err)
}
// Cancel all open orders after closing position
// Cancel pending orders before closing
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof("⚠️ Failed to cancel orders: %v", err)
}
logger.Infof("✓ LIGHTER closed long successfully: %s", symbol)
// Create market sell order to close (reduceOnly=true)
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", true)
if err != nil {
return nil, fmt.Errorf("failed to close long: %w", err)
}
txHash, _ := orderResult["orderId"].(string)
logger.Infof("✓ LIGHTER closed long successfully: %s (tx: %s)", symbol, txHash)
return map[string]interface{}{
"orderId": orderResult["orderId"],
"orderId": txHash,
"symbol": symbol,
"status": "FILLED",
}, nil
@@ -163,96 +164,101 @@ func (t *LighterTraderV2) CloseShort(symbol string, quantity float64) (map[strin
logger.Infof("🔺 LIGHTER closing short: %s, qty=%.4f", symbol, quantity)
// Create market buy order to close (reduceOnly=true)
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market")
if err != nil {
return nil, fmt.Errorf("failed to close short: %w", err)
}
// Cancel all open orders after closing position
// Cancel pending orders before closing
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof("⚠️ Failed to cancel orders: %v", err)
}
logger.Infof("✓ LIGHTER closed short successfully: %s", symbol)
// Create market buy order to close (reduceOnly=true)
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", true)
if err != nil {
return nil, fmt.Errorf("failed to close short: %w", err)
}
txHash, _ := orderResult["orderId"].(string)
logger.Infof("✓ LIGHTER closed short successfully: %s (tx: %s)", symbol, txHash)
return map[string]interface{}{
"orderId": orderResult["orderId"],
"orderId": txHash,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CreateOrder Create order (market or limit) - uses official SDK for signing
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string) (map[string]interface{}, error) {
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string, reduceOnly bool) (map[string]interface{}, error) {
if t.txClient == nil {
return nil, fmt.Errorf("TxClient not initialized")
}
// Get market index (convert from symbol)
marketIndexU16, err := t.getMarketIndex(symbol)
// Get market info (includes market_id and precision)
marketInfo, err := t.getMarketInfo(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market index: %w", err)
return nil, fmt.Errorf("failed to get market info: %w", err)
}
marketIndex := uint8(marketIndexU16) // SDK expects uint8
marketIndex := uint8(marketInfo.MarketID) // SDK expects uint8
// Build order request
// ClientOrderIndex must be <= 281474976710655 (48-bit max)
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
// Use ClientOrderIndex=0 for market orders (same as web UI)
clientOrderIndex := int64(0)
var orderTypeValue uint8 = 0 // 0=limit, 1=market
if orderType == "market" {
orderTypeValue = 1
}
// Convert quantity to LIGHTER base_amount format
// Different markets have different size_decimals:
// - ETH: supported_size_decimals=4, min=0.0050
// - BTC: supported_size_decimals=5, min=0.00020
// - SOL: supported_size_decimals=3, min=0.050
sizeDecimals := 4 // Default for ETH
normalizedSymbol := normalizeSymbol(symbol)
switch normalizedSymbol {
case "BTC":
sizeDecimals = 5
case "SOL":
sizeDecimals = 3
case "ETH":
sizeDecimals = 4
}
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
// Convert quantity to LIGHTER base_amount format using dynamic precision from API
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
logger.Infof("🔸 Using size precision: %d decimals, quantity=%.4f → baseAmount=%d",
marketInfo.SizeDecimals, quantity, baseAmount)
// For market orders, we need to set a price protection value
// Buy orders: set high price (current * 1.05), Sell orders: set low price (current * 0.95)
// Set price based on order type
priceValue := uint32(0)
if orderType == "limit" {
priceValue = uint32(price * 1e2) // Price precision (2 decimals)
priceValue = uint32(price * float64(pow10(marketInfo.PriceDecimals)))
logger.Infof("🔸 LIMIT order - Price: %.2f (precision: %d decimals)", price, marketInfo.PriceDecimals)
} else {
// Market order - get current price for protection
// Market order - Price field is used as PRICE PROTECTION (slippage limit)
// NOT as the execution price! Set it wider to allow order to fill.
marketPrice, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price for protection: %w", err)
return nil, fmt.Errorf("failed to get market price: %w", err)
}
// For BUY: set price protection ABOVE market (allow buying up to 105% of market price)
// For SELL: set price protection BELOW market (allow selling down to 95% of market price)
var protectedPrice float64
if isAsk {
// Sell order - set minimum price (95% of current)
priceValue = uint32(marketPrice * 0.95 * 1e2)
// Selling: accept down to 95% of market price
protectedPrice = marketPrice * 0.95
logger.Infof("🔸 MARKET SELL order - Price protection: %.2f (95%% of market %.2f, precision: %d decimals)",
protectedPrice, marketPrice, marketInfo.PriceDecimals)
} else {
// Buy order - set maximum price (105% of current)
priceValue = uint32(marketPrice * 1.05 * 1e2)
// Buying: accept up to 105% of market price
protectedPrice = marketPrice * 1.05
logger.Infof("🔸 MARKET BUY order - Price protection: %.2f (105%% of market %.2f, precision: %d decimals)",
protectedPrice, marketPrice, marketInfo.PriceDecimals)
}
priceValue = uint32(protectedPrice * float64(pow10(marketInfo.PriceDecimals)))
}
// For market orders: TimeInForce must be ImmediateOrCancel (0), OrderExpiry must be 0
// For limit orders: OrderExpiry must be between 5 minutes and 30 days from now (in milliseconds)
// TimeInForce and Expiry based on order type
// Market orders MUST use TimeInForce=0 (ImmediateOrCancel)
// Limit orders use TimeInForce=1 (GoodTillTime)
var orderExpiry int64 = 0
var timeInForce uint8 = 0 // ImmediateOrCancel for market orders
var timeInForce uint8 = 0 // Default: ImmediateOrCancel for market orders
if orderType == "limit" {
// Limit orders need expiry and can use GTC (1)
timeInForce = 1 // GoodTillTime
timeInForce = 1 // GoodTillTime for limit orders
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
}
// Set reduceOnly flag
var reduceOnlyValue uint8 = 0
if reduceOnly {
reduceOnlyValue = 1
}
txReq := &types.CreateOrderTxReq{
MarketIndex: marketIndex,
ClientOrderIndex: clientOrderIndex,
@@ -261,7 +267,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
IsAsk: boolToUint8(isAsk),
Type: orderTypeValue,
TimeInForce: timeInForce,
ReduceOnly: 0, // Not reduce-only
ReduceOnly: reduceOnlyValue,
TriggerPrice: 0,
OrderExpiry: orderExpiry,
}
@@ -343,8 +349,8 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
return nil, fmt.Errorf("failed to write tx_info: %w", err)
}
// Add price_protection field
if err := writer.WriteField("price_protection", "true"); err != nil {
// Add price_protection field (false = use Price field as slippage protection)
if err := writer.WriteField("price_protection", "false"); err != nil {
return nil, fmt.Errorf("failed to write price_protection: %w", err)
}
@@ -420,50 +426,45 @@ func normalizeSymbol(symbol string) string {
return strings.ToUpper(s)
}
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
// getMarketInfo Get market info including precision - dynamically fetch from API
func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
// Normalize symbol to Lighter format
normalizedSymbol := normalizeSymbol(symbol)
// 1. Check cache
t.marketMutex.RLock()
if index, ok := t.marketIndexMap[normalizedSymbol]; ok {
t.marketMutex.RUnlock()
return index, nil
}
t.marketMutex.RUnlock()
// 2. Fetch market list from API
// 1. Fetch market list from API (TODO: cache this)
markets, err := t.fetchMarketList()
if err != nil {
// If API fails, fallback to hardcoded mapping
logger.Infof("⚠️ Failed to fetch market list from API, using hardcoded mapping: %v", err)
return nil, fmt.Errorf("failed to fetch market list: %w", err)
}
// 2. Find market by symbol
for _, market := range markets {
if market.Symbol == normalizedSymbol {
return &market, nil
}
}
return nil, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
}
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
marketInfo, err := t.getMarketInfo(symbol)
if err != nil {
// Fallback to hardcoded mapping
logger.Infof("⚠️ Failed to get market info from API, using hardcoded mapping: %v", err)
normalizedSymbol := normalizeSymbol(symbol)
return t.getFallbackMarketIndex(normalizedSymbol)
}
// 3. Update cache
t.marketMutex.Lock()
for _, market := range markets {
t.marketIndexMap[market.Symbol] = market.MarketID
}
t.marketMutex.Unlock()
// 4. Get from cache
t.marketMutex.RLock()
index, ok := t.marketIndexMap[normalizedSymbol]
t.marketMutex.RUnlock()
if !ok {
return 0, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
}
return index, nil
return marketInfo.MarketID, nil
}
// MarketInfo Market information
type MarketInfo struct {
Symbol string `json:"symbol"`
MarketID uint16 `json:"market_id"`
Symbol string `json:"symbol"`
MarketID uint16 `json:"market_id"`
SizeDecimals int `json:"size_decimals"`
PriceDecimals int `json:"price_decimals"`
}
// fetchMarketList Fetch market list from API
@@ -492,9 +493,11 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
var apiResp struct {
Code int `json:"code"`
OrderBooks []struct {
Symbol string `json:"symbol"`
MarketID uint16 `json:"market_id"`
Status string `json:"status"`
Symbol string `json:"symbol"`
MarketID uint16 `json:"market_id"`
Status string `json:"status"`
SupportedSizeDecimals int `json:"supported_size_decimals"`
SupportedPriceDecimals int `json:"supported_price_decimals"`
} `json:"order_books"`
}
@@ -511,8 +514,10 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
for _, market := range apiResp.OrderBooks {
if market.Status == "active" {
markets = append(markets, MarketInfo{
Symbol: market.Symbol,
MarketID: market.MarketID,
Symbol: market.Symbol,
MarketID: market.MarketID,
SizeDecimals: market.SupportedSizeDecimals,
PriceDecimals: market.SupportedPriceDecimals,
})
}
}
@@ -580,12 +585,12 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
return nil, fmt.Errorf("TxClient not initialized")
}
// Get market index
marketIndexU16, err := t.getMarketIndex(symbol)
// Get market info (includes market_id and precision)
marketInfo, err := t.getMarketInfo(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market index: %w", err)
return nil, fmt.Errorf("failed to get market info: %w", err)
}
marketIndex := uint8(marketIndexU16)
marketIndex := uint8(marketInfo.MarketID)
// Build order request
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
@@ -596,21 +601,11 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
orderTypeValue = 4 // TakeProfitOrder
}
// Convert quantity to base amount
sizeDecimals := 4
normalizedSymbol := normalizeSymbol(symbol)
switch normalizedSymbol {
case "BTC":
sizeDecimals = 5
case "SOL":
sizeDecimals = 3
case "ETH":
sizeDecimals = 4
}
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
// Convert quantity to base amount using dynamic precision
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
// TriggerPrice: price precision is 2 decimals (multiply by 100)
triggerPriceValue := uint32(triggerPrice * 1e2)
// TriggerPrice: use dynamic price precision from API
triggerPriceValue := uint32(triggerPrice * float64(pow10(marketInfo.PriceDecimals)))
// For stop orders, Price should be set to a reasonable execution price
// Stop-loss sell: price slightly below trigger (95% of trigger)
@@ -620,10 +615,10 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
var priceValue uint32
if isAsk {
// Sell order - set price at 95% of trigger to ensure execution
priceValue = uint32(triggerPrice * 0.95 * 1e2)
priceValue = uint32(triggerPrice * 0.95 * float64(pow10(marketInfo.PriceDecimals)))
} else {
// Buy order - set price at 105% of trigger to ensure execution
priceValue = uint32(triggerPrice * 1.05 * 1e2)
priceValue = uint32(triggerPrice * 1.05 * float64(pow10(marketInfo.PriceDecimals)))
}
// Stop orders MUST use ImmediateOrCancel (0) with expiry set