From 23dbbf6bdd12ee56ccab9a2ed2c95436d8773879 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Wed, 4 Feb 2026 02:12:37 +0800 Subject: [PATCH] feat(kucoin): integrate KuCoin exchange support - Add kucoin to validTypes in api/server.go - Add KuCoin trader creation in trader_manager.go - Fix PostgreSQL duplicate key in equity.go (Omit ID) - Start KuCoin order sync in auto_trader.go - Update FooterSection UI --- api/server.go | 25 +++++++++++- manager/trader_manager.go | 4 ++ store/equity.go | 4 +- trader/auto_trader.go | 43 +++++++++++++++----- web/src/components/landing/FooterSection.tsx | 1 + 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/api/server.go b/api/server.go index 7e4a5214..e22a1e0f 100644 --- a/api/server.go +++ b/api/server.go @@ -26,6 +26,7 @@ import ( "nofx/trader/bybit" "nofx/trader/gate" hyperliquidtrader "nofx/trader/hyperliquid" + "nofx/trader/kucoin" "nofx/trader/lighter" "nofx/trader/okx" "strconv" @@ -628,6 +629,12 @@ func (s *Server) handleCreateTrader(c *gin.Context) { string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) + case "kucoin": + tempTrader = kucoin.NewKuCoinTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + string(exchangeCfg.Passphrase), + ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet @@ -1191,6 +1198,12 @@ func (s *Server) handleSyncBalance(c *gin.Context) { string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) + case "kucoin": + tempTrader = kucoin.NewKuCoinTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + string(exchangeCfg.Passphrase), + ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet @@ -1348,6 +1361,12 @@ func (s *Server) handleClosePosition(c *gin.Context) { string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) + case "kucoin": + tempTrader = kucoin.NewKuCoinTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + string(exchangeCfg.Passphrase), + ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet @@ -1984,7 +2003,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) { // Validate exchange type validTypes := map[string]bool{ "binance": true, "bybit": true, "okx": true, "bitget": true, - "hyperliquid": true, "aster": true, "lighter": true, "gate": true, + "hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, } if !validTypes[req.ExchangeType] { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) @@ -2523,6 +2542,9 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i case "lighter": // Lighter doesn't have direct CoinAnk support, use Binance data as fallback coinankExchange = coinank_enum.Binance + case "kucoin": + // KuCoin doesn't have direct CoinAnk support, use Binance data as fallback + coinankExchange = coinank_enum.Binance default: // For any unknown exchange, default to Binance logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange) @@ -3368,6 +3390,7 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) { {ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"}, {ExchangeType: "okx", Name: "OKX Futures", Type: "cex"}, {ExchangeType: "gate", Name: "Gate.io Futures", Type: "cex"}, + {ExchangeType: "kucoin", Name: "KuCoin Futures", Type: "cex"}, {ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"}, {ExchangeType: "aster", Name: "Aster DEX", Type: "dex"}, {ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"}, diff --git a/manager/trader_manager.go b/manager/trader_manager.go index bf9baedd..93dd1094 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -693,6 +693,10 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg case "gate": traderConfig.GateAPIKey = string(exchangeCfg.APIKey) traderConfig.GateSecretKey = string(exchangeCfg.SecretKey) + case "kucoin": + traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey) + traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey) + traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase) case "hyperliquid": traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey) traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr diff --git a/store/equity.go b/store/equity.go index 9c337019..db5b0b37 100644 --- a/store/equity.go +++ b/store/equity.go @@ -53,7 +53,9 @@ func (s *EquityStore) Save(snapshot *EquitySnapshot) error { snapshot.Timestamp = snapshot.Timestamp.UTC() } - if err := s.db.Create(snapshot).Error; err != nil { + // Omit ID to let PostgreSQL sequence auto-generate it + // Without this, GORM inserts ID=0 which causes duplicate key errors + if err := s.db.Omit("ID").Create(snapshot).Error; err != nil { return fmt.Errorf("failed to save equity snapshot: %w", err) } return nil diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 929e1949..db1fcae8 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -16,6 +16,7 @@ import ( "nofx/trader/bybit" "nofx/trader/gate" "nofx/trader/hyperliquid" + "nofx/trader/kucoin" "nofx/trader/lighter" "nofx/trader/okx" "strings" @@ -56,6 +57,11 @@ type AutoTraderConfig struct { GateAPIKey string GateSecretKey string + // KuCoin API configuration + KuCoinAPIKey string + KuCoinSecretKey string + KuCoinPassphrase string + // Hyperliquid configuration HyperliquidPrivateKey string HyperliquidWalletAddr string @@ -249,6 +255,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au case "gate": logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name) trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey) + case "kucoin": + logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name) + trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase) case "hyperliquid": logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) @@ -440,6 +449,14 @@ func (at *AutoTrader) Run() error { } } + // Start KuCoin order sync if using KuCoin exchange + if at.exchange == "kucoin" { + if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil { + kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) + logger.Infof("πŸ”„ [%s] KuCoin order+position sync enabled (every 30s)", at.name) + } + } + ticker := time.NewTicker(at.config.ScanInterval) defer ticker.Stop() @@ -557,15 +574,16 @@ func (at *AutoTrader) runCycle() error { return fmt.Errorf("failed to build trading context: %w", err) } + // Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve) + // NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded + at.saveEquitySnapshot(ctx) + // ε¦‚ζžœζ²‘ζœ‰ε€™ι€‰εΈη§οΌŒε‹ε₯½ζη€ΊεΉΆθ·³θΏ‡ζœ¬ε‘¨ζœŸ if len(ctx.CandidateCoins) == 0 { logger.Infof("ℹ️ No candidate coins available, skipping this cycle") return nil } - // Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve) - at.saveEquitySnapshot(ctx) - logger.Info(strings.Repeat("=", 70)) for _, coin := range ctx.CandidateCoins { record.CandidateCoins = append(record.CandidateCoins, coin.Symbol) @@ -844,14 +862,19 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { } // 3. Use strategy engine to get candidate coins (must have strategy engine) + var candidateCoins []kernel.CandidateCoin if at.strategyEngine == nil { - return nil, fmt.Errorf("trader has no strategy engine configured") + logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name) + } else { + coins, err := at.strategyEngine.GetCandidateCoins() + if err != nil { + // Log warning but don't fail - equity snapshot should still be saved + logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err) + } else { + candidateCoins = coins + logger.Infof("πŸ“‹ [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) + } } - candidateCoins, err := at.strategyEngine.GetCandidateCoins() - if err != nil { - return nil, fmt.Errorf("failed to get candidate coins: %w", err) - } - logger.Infof("πŸ“‹ [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) // 4. Calculate total P&L totalPnL := totalEquity - at.initialBalance @@ -1949,7 +1972,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, // 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": + case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin": logger.Infof(" πŸ“ Order submitted (id: %s), will be synced by OrderSync", orderID) return } diff --git a/web/src/components/landing/FooterSection.tsx b/web/src/components/landing/FooterSection.tsx index 360d146b..f09f373b 100644 --- a/web/src/components/landing/FooterSection.tsx +++ b/web/src/components/landing/FooterSection.tsx @@ -35,6 +35,7 @@ export default function FooterSection({ language }: FooterSectionProps) { { name: 'OKX', href: 'https://www.okx.com/join/1865360' }, { name: 'Bitget', href: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172' }, { name: 'Gate.io', href: 'https://www.gatenode.xyz/share/VQBGUAxY' }, + { name: 'KuCoin', href: 'https://www.kucoin.com/r/broker/CXEV7XKK' }, { name: 'Hyperliquid', href: 'https://app.hyperliquid.xyz/join/AITRADING' }, { name: 'Aster DEX', href: 'https://www.asterdex.com/en/referral/fdfc0e' }, { name: 'Lighter', href: 'https://app.lighter.xyz/?referral=68151432' },