Files
tinkle-community cb31782be4 refactor: split large files and clean up project structure
- 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)
2026-03-12 12:53:57 +08:00

778 lines
21 KiB
Go

package kucoin
import (
"encoding/json"
"fmt"
"math"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
)
// OpenLong opens long position
func (t *KuCoinTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ Failed to set leverage: %v", err)
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "buy",
"type": "market",
"size": lots,
"leverage": fmt.Sprintf("%d", leverage),
"reduceOnly": false,
"marginMode": "CROSS", // Use cross margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin opened long position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId)
// Query order to get fill price
fillPrice := t.queryOrderFillPrice(result.OrderId)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
"fillPrice": fillPrice,
}, nil
}
// OpenShort opens short position
func (t *KuCoinTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ Failed to set leverage: %v", err)
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "sell",
"type": "market",
"size": lots,
"leverage": fmt.Sprintf("%d", leverage),
"reduceOnly": false,
"marginMode": "CROSS", // Use cross margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin opened short position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId)
// Query order to get fill price
fillPrice := t.queryOrderFillPrice(result.OrderId)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
"fillPrice": fillPrice,
}, nil
}
// queryOrderFillPrice queries order status and returns fill price
func (t *KuCoinTrader) queryOrderFillPrice(orderId string) float64 {
// Wait a bit for order to fill
time.Sleep(500 * time.Millisecond)
path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
logger.Warnf("Failed to query order %s: %v", orderId, err)
return 0
}
var order struct {
DealAvgPrice float64 `json:"dealAvgPrice"`
Status string `json:"status"`
DealSize int64 `json:"dealSize"`
}
if err := json.Unmarshal(data, &order); err != nil {
return 0
}
return order.DealAvgPrice
}
// CloseLong closes long position
func (t *KuCoinTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position and get margin mode
var actualQty float64
var posFound bool
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
actualQty = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
marginMode = strings.ToUpper(mgnMode)
}
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No long position found for %s on KuCoin", symbol),
}, nil
}
// Use actual quantity from exchange
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "sell",
"type": "market",
"size": lots,
"reduceOnly": true,
"closeOrder": true,
"marginMode": marginMode, // Use position's margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin closed long position: %s", symbol)
// Cancel pending orders
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort closes short position
func (t *KuCoinTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position and get margin mode
var actualQty float64
var posFound bool
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
marginMode = strings.ToUpper(mgnMode)
}
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No short position found for %s on KuCoin", symbol),
}, nil
}
// Use actual quantity from exchange
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "buy",
"type": "market",
"size": lots,
"reduceOnly": true,
"closeOrder": true,
"marginMode": marginMode, // Use position's margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin closed short position: %s", symbol)
// Cancel pending orders
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// GetMarketPrice gets market price
func (t *KuCoinTrader) GetMarketPrice(symbol string) (float64, error) {
kcSymbol := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?symbol=%s", kucoinTickerPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
var ticker struct {
Price string `json:"price"`
}
if err := json.Unmarshal(data, &ticker); err != nil {
return 0, err
}
price, _ := strconv.ParseFloat(ticker.Price, 64)
return price, nil
}
// SetStopLoss sets stop loss order
func (t *KuCoinTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return fmt.Errorf("failed to calculate lots: %w", err)
}
// Determine side: close long = sell, close short = buy
side := "sell"
stop := "down" // Long position: stop loss triggers when price goes down
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
stop = "up" // Short position: stop loss triggers when price goes up
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfxsl%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": side,
"type": "market",
"size": lots,
"stop": stop,
"stopPriceType": "MP", // Mark Price
"stopPrice": fmt.Sprintf("%.8f", stopPrice),
"reduceOnly": true,
"closeOrder": true,
}
_, err = t.doRequest("POST", kucoinStopOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof("✓ Stop loss set: %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *KuCoinTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return fmt.Errorf("failed to calculate lots: %w", err)
}
// Determine side: close long = sell, close short = buy
side := "sell"
stop := "up" // Long position: take profit triggers when price goes up
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
stop = "down" // Short position: take profit triggers when price goes down
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfxtp%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": side,
"type": "market",
"size": lots,
"stop": stop,
"stopPriceType": "MP", // Mark Price
"stopPrice": fmt.Sprintf("%.8f", takeProfitPrice),
"reduceOnly": true,
"closeOrder": true,
}
_, err = t.doRequest("POST", kucoinStopOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof("✓ Take profit set: %.4f", takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *KuCoinTrader) CancelStopLossOrders(symbol string) error {
return t.cancelStopOrdersByType(symbol, "sl")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *KuCoinTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelStopOrdersByType(symbol, "tp")
}
// cancelStopOrdersByType cancels stop orders by type
func (t *KuCoinTrader) cancelStopOrdersByType(symbol string, orderType string) error {
kcSymbol := t.convertSymbol(symbol)
// Get pending stop orders
path := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var response struct {
Items []struct {
Id string `json:"id"`
ClientOid string `json:"clientOid"`
Stop string `json:"stop"`
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
// Try alternate format (direct array)
var items []struct {
Id string `json:"id"`
ClientOid string `json:"clientOid"`
Stop string `json:"stop"`
}
if err := json.Unmarshal(data, &items); err != nil {
return err
}
response.Items = items
}
// Cancel matching orders
for _, order := range response.Items {
// Check if order matches type based on clientOid prefix
if orderType == "sl" && !strings.Contains(order.ClientOid, "sl") {
continue
}
if orderType == "tp" && !strings.Contains(order.ClientOid, "tp") {
continue
}
cancelPath := fmt.Sprintf("%s/%s", kucoinCancelStopPath, order.Id)
_, err := t.doRequest("DELETE", cancelPath, nil)
if err != nil {
logger.Warnf("Failed to cancel stop order %s: %v", order.Id, err)
}
}
return nil
}
// CancelStopOrders cancels all stop orders for symbol
func (t *KuCoinTrader) CancelStopOrders(symbol string) error {
kcSymbol := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?symbol=%s", kucoinCancelStopPath, kcSymbol)
_, err := t.doRequest("DELETE", path, nil)
if err != nil {
// Ignore if no orders to cancel
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "400100") {
return nil
}
return err
}
logger.Infof("✓ Cancelled stop orders for %s", symbol)
return nil
}
// CancelAllOrders cancels all pending orders for symbol
func (t *KuCoinTrader) CancelAllOrders(symbol string) error {
kcSymbol := t.convertSymbol(symbol)
// Cancel regular orders
path := fmt.Sprintf("%s?symbol=%s", kucoinCancelOrderPath, kcSymbol)
_, err := t.doRequest("DELETE", path, nil)
if err != nil && !strings.Contains(err.Error(), "not found") {
logger.Warnf("Failed to cancel regular orders: %v", err)
}
// Cancel stop orders
t.CancelStopOrders(symbol)
return nil
}
// SetMarginMode sets margin mode
func (t *KuCoinTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// KuCoin sets margin mode per position, handled automatically
logger.Infof("✓ KuCoin margin mode: %v (handled per position)", isCrossMargin)
return nil
}
// SetLeverage sets leverage for a symbol
func (t *KuCoinTrader) SetLeverage(symbol string, leverage int) error {
kcSymbol := t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": kcSymbol,
"leverage": fmt.Sprintf("%d", leverage),
}
_, err := t.doRequest("POST", kucoinLeveragePath, body)
if err != nil {
// Ignore if already at target leverage
if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") {
logger.Infof("✓ %s leverage is already %dx", symbol, leverage)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof("✓ %s leverage set to %dx", symbol, leverage)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *KuCoinTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
contract, err := t.getContract(symbol)
if err != nil {
return "", err
}
// Calculate lots
lots := quantity / contract.Multiplier
// Round to integer
lotsInt := int64(math.Round(lots))
return strconv.FormatInt(lotsInt, 10), nil
}
// GetOrderStatus gets order status
func (t *KuCoinTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderID)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var order struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Status string `json:"status"`
DealAvgPrice float64 `json:"dealAvgPrice"`
DealSize int64 `json:"dealSize"`
Fee float64 `json:"fee"`
Side string `json:"side"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, err
}
// Convert status
status := "NEW"
if order.Status == "done" {
status = "FILLED"
} else if order.Status == "cancelled" || order.Status == "canceled" {
status = "CANCELED"
}
return map[string]interface{}{
"orderId": order.Id,
"symbol": t.convertSymbolBack(order.Symbol),
"status": status,
"avgPrice": order.DealAvgPrice,
"executedQty": order.DealSize,
"commission": order.Fee,
}, nil
}
// GetClosedPnL gets closed position PnL records
func (t *KuCoinTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
// KuCoin closed positions API
path := fmt.Sprintf("/api/v1/history-positions?status=CLOSE&limit=%d", limit)
if !startTime.IsZero() {
path += fmt.Sprintf("&from=%d", startTime.UnixMilli())
}
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get closed PnL: %w", err)
}
var response struct {
HasMore bool `json:"hasMore"`
DataList []struct {
Symbol string `json:"symbol"`
OpenPrice float64 `json:"avgEntryPrice"`
ClosePrice float64 `json:"avgClosePrice"`
Qty int64 `json:"qty"`
RealisedPnl float64 `json:"realisedGrossCost"`
CloseTime int64 `json:"closeTime"`
OpenTime int64 `json:"openTime"`
PositionId string `json:"id"`
CloseType string `json:"type"`
Leverage int `json:"leverage"`
SettleCurrency string `json:"settleCurrency"`
} `json:"dataList"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to parse closed PnL: %w", err)
}
var records []types.ClosedPnLRecord
for _, item := range response.DataList {
side := "long"
qty := item.Qty
if qty < 0 {
side = "short"
qty = -qty
}
// Map close type
closeType := "unknown"
switch strings.ToUpper(item.CloseType) {
case "CLOSE", "MANUAL":
closeType = "manual"
case "STOP", "STOPLOSS":
closeType = "stop_loss"
case "TAKEPROFIT", "TP":
closeType = "take_profit"
case "LIQUIDATION", "LIQ", "ADL":
closeType = "liquidation"
}
records = append(records, types.ClosedPnLRecord{
Symbol: t.convertSymbolBack(item.Symbol),
Side: side,
EntryPrice: item.OpenPrice,
ExitPrice: item.ClosePrice,
Quantity: float64(qty),
RealizedPnL: item.RealisedPnl,
Leverage: item.Leverage,
EntryTime: time.UnixMilli(item.OpenTime),
ExitTime: time.UnixMilli(item.CloseTime),
ExchangeID: item.PositionId,
CloseType: closeType,
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *KuCoinTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
kcSymbol := t.convertSymbol(symbol)
// Get regular orders
path := fmt.Sprintf("%s?symbol=%s&status=active", kucoinOrderPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var response struct {
Items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
Size int64 `json:"size"`
StopType string `json:"stopType"`
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
// Try alternate format
var items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
Size int64 `json:"size"`
StopType string `json:"stopType"`
}
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
response.Items = items
}
var orders []types.OpenOrder
for _, item := range response.Items {
// Determine position side based on order side
positionSide := "LONG"
if item.Side == "sell" {
positionSide = "SHORT"
}
price, _ := strconv.ParseFloat(item.Price, 64)
orders = append(orders, types.OpenOrder{
OrderID: item.Id,
Symbol: t.convertSymbolBack(item.Symbol),
Side: strings.ToUpper(item.Side),
PositionSide: positionSide,
Type: strings.ToUpper(item.Type),
Price: price,
Quantity: float64(item.Size),
Status: "NEW",
})
}
// Get stop orders
stopPath := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol)
stopData, err := t.doRequest("GET", stopPath, nil)
if err == nil {
var stopResponse struct {
Items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
StopPrice string `json:"stopPrice"`
Size int64 `json:"size"`
} `json:"items"`
}
if json.Unmarshal(stopData, &stopResponse) == nil {
for _, item := range stopResponse.Items {
positionSide := "LONG"
if item.Side == "sell" {
positionSide = "SHORT"
}
stopPrice, _ := strconv.ParseFloat(item.StopPrice, 64)
orders = append(orders, types.OpenOrder{
OrderID: item.Id,
Symbol: t.convertSymbolBack(item.Symbol),
Side: strings.ToUpper(item.Side),
PositionSide: positionSide,
Type: "STOP_MARKET",
StopPrice: stopPrice,
Quantity: float64(item.Size),
Status: "NEW",
})
}
}
}
return orders, nil
}