From 093d2a329da80a48300f18ab84a9b4aee981ed8a Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sat, 31 Jan 2026 23:15:17 +0800 Subject: [PATCH] feat(gate): complete Gate.io exchange integration with trader refactoring Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data --- README.md | 3 +- api/server.go | 72 +- docs/i18n/ja/README.md | 3 +- docs/i18n/ko/README.md | 3 +- docs/i18n/ru/README.md | 3 +- docs/i18n/uk/README.md | 3 +- docs/i18n/vi/README.md | 3 +- docs/i18n/zh-CN/README.md | 3 +- go.mod | 2 + go.sum | 4 + manager/trader_manager.go | 3 + market/data.go | 72 +- .../order_sync.go} | 2 +- trader/{aster_trader.go => aster/trader.go} | 29 +- .../trader_test.go} | 28 +- trader/auto_trader.go | 63 +- .../futures.go} | 39 +- .../futures_test.go} | 30 +- .../order_sync.go} | 7 +- .../order_sync_test.go} | 2 +- .../sync_e2e_test.go} | 2 +- .../sync_verify_test.go} | 2 +- .../order_sync.go} | 2 +- trader/{bitget_trader.go => bitget/trader.go} | 21 +- .../order_sync.go} | 2 +- trader/{bybit_trader.go => bybit/trader.go} | 23 +- .../trader_test.go} | 36 +- trader/gate/order_sync.go | 282 ++++++ trader/gate/trader.go | 897 ++++++++++++++++++ trader/gate/trader_test.go | 337 +++++++ trader/{ => hyperliquid}/balance_test.go | 2 +- .../order_sync.go} | 2 +- .../sync_test.go} | 2 +- .../trader.go} | 27 +- .../trader_race_test.go} | 42 +- .../trader_test.go} | 20 +- trader/{ => hyperliquid}/xyz_dex_test.go | 2 +- trader/interface.go | 164 +--- .../account.go} | 6 +- .../integration_test.go} | 6 +- .../order_sync.go} | 2 +- .../orders.go} | 2 +- .../orders_test.go} | 2 +- .../trader.go} | 25 +- .../trading.go} | 13 +- trader/{lighter_types.go => lighter/types.go} | 10 +- .../{okx_order_sync.go => okx/order_sync.go} | 2 +- trader/{okx_trader.go => okx/trader.go} | 25 +- .../test_suite.go} | 7 +- trader/types/interface.go | 230 +++++ web/public/exchange-icons/gate.svg | 7 + web/public/exchange-icons/kucoin.svg | 6 + web/src/components/ExchangeIcons.tsx | 21 +- .../traders/ExchangeConfigModal.tsx | 4 +- 54 files changed, 2183 insertions(+), 424 deletions(-) rename trader/{aster_order_sync.go => aster/order_sync.go} (99%) rename trader/{aster_trader.go => aster/trader.go} (98%) rename trader/{aster_trader_test.go => aster/trader_test.go} (93%) rename trader/{binance_futures.go => binance/futures.go} (98%) rename trader/{binance_futures_test.go => binance/futures_test.go} (94%) rename trader/{binance_order_sync.go => binance/order_sync.go} (99%) rename trader/{binance_order_sync_test.go => binance/order_sync_test.go} (99%) rename trader/{binance_sync_e2e_test.go => binance/sync_e2e_test.go} (99%) rename trader/{binance_sync_verify_test.go => binance/sync_verify_test.go} (99%) rename trader/{bitget_order_sync.go => bitget/order_sync.go} (99%) rename trader/{bitget_trader.go => bitget/trader.go} (98%) rename trader/{bybit_order_sync.go => bybit/order_sync.go} (99%) rename trader/{bybit_trader.go => bybit/trader.go} (98%) rename trader/{bybit_trader_test.go => bybit/trader_test.go} (93%) create mode 100644 trader/gate/order_sync.go create mode 100644 trader/gate/trader.go create mode 100644 trader/gate/trader_test.go rename trader/{ => hyperliquid}/balance_test.go (99%) rename trader/{hyperliquid_order_sync.go => hyperliquid/order_sync.go} (99%) rename trader/{hyperliquid_sync_test.go => hyperliquid/sync_test.go} (99%) rename trader/{hyperliquid_trader.go => hyperliquid/trader.go} (99%) rename trader/{hyperliquid_trader_race_test.go => hyperliquid/trader_race_test.go} (85%) rename trader/{hyperliquid_trader_test.go => hyperliquid/trader_test.go} (97%) rename trader/{ => hyperliquid}/xyz_dex_test.go (99%) rename trader/{lighter_trader_v2_account.go => lighter/account.go} (99%) rename trader/{lighter_integration_test.go => lighter/integration_test.go} (99%) rename trader/{lighter_order_sync.go => lighter/order_sync.go} (99%) rename trader/{lighter_trader_v2_orders.go => lighter/orders.go} (99%) rename trader/{lighter_trader_v2_orders_test.go => lighter/orders_test.go} (99%) rename trader/{lighter_trader_v2.go => lighter/trader.go} (97%) rename trader/{lighter_trader_v2_trading.go => lighter/trading.go} (98%) rename trader/{lighter_types.go => lighter/types.go} (96%) rename trader/{okx_order_sync.go => okx/order_sync.go} (99%) rename trader/{okx_trader.go => okx/trader.go} (98%) rename trader/{trader_test_suite.go => testutil/test_suite.go} (99%) create mode 100644 trader/types/interface.go create mode 100644 web/public/exchange-icons/gate.svg create mode 100644 web/public/exchange-icons/kucoin.svg diff --git a/README.md b/README.md index 107cfa94..b34297c5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ### Core Features - **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime -- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform +- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform - **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls - **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst) - **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side @@ -84,6 +84,7 @@ To use NOFX, you'll need: | **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) | | **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ Supported | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ Supported | [Register](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (Decentralized Perpetual Exchanges) diff --git a/api/server.go b/api/server.go index 8bcbfd00..7e4a5214 100644 --- a/api/server.go +++ b/api/server.go @@ -20,6 +20,14 @@ import ( "nofx/provider/twelvedata" "nofx/store" "nofx/trader" + "nofx/trader/aster" + "nofx/trader/binance" + "nofx/trader/bitget" + "nofx/trader/bybit" + "nofx/trader/gate" + hyperliquidtrader "nofx/trader/hyperliquid" + "nofx/trader/lighter" + "nofx/trader/okx" "strconv" "strings" "time" @@ -585,40 +593,45 @@ func (s *Server) handleCreateTrader(c *gin.Context) { // Convert EncryptedString fields to string switch exchangeCfg.ExchangeType { case "binance": - tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) + tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) case "hyperliquid": - tempTrader, createErr = trader.NewHyperliquidTrader( + tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( string(exchangeCfg.APIKey), // private key exchangeCfg.HyperliquidWalletAddr, exchangeCfg.Testnet, ) case "aster": - tempTrader, createErr = trader.NewAsterTrader( + tempTrader, createErr = aster.NewAsterTrader( exchangeCfg.AsterUser, exchangeCfg.AsterSigner, string(exchangeCfg.AsterPrivateKey), ) case "bybit": - tempTrader = trader.NewBybitTrader( + tempTrader = bybit.NewBybitTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "okx": - tempTrader = trader.NewOKXTrader( + tempTrader = okx.NewOKXTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "bitget": - tempTrader = trader.NewBitgetTrader( + tempTrader = bitget.NewBitgetTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) + case "gate": + tempTrader = gate.NewGateTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet - tempTrader, createErr = trader.NewLighterTraderV2( + tempTrader, createErr = lighter.NewLighterTraderV2( exchangeCfg.LighterWalletAddr, string(exchangeCfg.LighterAPIKeyPrivateKey), exchangeCfg.LighterAPIKeyIndex, @@ -1143,40 +1156,45 @@ func (s *Server) handleSyncBalance(c *gin.Context) { // Convert EncryptedString fields to string switch exchangeCfg.ExchangeType { case "binance": - tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) + tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) case "hyperliquid": - tempTrader, createErr = trader.NewHyperliquidTrader( + tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( string(exchangeCfg.APIKey), exchangeCfg.HyperliquidWalletAddr, exchangeCfg.Testnet, ) case "aster": - tempTrader, createErr = trader.NewAsterTrader( + tempTrader, createErr = aster.NewAsterTrader( exchangeCfg.AsterUser, exchangeCfg.AsterSigner, string(exchangeCfg.AsterPrivateKey), ) case "bybit": - tempTrader = trader.NewBybitTrader( + tempTrader = bybit.NewBybitTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "okx": - tempTrader = trader.NewOKXTrader( + tempTrader = okx.NewOKXTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "bitget": - tempTrader = trader.NewBitgetTrader( + tempTrader = bitget.NewBitgetTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) + case "gate": + tempTrader = gate.NewGateTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet - tempTrader, createErr = trader.NewLighterTraderV2( + tempTrader, createErr = lighter.NewLighterTraderV2( exchangeCfg.LighterWalletAddr, string(exchangeCfg.LighterAPIKeyPrivateKey), exchangeCfg.LighterAPIKeyIndex, @@ -1295,40 +1313,45 @@ func (s *Server) handleClosePosition(c *gin.Context) { // Convert EncryptedString fields to string switch exchangeCfg.ExchangeType { case "binance": - tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) + tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) case "hyperliquid": - tempTrader, createErr = trader.NewHyperliquidTrader( + tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( string(exchangeCfg.APIKey), exchangeCfg.HyperliquidWalletAddr, exchangeCfg.Testnet, ) case "aster": - tempTrader, createErr = trader.NewAsterTrader( + tempTrader, createErr = aster.NewAsterTrader( exchangeCfg.AsterUser, exchangeCfg.AsterSigner, string(exchangeCfg.AsterPrivateKey), ) case "bybit": - tempTrader = trader.NewBybitTrader( + tempTrader = bybit.NewBybitTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), ) case "okx": - tempTrader = trader.NewOKXTrader( + tempTrader = okx.NewOKXTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) case "bitget": - tempTrader = trader.NewBitgetTrader( + tempTrader = bitget.NewBitgetTrader( string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase), ) + case "gate": + tempTrader = gate.NewGateTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + ) case "lighter": if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { // Lighter only supports mainnet - tempTrader, createErr = trader.NewLighterTraderV2( + tempTrader, createErr = lighter.NewLighterTraderV2( exchangeCfg.LighterWalletAddr, string(exchangeCfg.LighterAPIKeyPrivateKey), exchangeCfg.LighterAPIKeyIndex, @@ -1407,7 +1430,7 @@ func (s *Server) handleClosePosition(c *gin.Context) { func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) { // Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates switch exchangeType { - case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster": + case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate": logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record") return } @@ -1961,7 +1984,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, + "hyperliquid": true, "aster": true, "lighter": true, "gate": true, } if !validTypes[req.ExchangeType] { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) @@ -2493,6 +2516,8 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i coinankExchange = coinank_enum.Okex case "bitget": coinankExchange = coinank_enum.Bitget + case "gate": + coinankExchange = coinank_enum.Gate case "aster": coinankExchange = coinank_enum.Aster case "lighter": @@ -3342,6 +3367,7 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) { {ExchangeType: "binance", Name: "Binance Futures", Type: "cex"}, {ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"}, {ExchangeType: "okx", Name: "OKX Futures", Type: "cex"}, + {ExchangeType: "gate", Name: "Gate.io 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/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index 2793937b..b5fe2e16 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -24,7 +24,7 @@ ### コア機能 - **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能 -- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引 +- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引 - **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー - **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡 - **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定 @@ -64,6 +64,7 @@ NOFXを使用するには以下が必要です: | **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) | | **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ サポート | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ サポート | [登録](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (分散型永久先物取引所) diff --git a/docs/i18n/ko/README.md b/docs/i18n/ko/README.md index be8454c2..7ff4a41e 100644 --- a/docs/i18n/ko/README.md +++ b/docs/i18n/ko/README.md @@ -24,7 +24,7 @@ ### 핵심 기능 - **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능 -- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래 +- **다중 거래소**: Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래 - **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더 - **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적 - **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료 @@ -64,6 +64,7 @@ NOFX를 사용하려면 다음이 필요합니다: | **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) | | **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ 지원 | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ 지원 | [등록](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (탈중앙화 영구 선물 거래소) diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index 3a099a1c..4c4edb31 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -24,7 +24,7 @@ ### Основные функции - **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время -- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы +- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы - **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков - **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок - **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс @@ -64,6 +64,7 @@ | **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) | | **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ Поддерживается | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ Поддерживается | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (Децентрализованные биржи) diff --git a/docs/i18n/uk/README.md b/docs/i18n/uk/README.md index 1663d929..6b08325c 100644 --- a/docs/i18n/uk/README.md +++ b/docs/i18n/uk/README.md @@ -24,7 +24,7 @@ ### Основні функції - **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли -- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи +- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи - **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків - **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч - **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс @@ -64,6 +64,7 @@ | **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) | | **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ Підтримується | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ Підтримується | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (Децентралізовані біржі) diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md index 003cad0f..f91f8105 100644 --- a/docs/i18n/vi/README.md +++ b/docs/i18n/vi/README.md @@ -24,7 +24,7 @@ ### Tính Năng Chính - **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào -- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng +- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter từ một nền tảng - **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro - **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song - **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web @@ -64,6 +64,7 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx | **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) | | **Bitget** | ✅ Hỗ trợ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ Hỗ trợ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ Hỗ trợ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (Sàn Phi Tập Trung) diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index d4898ab9..12799772 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -31,7 +31,7 @@ ### 核心功能 - **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型 -- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易 +- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易 - **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数 - **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现 - **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置 @@ -76,6 +76,7 @@ | **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) | | **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **KuCoin** | ✅ 已支持 | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) | +| **Gate** | ✅ 已支持 | [注册](https://www.gatenode.xyz/share/VQBGUAxY) | ### Perp-DEX (去中心化永续交易所) diff --git a/go.mod b/go.mod index eb33cad4..d6d0127f 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( require ( github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/antihax/optional v1.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bitly/go-simplejson v0.5.1 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect @@ -44,6 +45,7 @@ require ( github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gateio/gateapi-go/v6 v6.104.3 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect diff --git a/go.sum b/go.sum index 48302780..732fc62b 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/S github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM= github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo= github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= @@ -68,6 +70,8 @@ github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeD github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k= +github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 4060a794..bf9baedd 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -690,6 +690,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey) traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey) traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase) + case "gate": + traderConfig.GateAPIKey = string(exchangeCfg.APIKey) + traderConfig.GateSecretKey = string(exchangeCfg.SecretKey) case "hyperliquid": traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey) traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr diff --git a/market/data.go b/market/data.go index a993736a..1860ff54 100644 --- a/market/data.go +++ b/market/data.go @@ -31,7 +31,7 @@ var ( // Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication // getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli) -func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) { +func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) { // Map interval string to coinank enum var coinankInterval coinank_enum.Interval switch interval { @@ -67,13 +67,44 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) { return nil, fmt.Errorf("unsupported interval: %s", interval) } + // Map exchange string to coinank enum + var coinankExchange coinank_enum.Exchange + switch strings.ToLower(exchange) { + case "binance": + coinankExchange = coinank_enum.Binance + case "bybit": + coinankExchange = coinank_enum.Bybit + case "okx": + coinankExchange = coinank_enum.Okex + case "bitget": + coinankExchange = coinank_enum.Bitget + case "gate": + coinankExchange = coinank_enum.Gate + case "hyperliquid": + coinankExchange = coinank_enum.Hyperliquid + case "aster": + coinankExchange = coinank_enum.Aster + default: + // Default to Binance for unknown exchanges + coinankExchange = coinank_enum.Binance + } + // Call CoinAnk free/open API (no authentication required) ctx := context.Background() ts := time.Now().UnixMilli() // Use "To" side to search backward from current time (get historical klines) - coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval) + coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval) if err != nil { - return nil, fmt.Errorf("CoinAnk API error: %w", err) + // If exchange-specific data fails, fallback to Binance + if coinankExchange != coinank_enum.Binance { + logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err) + coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval) + if err != nil { + return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err) + } + } else { + return nil, fmt.Errorf("CoinAnk API error: %w", err) + } } // Convert coinank kline format to market.Kline format @@ -134,8 +165,13 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro return klines, nil } -// Get retrieves market data for the specified token +// Get retrieves market data for the specified token (uses Binance data by default) func Get(symbol string) (*Data, error) { + return GetWithExchange(symbol, "binance") +} + +// GetWithExchange retrieves market data for the specified token using exchange-specific data +func GetWithExchange(symbol, exchange string) (*Data, error) { var klines3m, klines4h []Kline var err error // Normalize symbol @@ -144,18 +180,21 @@ func Get(symbol string) (*Data, error) { // Check if this is an xyz dex asset (use Hyperliquid API) isXyzAsset := IsXyzDexAsset(symbol) + // For hyperliquid exchange, also use Hyperliquid API + useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid" + // Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available) - if isXyzAsset { + if useHyperliquidAPI { // Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available) klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100) if err != nil { return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err) } } else { - // Use CoinAnk for regular crypto assets - klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100) + // Use CoinAnk for regular crypto assets with exchange-specific data + klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100) if err != nil { - return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err) + return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err) } } @@ -166,15 +205,15 @@ func Get(symbol string) (*Data, error) { } // Get 4-hour K-line data - if isXyzAsset { + if useHyperliquidAPI { klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100) if err != nil { return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err) } } else { - klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100) + klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100) if err != nil { - return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err) + return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err) } } @@ -290,8 +329,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri continue } } else { - // Use CoinAnk for regular crypto assets - klines, err = getKlinesFromCoinAnk(symbol, tf, 200) + // Use CoinAnk for regular crypto assets (default to Binance) + klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200) if err != nil { logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err) continue @@ -1068,6 +1107,11 @@ func Normalize(symbol string) string { return "xyz:" + base } + // Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP) + symbol = strings.ReplaceAll(symbol, "_", "") + symbol = strings.ReplaceAll(symbol, "-SWAP", "") + symbol = strings.ReplaceAll(symbol, "-", "") + // For regular crypto assets if strings.HasSuffix(symbol, "USDT") { return symbol @@ -1283,7 +1327,7 @@ func GetBoxData(symbol string) (*BoxData, error) { if IsXyzDexAsset(symbol) { klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod) } else { - klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod) + klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod) } if err != nil { diff --git a/trader/aster_order_sync.go b/trader/aster/order_sync.go similarity index 99% rename from trader/aster_order_sync.go rename to trader/aster/order_sync.go index f7d7d526..2a1c0d51 100644 --- a/trader/aster_order_sync.go +++ b/trader/aster/order_sync.go @@ -1,4 +1,4 @@ -package trader +package aster import ( "fmt" diff --git a/trader/aster_trader.go b/trader/aster/trader.go similarity index 98% rename from trader/aster_trader.go rename to trader/aster/trader.go index 1d9a547b..117d73be 100644 --- a/trader/aster_trader.go +++ b/trader/aster/trader.go @@ -1,4 +1,4 @@ -package trader +package aster import ( "context" @@ -23,6 +23,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + "nofx/trader/types" ) // AsterTrader Aster trading platform implementation @@ -1295,14 +1296,14 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string] // GetClosedPnL gets recent closing trades from Aster // Note: Aster does NOT have a position history API, only trade history. // This returns individual closing trades for real-time position closure detection. -func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { trades, err := t.GetTrades(startTime, limit) if err != nil { return nil, err } // Filter only closing trades (realizedPnl != 0) - var records []ClosedPnLRecord + var records []types.ClosedPnLRecord for _, trade := range trades { if trade.RealizedPnL == 0 { continue @@ -1330,7 +1331,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLR } } - records = append(records, ClosedPnLRecord{ + records = append(records, types.ClosedPnLRecord{ Symbol: trade.Symbol, Side: side, EntryPrice: entryPrice, @@ -1366,7 +1367,7 @@ type AsterTradeRecord struct { } // GetTrades retrieves trade history from Aster -func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { +func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) { if limit <= 0 { limit = 500 } @@ -1381,24 +1382,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, body, err := t.request("GET", "/fapi/v3/userTrades", params) if err != nil { logger.Infof("⚠️ Aster userTrades API error: %v", err) - return []TradeRecord{}, nil + return []types.TradeRecord{}, nil } var asterTrades []AsterTradeRecord if err := json.Unmarshal(body, &asterTrades); err != nil { logger.Infof("⚠️ Failed to parse Aster trades response: %v", err) - return []TradeRecord{}, nil + return []types.TradeRecord{}, nil } // Convert to unified TradeRecord format - var result []TradeRecord + var result []types.TradeRecord for _, at := range asterTrades { price, _ := strconv.ParseFloat(at.Price, 64) qty, _ := strconv.ParseFloat(at.Qty, 64) fee, _ := strconv.ParseFloat(at.Commission, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) - trade := TradeRecord{ + trade := types.TradeRecord{ TradeID: strconv.FormatInt(at.ID, 10), Symbol: at.Symbol, Side: at.Side, @@ -1416,7 +1417,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, } // GetOpenOrders gets all open/pending orders for a symbol -func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { +func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { params := map[string]interface{}{ "symbol": symbol, } @@ -1442,13 +1443,13 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { return nil, fmt.Errorf("failed to parse open orders: %w", err) } - var result []OpenOrder + var result []types.OpenOrder for _, order := range orders { price, _ := strconv.ParseFloat(order.Price, 64) stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64) quantity, _ := strconv.ParseFloat(order.OrigQty, 64) - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: fmt.Sprintf("%d", order.OrderID), Symbol: order.Symbol, Side: order.Side, @@ -1466,7 +1467,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { } // PlaceLimitOrder places a limit order for grid trading -func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { // Format price and quantity to correct precision formattedPrice, err := t.formatPrice(req.Symbol, req.Price) if err != nil { @@ -1532,7 +1533,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult clientOrderID = cid } - return &LimitOrderResult{ + return &types.LimitOrderResult{ OrderID: orderID, ClientID: clientOrderID, Symbol: req.Symbol, diff --git a/trader/aster_trader_test.go b/trader/aster/trader_test.go similarity index 93% rename from trader/aster_trader_test.go rename to trader/aster/trader_test.go index 0a7a8c8e..ba3c7c05 100644 --- a/trader/aster_trader_test.go +++ b/trader/aster/trader_test.go @@ -1,4 +1,4 @@ -package trader +package aster import ( "context" @@ -10,6 +10,8 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" + "nofx/trader/testutil" + "nofx/trader/types" ) // ============================================================ @@ -19,8 +21,8 @@ import ( // AsterTraderTestSuite Aster trader test suite // Inherits TraderTestSuite and adds Aster specific mock logic type AsterTraderTestSuite struct { - *TraderTestSuite // Embeds base test suite - mockServer *httptest.Server + *testutil.TraderTestSuite // Embeds base test suite + mockServer *httptest.Server } // NewAsterTraderTestSuite creates Aster test suite @@ -191,7 +193,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite { privateKey, _ := crypto.GenerateKey() // Create mock trader using mock server's URL - trader := &AsterTrader{ + traderInstance := &AsterTrader{ ctx: context.Background(), user: "0x1234567890123456789012345678901234567890", signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", @@ -202,7 +204,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite { } // Create base suite - baseSuite := NewTraderTestSuite(t, trader) + baseSuite := testutil.NewTraderTestSuite(t, traderInstance) return &AsterTraderTestSuite{ TraderTestSuite: baseSuite, @@ -224,7 +226,7 @@ func (s *AsterTraderTestSuite) Cleanup() { // TestAsterTrader_InterfaceCompliance tests interface compliance func TestAsterTrader_InterfaceCompliance(t *testing.T) { - var _ Trader = (*AsterTrader)(nil) + var _ types.Trader = (*AsterTrader)(nil) } // TestAsterTrader_CommonInterface runs all common interface tests using test suite @@ -277,21 +279,21 @@ func TestNewAsterTrader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - trader, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex) + at, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex) if tt.wantError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } - assert.Nil(t, trader) + assert.Nil(t, at) } else { assert.NoError(t, err) - assert.NotNil(t, trader) - if trader != nil { - assert.Equal(t, tt.user, trader.user) - assert.Equal(t, tt.signer, trader.signer) - assert.NotNil(t, trader.privateKey) + assert.NotNil(t, at) + if at != nil { + assert.Equal(t, tt.user, at.user) + assert.Equal(t, tt.signer, at.signer) + assert.NotNil(t, at.privateKey) } } }) diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 5a59c662..929e1949 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -4,12 +4,20 @@ import ( "encoding/json" "fmt" "math" - "nofx/kernel" "nofx/experience" + "nofx/kernel" "nofx/logger" "nofx/market" "nofx/mcp" "nofx/store" + "nofx/trader/aster" + "nofx/trader/binance" + "nofx/trader/bitget" + "nofx/trader/bybit" + "nofx/trader/gate" + "nofx/trader/hyperliquid" + "nofx/trader/lighter" + "nofx/trader/okx" "strings" "sync" "time" @@ -23,7 +31,7 @@ type AutoTraderConfig struct { AIModel string // AI model: "qwen" or "deepseek" // Trading platform selection - Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter" + Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter" ExchangeID string // Exchange account UUID (for multi-account support) // Binance API configuration @@ -44,6 +52,10 @@ type AutoTraderConfig struct { BitgetSecretKey string BitgetPassphrase string + // Gate API configuration + GateAPIKey string + GateSecretKey string + // Hyperliquid configuration HyperliquidPrivateKey string HyperliquidWalletAddr string @@ -224,25 +236,28 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au switch config.Exchange { case "binance": logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name) - trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID) + trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID) case "bybit": logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name) - trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) + trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) case "okx": logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name) - trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase) + trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase) case "bitget": logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name) - trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase) + trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase) + case "gate": + logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name) + trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey) case "hyperliquid": logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) - trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) + trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) if err != nil { return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err) } case "aster": logger.Infof("🏦 [%s] Using Aster trading", config.Name) - trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey) + trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey) if err != nil { return nil, fmt.Errorf("failed to initialize Aster trader: %w", err) } @@ -254,7 +269,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au } // Lighter only supports mainnet (testnet disabled) - trader, err = NewLighterTraderV2( + trader, err = lighter.NewLighterTraderV2( config.LighterWalletAddr, config.LighterAPIKeyPrivateKey, config.LighterAPIKeyIndex, @@ -363,7 +378,7 @@ func (at *AutoTrader) Run() error { // Start Lighter order sync if using Lighter exchange if at.exchange == "lighter" { - if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil { + if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil { lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name) } @@ -371,7 +386,7 @@ func (at *AutoTrader) Run() error { // Start Hyperliquid order sync if using Hyperliquid exchange if at.exchange == "hyperliquid" { - if hyperliquidTrader, ok := at.trader.(*HyperliquidTrader); ok && at.store != nil { + if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil { hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name) } @@ -379,7 +394,7 @@ func (at *AutoTrader) Run() error { // Start Bybit order sync if using Bybit exchange if at.exchange == "bybit" { - if bybitTrader, ok := at.trader.(*BybitTrader); ok && at.store != nil { + if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil { bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name) } @@ -387,7 +402,7 @@ func (at *AutoTrader) Run() error { // Start OKX order sync if using OKX exchange if at.exchange == "okx" { - if okxTrader, ok := at.trader.(*OKXTrader); ok && at.store != nil { + if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil { okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name) } @@ -395,7 +410,7 @@ func (at *AutoTrader) Run() error { // Start Bitget order sync if using Bitget exchange if at.exchange == "bitget" { - if bitgetTrader, ok := at.trader.(*BitgetTrader); ok && at.store != nil { + if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil { bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name) } @@ -403,7 +418,7 @@ func (at *AutoTrader) Run() error { // Start Aster order sync if using Aster exchange if at.exchange == "aster" { - if asterTrader, ok := at.trader.(*AsterTrader); ok && at.store != nil { + if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil { asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name) } @@ -411,12 +426,20 @@ func (at *AutoTrader) Run() error { // Start Binance order sync if using Binance exchange if at.exchange == "binance" { - if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil { + if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil { binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name) } } + // Start Gate order sync if using Gate exchange + if at.exchange == "gate" { + if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil { + gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second) + logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name) + } + } + ticker := time.NewTicker(at.config.ScanInterval) defer ticker.Stop() @@ -1050,7 +1073,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio } // Get current price - marketData, err := market.Get(decision.Symbol) + marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } @@ -1167,7 +1190,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti } // Get current price - marketData, err := market.Get(decision.Symbol) + marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } @@ -1266,7 +1289,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti logger.Infof(" 🔄 Close long: %s", decision.Symbol) // Get current price - marketData, err := market.Get(decision.Symbol) + marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } @@ -1330,7 +1353,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act logger.Infof(" 🔄 Close short: %s", decision.Symbol) // Get current price - marketData, err := market.Get(decision.Symbol) + marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { return err } diff --git a/trader/binance_futures.go b/trader/binance/futures.go similarity index 98% rename from trader/binance_futures.go rename to trader/binance/futures.go index a7ef6dd0..723cb470 100644 --- a/trader/binance_futures.go +++ b/trader/binance/futures.go @@ -1,4 +1,4 @@ -package trader +package binance import ( "context" @@ -7,6 +7,7 @@ import ( "fmt" "nofx/hook" "nofx/logger" + "nofx/trader/types" "strconv" "strings" "sync" @@ -718,7 +719,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error { // PlaceLimitOrder places a limit order for grid trading // This implements the GridTrader interface for FuturesTrader -func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { // Format quantity to correct precision quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity) if err != nil { @@ -770,7 +771,7 @@ func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResu logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d", req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID) - return &LimitOrderResult{ + return &types.LimitOrderResult{ OrderID: fmt.Sprintf("%d", order.OrderID), ClientID: order.ClientOrderID, Symbol: order.Symbol, @@ -896,8 +897,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error { } // GetOpenOrders gets all open/pending orders for a symbol -func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { - var result []OpenOrder +func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { + var result []types.OpenOrder // 1. Get legacy open orders orders, err := t.client.NewListOpenOrdersService(). @@ -913,7 +914,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64) quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64) - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: fmt.Sprintf("%d", order.OrderID), Symbol: order.Symbol, Side: string(order.Side), @@ -936,7 +937,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64) quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64) - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: fmt.Sprintf("%d", algoOrder.AlgoId), Symbol: algoOrder.Symbol, Side: string(algoOrder.Side), @@ -1247,14 +1248,14 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin // Note: Binance does NOT have a position history API, only trade history. // This returns individual closing trades (realizedPnl != 0) for real-time position closure detection. // NOT suitable for historical position reconstruction - use only for matching recent closures. -func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { trades, err := t.GetTrades(startTime, limit) if err != nil { return nil, err } // Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord - var records []ClosedPnLRecord + var records []types.ClosedPnLRecord for _, trade := range trades { if trade.RealizedPnL == 0 { continue // Skip opening trades @@ -1283,7 +1284,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn } } - records = append(records, ClosedPnLRecord{ + records = append(records, types.ClosedPnLRecord{ Symbol: trade.Symbol, Side: side, EntryPrice: entryPrice, @@ -1304,7 +1305,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn // GetTrades retrieves trade history from Binance Futures using Income API // Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead -func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { +func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) { if limit <= 0 { limit = 100 } @@ -1322,7 +1323,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord return nil, fmt.Errorf("failed to get income history: %w", err) } - var trades []TradeRecord + var trades []types.TradeRecord for _, income := range incomes { pnl, _ := strconv.ParseFloat(income.Income, 64) if pnl == 0 { @@ -1331,7 +1332,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord // Income API doesn't provide full trade details, create a minimal record // This is mainly used for detecting recent closures, not historical reconstruction - trade := TradeRecord{ + trade := types.TradeRecord{ TradeID: strconv.FormatInt(income.TranID, 10), Symbol: income.Symbol, RealizedPnL: pnl, @@ -1347,7 +1348,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord // GetTradesForSymbol retrieves trade history for a specific symbol // This is more reliable than using Income API which may have delays -func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) { +func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]types.TradeRecord, error) { if limit <= 0 { limit = 100 } @@ -1364,14 +1365,14 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err) } - var trades []TradeRecord + var trades []types.TradeRecord for _, at := range accountTrades { price, _ := strconv.ParseFloat(at.Price, 64) qty, _ := strconv.ParseFloat(at.Quantity, 64) fee, _ := strconv.ParseFloat(at.Commission, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) - trade := TradeRecord{ + trade := types.TradeRecord{ TradeID: strconv.FormatInt(at.ID, 10), Symbol: at.Symbol, Side: string(at.Side), @@ -1390,7 +1391,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l // GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID // This is used for incremental sync - only fetch new trades since last sync -func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) { +func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]types.TradeRecord, error) { if limit <= 0 { limit = 100 } @@ -1407,14 +1408,14 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err) } - var trades []TradeRecord + var trades []types.TradeRecord for _, at := range accountTrades { price, _ := strconv.ParseFloat(at.Price, 64) qty, _ := strconv.ParseFloat(at.Quantity, 64) fee, _ := strconv.ParseFloat(at.Commission, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) - trade := TradeRecord{ + trade := types.TradeRecord{ TradeID: strconv.FormatInt(at.ID, 10), Symbol: at.Symbol, Side: string(at.Side), diff --git a/trader/binance_futures_test.go b/trader/binance/futures_test.go similarity index 94% rename from trader/binance_futures_test.go rename to trader/binance/futures_test.go index f6a166b3..270e9b98 100644 --- a/trader/binance_futures_test.go +++ b/trader/binance/futures_test.go @@ -1,4 +1,4 @@ -package trader +package binance import ( "encoding/json" @@ -11,6 +11,8 @@ import ( "github.com/adshao/go-binance/v2/futures" "github.com/stretchr/testify/assert" + "nofx/trader/testutil" + "nofx/trader/types" ) // ============================================================ @@ -20,8 +22,8 @@ import ( // BinanceFuturesTestSuite Binance Futures trader test suite // Inherits TraderTestSuite and adds Binance Futures specific mock logic type BinanceFuturesTestSuite struct { - *TraderTestSuite // Embeds base test suite - mockServer *httptest.Server + *testutil.TraderTestSuite // Embeds base test suite + mockServer *httptest.Server } // NewBinanceFuturesTestSuite Creates Binance Futures test suite @@ -270,13 +272,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite { client.HTTPClient = mockServer.Client() // Create FuturesTrader - trader := &FuturesTrader{ + traderInstance := &FuturesTrader{ client: client, cacheDuration: 0, // disable cache for testing } // Create base suite - baseSuite := NewTraderTestSuite(t, trader) + baseSuite := testutil.NewTraderTestSuite(t, traderInstance) return &BinanceFuturesTestSuite{ TraderTestSuite: baseSuite, @@ -298,7 +300,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() { // TestFuturesTrader_InterfaceCompliance tests interface compliance func TestFuturesTrader_InterfaceCompliance(t *testing.T) { - var _ Trader = (*FuturesTrader)(nil) + var _ types.Trader = (*FuturesTrader)(nil) } // TestFuturesTrader_CommonInterface runs all common interface tests using test suite @@ -343,20 +345,20 @@ func TestNewFuturesTrader(t *testing.T) { defer mockServer.Close() // Test successful creation - trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user") + t1 := NewFuturesTrader("test_api_key", "test_secret_key", "test_user") // Modify client to use mock server - trader.client.BaseURL = mockServer.URL - trader.client.HTTPClient = mockServer.Client() + t1.client.BaseURL = mockServer.URL + t1.client.HTTPClient = mockServer.Client() - assert.NotNil(t, trader) - assert.NotNil(t, trader.client) - assert.Equal(t, 15*time.Second, trader.cacheDuration) + assert.NotNil(t, t1) + assert.NotNil(t, t1.client) + assert.Equal(t, 15*time.Second, t1.cacheDuration) } // TestCalculatePositionSize tests position size calculation func TestCalculatePositionSize(t *testing.T) { - trader := &FuturesTrader{} + ft := &FuturesTrader{} tests := []struct { name string @@ -394,7 +396,7 @@ func TestCalculatePositionSize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - quantity := trader.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage) + quantity := ft.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage) assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect") }) } diff --git a/trader/binance_order_sync.go b/trader/binance/order_sync.go similarity index 99% rename from trader/binance_order_sync.go rename to trader/binance/order_sync.go index 459d5b8a..76705395 100644 --- a/trader/binance_order_sync.go +++ b/trader/binance/order_sync.go @@ -1,10 +1,11 @@ -package trader +package binance import ( "fmt" "nofx/logger" "nofx/market" "nofx/store" + "nofx/trader/types" "sort" "strings" "sync" @@ -126,11 +127,11 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols) // Step 3: Query trades for changed symbols using fromId (incremental) or time-based (new symbols) - var allTrades []TradeRecord + var allTrades []types.TradeRecord var failedSymbols []string apiCalls := 0 for _, symbol := range changedSymbols { - var trades []TradeRecord + var trades []types.TradeRecord var queryErr error if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 { diff --git a/trader/binance_order_sync_test.go b/trader/binance/order_sync_test.go similarity index 99% rename from trader/binance_order_sync_test.go rename to trader/binance/order_sync_test.go index c46ce07f..0ef6cb5e 100644 --- a/trader/binance_order_sync_test.go +++ b/trader/binance/order_sync_test.go @@ -1,4 +1,4 @@ -package trader +package binance import ( "context" diff --git a/trader/binance_sync_e2e_test.go b/trader/binance/sync_e2e_test.go similarity index 99% rename from trader/binance_sync_e2e_test.go rename to trader/binance/sync_e2e_test.go index 91f42436..5269a275 100644 --- a/trader/binance_sync_e2e_test.go +++ b/trader/binance/sync_e2e_test.go @@ -1,4 +1,4 @@ -package trader +package binance import ( "nofx/store" diff --git a/trader/binance_sync_verify_test.go b/trader/binance/sync_verify_test.go similarity index 99% rename from trader/binance_sync_verify_test.go rename to trader/binance/sync_verify_test.go index 2c05461d..91ceb9ea 100644 --- a/trader/binance_sync_verify_test.go +++ b/trader/binance/sync_verify_test.go @@ -1,4 +1,4 @@ -package trader +package binance import ( "context" diff --git a/trader/bitget_order_sync.go b/trader/bitget/order_sync.go similarity index 99% rename from trader/bitget_order_sync.go rename to trader/bitget/order_sync.go index df7bc8a8..e8f10789 100644 --- a/trader/bitget_order_sync.go +++ b/trader/bitget/order_sync.go @@ -1,4 +1,4 @@ -package trader +package bitget import ( "encoding/json" diff --git a/trader/bitget_trader.go b/trader/bitget/trader.go similarity index 98% rename from trader/bitget_trader.go rename to trader/bitget/trader.go index 86ae2f1a..d44f7e01 100644 --- a/trader/bitget_trader.go +++ b/trader/bitget/trader.go @@ -1,4 +1,4 @@ -package trader +package bitget import ( "bytes" @@ -14,6 +14,7 @@ import ( "strings" "sync" "time" + "nofx/trader/types" ) // Bitget API endpoints (V2) @@ -1013,7 +1014,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string } // GetClosedPnL retrieves closed position PnL records -func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { if limit <= 0 { limit = 100 } @@ -1051,9 +1052,9 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL return nil, fmt.Errorf("failed to parse response: %w", err) } - records := make([]ClosedPnLRecord, 0, len(resp.List)) + records := make([]types.ClosedPnLRecord, 0, len(resp.List)) for _, pos := range resp.List { - record := ClosedPnLRecord{ + record := types.ClosedPnLRecord{ Symbol: pos.Symbol, Side: pos.HoldSide, } @@ -1098,9 +1099,9 @@ func genBitgetClientOid() string { } // GetOpenOrders gets all open/pending orders for a symbol -func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { +func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { symbol = t.convertSymbol(symbol) - var result []OpenOrder + var result []types.OpenOrder // 1. Get pending limit orders params := map[string]interface{}{ @@ -1135,7 +1136,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { side := strings.ToUpper(order.Side) positionSide := strings.ToUpper(order.PosSide) - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: order.OrderId, Symbol: symbol, Side: side, @@ -1208,7 +1209,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { side := strings.ToUpper(order.Side) positionSide := strings.ToUpper(order.PosSide) - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: order.OrderId, Symbol: order.Symbol, Side: side, @@ -1229,7 +1230,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { // PlaceLimitOrder places a limit order for grid trading // Implements GridTrader interface -func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { symbol := t.convertSymbol(req.Symbol) // Set leverage if specified @@ -1285,7 +1286,7 @@ func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResul logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s", symbol, side, req.Price, order.OrderId) - return &LimitOrderResult{ + return &types.LimitOrderResult{ OrderID: order.OrderId, ClientID: order.ClientOid, Symbol: req.Symbol, diff --git a/trader/bybit_order_sync.go b/trader/bybit/order_sync.go similarity index 99% rename from trader/bybit_order_sync.go rename to trader/bybit/order_sync.go index 662f8d9d..cf54afac 100644 --- a/trader/bybit_order_sync.go +++ b/trader/bybit/order_sync.go @@ -1,4 +1,4 @@ -package trader +package bybit import ( "crypto/hmac" diff --git a/trader/bybit_trader.go b/trader/bybit/trader.go similarity index 98% rename from trader/bybit_trader.go rename to trader/bybit/trader.go index 745a58e4..1d4e87c7 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit/trader.go @@ -1,4 +1,4 @@ -package trader +package bybit import ( "context" @@ -17,6 +17,7 @@ import ( "time" bybit "github.com/bybit-exchange/bybit.go.api" + "nofx/trader/types" ) // BybitTrader Bybit USDT Perpetual Futures Trader @@ -900,13 +901,13 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e } // GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API -func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { // The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call return t.getClosedPnLViaHTTP(startTime, limit) } // getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing -func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { // Build query string queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit) url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams @@ -967,14 +968,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]Clo } // parseClosedPnLResult parses the closed PnL result from Bybit API -func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) { +func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) { data, ok := resultData.(map[string]interface{}) if !ok { return nil, fmt.Errorf("invalid result format") } list, _ := data["list"].([]interface{}) - var records []ClosedPnLRecord + var records []types.ClosedPnLRecord for _, item := range list { pnl, ok := item.(map[string]interface{}) @@ -1023,7 +1024,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR normalizedSide = "short" } - record := ClosedPnLRecord{ + record := types.ClosedPnLRecord{ Symbol: symbol, Side: normalizedSide, EntryPrice: avgEntryPrice, @@ -1046,8 +1047,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR } // GetOpenOrders gets all open/pending orders for a symbol -func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { - var result []OpenOrder +func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { + var result []types.OpenOrder // Get conditional orders (stop-loss, take-profit) params := map[string]interface{}{ @@ -1088,7 +1089,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { displayType = stopOrderType } - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: orderId, Symbol: sym, Side: side, @@ -1108,7 +1109,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { // PlaceLimitOrder places a limit order for grid trading // Implements GridTrader interface -func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { // Format quantity qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity) if err != nil { @@ -1169,7 +1170,7 @@ func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s", req.Symbol, side, priceStr, qtyStr, orderID) - return &LimitOrderResult{ + return &types.LimitOrderResult{ OrderID: orderID, ClientID: req.ClientID, Symbol: req.Symbol, diff --git a/trader/bybit_trader_test.go b/trader/bybit/trader_test.go similarity index 93% rename from trader/bybit_trader_test.go rename to trader/bybit/trader_test.go index 39808014..07011b01 100644 --- a/trader/bybit_trader_test.go +++ b/trader/bybit/trader_test.go @@ -1,4 +1,4 @@ -package trader +package bybit import ( "encoding/json" @@ -9,6 +9,8 @@ import ( "time" "github.com/stretchr/testify/assert" + "nofx/trader/testutil" + "nofx/trader/types" ) // ============================================================ @@ -18,8 +20,8 @@ import ( // BybitTraderTestSuite Bybit trader test suite // Inherits TraderTestSuite and adds Bybit-specific mock logic type BybitTraderTestSuite struct { - *TraderTestSuite // Embeds base test suite - mockServer *httptest.Server + *testutil.TraderTestSuite // Embeds base test suite + mockServer *httptest.Server } // NewBybitTraderTestSuite Create Bybit test suite @@ -66,10 +68,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite { })) // Create real Bybit trader (for interface compliance testing) - trader := NewBybitTrader("test_api_key", "test_secret_key") + traderInstance := NewBybitTrader("test_api_key", "test_secret_key") // Create base suite - baseSuite := NewTraderTestSuite(t, trader) + baseSuite := testutil.NewTraderTestSuite(t, traderInstance) return &BybitTraderTestSuite{ TraderTestSuite: baseSuite, @@ -91,7 +93,7 @@ func (s *BybitTraderTestSuite) Cleanup() { // TestBybitTrader_InterfaceCompliance Test interface compliance func TestBybitTrader_InterfaceCompliance(t *testing.T) { - var _ Trader = (*BybitTrader)(nil) + var _ types.Trader = (*BybitTrader)(nil) } // ============================================================ @@ -128,13 +130,13 @@ func TestNewBybitTrader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - trader := NewBybitTrader(tt.apiKey, tt.secretKey) + bt := NewBybitTrader(tt.apiKey, tt.secretKey) if tt.wantNil { - assert.Nil(t, trader) + assert.Nil(t, bt) } else { - assert.NotNil(t, trader) - assert.NotNil(t, trader.client) + assert.NotNil(t, bt) + assert.NotNil(t, bt.client) } }) } @@ -176,7 +178,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) { // TestBybitTrader_FormatQuantity Test quantity formatting func TestBybitTrader_FormatQuantity(t *testing.T) { - trader := NewBybitTrader("test", "test") + bt := NewBybitTrader("test", "test") tests := []struct { name string @@ -210,7 +212,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := trader.FormatQuantity(tt.symbol, tt.quantity) + result, err := bt.FormatQuantity(tt.symbol, tt.quantity) if tt.hasError { assert.Error(t, err) } else { @@ -335,19 +337,19 @@ func convertBybitSide(side string) string { // TestBybitTrader_CategoryLinear Test using only linear category func TestBybitTrader_CategoryLinear(t *testing.T) { // Bybit trader should only use linear category (USDT perpetual contracts) - trader := NewBybitTrader("test", "test") - assert.NotNil(t, trader) + bt := NewBybitTrader("test", "test") + assert.NotNil(t, bt) // Verify default configuration - assert.NotNil(t, trader.client) + assert.NotNil(t, bt.client) } // TestBybitTrader_CacheDuration Test cache duration func TestBybitTrader_CacheDuration(t *testing.T) { - trader := NewBybitTrader("test", "test") + bt := NewBybitTrader("test", "test") // Verify default cache time is 15 seconds - assert.Equal(t, 15*time.Second, trader.cacheDuration) + assert.Equal(t, 15*time.Second, bt.cacheDuration) } // ============================================================ diff --git a/trader/gate/order_sync.go b/trader/gate/order_sync.go new file mode 100644 index 00000000..21a3bd4d --- /dev/null +++ b/trader/gate/order_sync.go @@ -0,0 +1,282 @@ +package gate + +import ( + "fmt" + "nofx/logger" + "nofx/market" + "nofx/store" + "sort" + "strconv" + "strings" + "time" + + "github.com/antihax/optional" + "github.com/gateio/gateapi-go/v6" +) + +// GateTrade represents a trade record from Gate fill history +type GateTrade struct { + Symbol string + TradeID string + OrderID string + Side string // buy or sell + FillPrice float64 + FillQty float64 // In base currency (e.g., ETH), not contracts + Fee float64 + FeeAsset string + ExecTime time.Time + ProfitLoss float64 + OrderType string + OrderAction string // open_long, open_short, close_long, close_short +} + +// GetTrades retrieves trade/fill records from Gate +func (t *GateTrader) GetTrades(startTime time.Time, limit int) ([]GateTrade, error) { + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 // Gate max limit + } + + opts := &gateapi.GetMyTradesOpts{ + Limit: optional.NewInt32(int32(limit)), + } + + // Get trades from Gate API + trades, _, err := t.client.FuturesApi.GetMyTrades(t.ctx, "usdt", opts) + if err != nil { + return nil, fmt.Errorf("failed to get trade history: %w", err) + } + + logger.Infof("📥 Received %d trades from Gate", len(trades)) + + result := make([]GateTrade, 0, len(trades)) + + for _, trade := range trades { + // Filter by start time + createTime := int64(trade.CreateTime) + if createTime < startTime.Unix() { + continue + } + + fillPrice, _ := strconv.ParseFloat(trade.Price, 64) + + // Get quanto_multiplier for this contract to convert size to base currency + quantoMultiplier := 1.0 + contract, err := t.getContract(trade.Contract) + if err == nil && contract != nil { + qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + if qm > 0 { + quantoMultiplier = qm + } + } + + // Convert contract size to actual quantity + absSize := trade.Size + if absSize < 0 { + absSize = -absSize + } + fillQty := float64(absSize) * quantoMultiplier + + // Determine side and order action based on size and close_size + // Gate close_size field determines if trade is opening or closing: + // close_size=0 && size>0: Open long + // close_size=0 && size<0: Open short + // close_size>0 && size>0: Close short (and possibly open long if size > close_size) + // close_size<0 && size<0: Close long (and possibly open short if |size| > |close_size|) + side := "BUY" + orderAction := "open_long" + + if trade.Size > 0 { + side = "BUY" + if trade.CloseSize > 0 { + // Closing short position + orderAction = "close_short" + } else { + // Opening long position + orderAction = "open_long" + } + } else if trade.Size < 0 { + side = "SELL" + if trade.CloseSize < 0 { + // Closing long position + orderAction = "close_long" + } else { + // Opening short position + orderAction = "open_short" + } + } + + // Calculate fee (Gate returns fee as negative value) + fee, _ := strconv.ParseFloat(trade.Fee, 64) + if fee < 0 { + fee = -fee + } + + // For closed positions, estimate PnL (Gate doesn't directly provide it in trade record) + pnl := 0.0 + if strings.Contains(orderAction, "close") { + // PnL would need to be calculated from position history + // For now, we leave it as 0 and let position builder handle it + } + + gateTrade := GateTrade{ + Symbol: trade.Contract, + TradeID: fmt.Sprintf("%d", trade.Id), + OrderID: trade.OrderId, + Side: side, + FillPrice: fillPrice, + FillQty: fillQty, + Fee: fee, + FeeAsset: "USDT", + ExecTime: time.Unix(createTime, 0).UTC(), + ProfitLoss: pnl, + OrderType: "MARKET", + OrderAction: orderAction, + } + + result = append(result, gateTrade) + } + + return result, nil +} + +// SyncOrdersFromGate syncs Gate exchange order history to local database +// Also creates/updates position records to ensure orders/fills/positions data consistency +// exchangeID: Exchange account UUID (from exchanges.id) +// exchangeType: Exchange type ("gate") +func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exchangeType string, st *store.Store) error { + if st == nil { + return fmt.Errorf("store is nil") + } + + // Get recent trades (last 24 hours) + startTime := time.Now().Add(-24 * time.Hour) + + logger.Infof("🔄 Syncing Gate trades from: %s", startTime.Format(time.RFC3339)) + + // Use GetTrades method to fetch trade records + trades, err := t.GetTrades(startTime, 100) + if err != nil { + return fmt.Errorf("failed to get trades: %w", err) + } + + logger.Infof("📥 Received %d trades from Gate", len(trades)) + + // Sort trades by time ASC (oldest first) for proper position building + sort.Slice(trades, func(i, j int) bool { + return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli() + }) + + // Process trades one by one (no transaction to avoid deadlock) + orderStore := st.Order() + positionStore := st.Position() + posBuilder := store.NewPositionBuilder(positionStore) + syncedCount := 0 + + for _, trade := range trades { + // Check if trade already exists (use exchangeID which is UUID, not exchange type) + existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID) + if err == nil && existing != nil { + continue // Order already exists, skip + } + + // Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT) + symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", "")) + + // Determine position side from order action + positionSide := "LONG" + if strings.Contains(trade.OrderAction, "short") { + positionSide = "SHORT" + } + + // Normalize side for storage + side := strings.ToUpper(trade.Side) + + // Create order record - use UTC time in milliseconds to avoid timezone issues + execTimeMs := trade.ExecTime.UTC().UnixMilli() + orderRecord := &store.TraderOrder{ + TraderID: traderID, + ExchangeID: exchangeID, // UUID + ExchangeType: exchangeType, // Exchange type + ExchangeOrderID: trade.TradeID, + Symbol: symbol, + Side: side, + PositionSide: "BOTH", // Gate uses one-way position mode + Type: trade.OrderType, + OrderAction: trade.OrderAction, + Quantity: trade.FillQty, + Price: trade.FillPrice, + Status: "FILLED", + FilledQuantity: trade.FillQty, + AvgFillPrice: trade.FillPrice, + Commission: trade.Fee, + FilledAt: execTimeMs, + CreatedAt: execTimeMs, + UpdatedAt: execTimeMs, + } + + // Insert order record + if err := orderStore.CreateOrder(orderRecord); err != nil { + logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err) + continue + } + + // Create fill record - use UTC time in milliseconds + fillRecord := &store.TraderFill{ + TraderID: traderID, + ExchangeID: exchangeID, // UUID + ExchangeType: exchangeType, // Exchange type + OrderID: orderRecord.ID, + ExchangeOrderID: trade.OrderID, + ExchangeTradeID: trade.TradeID, + Symbol: symbol, + Side: side, + Price: trade.FillPrice, + Quantity: trade.FillQty, + QuoteQuantity: trade.FillPrice * trade.FillQty, + Commission: trade.Fee, + CommissionAsset: trade.FeeAsset, + RealizedPnL: trade.ProfitLoss, + IsMaker: false, + CreatedAt: execTimeMs, + } + + if err := orderStore.CreateFill(fillRecord); err != nil { + logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err) + } + + // Create/update position record using PositionBuilder + if err := posBuilder.ProcessTrade( + traderID, exchangeID, exchangeType, + symbol, positionSide, trade.OrderAction, + trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, + execTimeMs, trade.TradeID, + ); err != nil { + logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) + } else { + logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty) + } + + syncedCount++ + logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", + trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction) + } + + logger.Infof("✅ Gate order sync completed: %d new trades synced", syncedCount) + return nil +} + +// StartOrderSync starts background order sync task for Gate +func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + for range ticker.C { + if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil { + logger.Infof("⚠️ Gate order sync failed: %v", err) + } + } + }() + logger.Infof("🔄 Gate order sync started (interval: %v)", interval) +} diff --git a/trader/gate/trader.go b/trader/gate/trader.go new file mode 100644 index 00000000..636ba6ec --- /dev/null +++ b/trader/gate/trader.go @@ -0,0 +1,897 @@ +package gate + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + "sync" + "time" + + "github.com/antihax/optional" + "github.com/gateio/gateapi-go/v6" + "nofx/logger" + "nofx/trader/types" +) + +// GateTrader implements types.Trader interface for Gate.io Futures +type GateTrader struct { + apiKey string + secretKey string + client *gateapi.APIClient + ctx context.Context + + // Cache fields + cachedBalance map[string]interface{} + balanceCacheTime time.Time + balanceCacheMutex sync.RWMutex + cachedPositions []map[string]interface{} + positionsCacheTime time.Time + positionsCacheMutex sync.RWMutex + contractsCache map[string]*gateapi.Contract + contractsCacheMutex sync.RWMutex + cacheDuration time.Duration +} + +// NewGateTrader creates a new Gate trader instance +func NewGateTrader(apiKey, secretKey string) *GateTrader { + config := gateapi.NewConfiguration() + client := gateapi.NewAPIClient(config) + + ctx := context.WithValue(context.Background(), + gateapi.ContextGateAPIV4, + gateapi.GateAPIV4{ + Key: apiKey, + Secret: secretKey, + }, + ) + + return &GateTrader{ + apiKey: apiKey, + secretKey: secretKey, + client: client, + ctx: ctx, + contractsCache: make(map[string]*gateapi.Contract), + cacheDuration: 15 * time.Second, + } +} + +// GetBalance retrieves account balance +func (t *GateTrader) GetBalance() (map[string]interface{}, error) { + // Check cache + t.balanceCacheMutex.RLock() + if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { + cached := t.cachedBalance + t.balanceCacheMutex.RUnlock() + return cached, nil + } + t.balanceCacheMutex.RUnlock() + + // Fetch from API + accounts, _, err := t.client.FuturesApi.ListFuturesAccounts(t.ctx, "usdt") + if err != nil { + return nil, fmt.Errorf("failed to get balance: %w", err) + } + + total, _ := strconv.ParseFloat(accounts.Total, 64) + available, _ := strconv.ParseFloat(accounts.Available, 64) + unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64) + + result := map[string]interface{}{ + "totalWalletBalance": total, + "availableBalance": available, + "totalUnrealizedProfit": unrealizedPnl, + } + + // Update cache + t.balanceCacheMutex.Lock() + t.cachedBalance = result + t.balanceCacheTime = time.Now() + t.balanceCacheMutex.Unlock() + + return result, nil +} + +// GetPositions retrieves all open positions +func (t *GateTrader) GetPositions() ([]map[string]interface{}, error) { + // Check cache + t.positionsCacheMutex.RLock() + if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration { + cached := t.cachedPositions + t.positionsCacheMutex.RUnlock() + return cached, nil + } + t.positionsCacheMutex.RUnlock() + + // Fetch from API + positions, _, err := t.client.FuturesApi.ListPositions(t.ctx, "usdt", nil) + if err != nil { + return nil, fmt.Errorf("failed to get positions: %w", err) + } + + var result []map[string]interface{} + for _, pos := range positions { + if pos.Size == 0 { + continue // Skip empty positions + } + + entryPrice, _ := strconv.ParseFloat(pos.EntryPrice, 64) + markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64) + liqPrice, _ := strconv.ParseFloat(pos.LiqPrice, 64) + unrealizedPnl, _ := strconv.ParseFloat(pos.UnrealisedPnl, 64) + leverage, _ := strconv.ParseFloat(pos.Leverage, 64) + + // Gate returns position size in contracts, need to convert to base currency + // Each contract = quanto_multiplier base currency + contractSize := float64(pos.Size) + if pos.Size < 0 { + contractSize = float64(-pos.Size) + } + + // Get quanto_multiplier from contract info to convert contracts to actual quantity + quantoMultiplier := 1.0 + contract, err := t.getContract(pos.Contract) + if err == nil && contract != nil { + qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + if qm > 0 { + quantoMultiplier = qm + } + } + + // Convert contract count to actual token quantity + positionAmt := contractSize * quantoMultiplier + + // Determine side based on position size + side := "long" + if pos.Size < 0 { + side = "short" + } + + result = append(result, map[string]interface{}{ + "symbol": pos.Contract, + "positionAmt": positionAmt, + "entryPrice": entryPrice, + "markPrice": markPrice, + "unRealizedProfit": unrealizedPnl, + "leverage": int(leverage), + "liquidationPrice": liqPrice, + "side": side, + }) + } + + // Update cache + t.positionsCacheMutex.Lock() + t.cachedPositions = result + t.positionsCacheTime = time.Now() + t.positionsCacheMutex.Unlock() + + return result, nil +} + +// convertSymbol converts symbol format (e.g., BTCUSDT -> BTC_USDT) +func (t *GateTrader) convertSymbol(symbol string) string { + // If already in correct format + if strings.Contains(symbol, "_") { + return symbol + } + // Convert BTCUSDT to BTC_USDT + if strings.HasSuffix(symbol, "USDT") { + base := strings.TrimSuffix(symbol, "USDT") + return base + "_USDT" + } + return symbol +} + +// revertSymbol converts symbol back to standard format (e.g., BTC_USDT -> BTCUSDT) +func (t *GateTrader) revertSymbol(symbol string) string { + return strings.ReplaceAll(symbol, "_", "") +} + +// getContract fetches contract info with caching +func (t *GateTrader) getContract(symbol string) (*gateapi.Contract, error) { + symbol = t.convertSymbol(symbol) + + // Check cache + t.contractsCacheMutex.RLock() + if contract, ok := t.contractsCache[symbol]; ok { + t.contractsCacheMutex.RUnlock() + return contract, nil + } + t.contractsCacheMutex.RUnlock() + + // Fetch from API + contract, _, err := t.client.FuturesApi.GetFuturesContract(t.ctx, "usdt", symbol) + if err != nil { + return nil, fmt.Errorf("failed to get contract info: %w", err) + } + + // Update cache + t.contractsCacheMutex.Lock() + t.contractsCache[symbol] = &contract + t.contractsCacheMutex.Unlock() + + return &contract, nil +} + +// SetLeverage sets the leverage for a symbol +func (t *GateTrader) SetLeverage(symbol string, leverage int) error { + symbol = t.convertSymbol(symbol) + + _, _, err := t.client.FuturesApi.UpdatePositionLeverage(t.ctx, "usdt", symbol, fmt.Sprintf("%d", leverage), nil) + if err != nil { + // Gate.io may return error if leverage is already set + if strings.Contains(err.Error(), "RISK_LIMIT_EXCEEDED") { + logger.Warnf(" [Gate] Leverage %d exceeds limit for %s", leverage, symbol) + return nil + } + return fmt.Errorf("failed to set leverage: %w", err) + } + + logger.Infof(" [Gate] Leverage set to %dx for %s", leverage, symbol) + return nil +} + +// SetMarginMode sets margin mode (cross or isolated) +func (t *GateTrader) SetMarginMode(symbol string, isCrossMargin bool) error { + // Gate.io uses leverage=0 for cross margin, positive number for isolated + // This is handled through UpdatePositionLeverage with cross_leverage_limit + // For now, we'll skip explicit margin mode setting as it's tied to leverage + logger.Infof(" [Gate] Margin mode is set through leverage (0=cross)") + return nil +} + +// OpenLong opens a long position +func (t *GateTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + symbol = t.convertSymbol(symbol) + + // Cancel old orders first + t.CancelAllOrders(symbol) + + // Set leverage + if err := t.SetLeverage(symbol, leverage); err != nil { + logger.Warnf(" [Gate] Failed to set leverage: %v", err) + } + + // Get contract info for size calculation + contract, err := t.getContract(symbol) + if err != nil { + return nil, err + } + + // Gate uses contract size units (each contract = quanto_multiplier base currency) + // size = quantity / quanto_multiplier + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + size := int64(quantity / quantoMultiplier) + if size <= 0 { + size = 1 + } + + order := gateapi.FuturesOrder{ + Contract: symbol, + Size: size, // Positive for long + Price: "0", // Market order + Tif: "ioc", + Text: "t-nofx", + } + + logger.Infof(" [Gate] OpenLong: symbol=%s, size=%d, leverage=%d", symbol, size, leverage) + + result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil) + if err != nil { + return nil, fmt.Errorf("failed to open long position: %w", err) + } + + // Clear cache + t.clearCache() + + // Parse fill price from result + fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64) + + logger.Infof(" [Gate] Opened long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice) + + return map[string]interface{}{ + "orderId": fmt.Sprintf("%d", result.Id), + "symbol": t.revertSymbol(symbol), + "status": "FILLED", + "fillPrice": fillPrice, + "avgPrice": fillPrice, + }, nil +} + +// OpenShort opens a short position +func (t *GateTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + symbol = t.convertSymbol(symbol) + + // Cancel old orders first + t.CancelAllOrders(symbol) + + // Set leverage + if err := t.SetLeverage(symbol, leverage); err != nil { + logger.Warnf(" [Gate] Failed to set leverage: %v", err) + } + + // Get contract info for size calculation + contract, err := t.getContract(symbol) + if err != nil { + return nil, err + } + + // Gate uses contract size units + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + size := int64(quantity / quantoMultiplier) + if size <= 0 { + size = 1 + } + + order := gateapi.FuturesOrder{ + Contract: symbol, + Size: -size, // Negative for short + Price: "0", // Market order + Tif: "ioc", + Text: "t-nofx", + } + + logger.Infof(" [Gate] OpenShort: symbol=%s, size=%d, leverage=%d", symbol, -size, leverage) + + result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil) + if err != nil { + return nil, fmt.Errorf("failed to open short position: %w", err) + } + + // Clear cache + t.clearCache() + + // Parse fill price from result + fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64) + + logger.Infof(" [Gate] Opened short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice) + + return map[string]interface{}{ + "orderId": fmt.Sprintf("%d", result.Id), + "symbol": t.revertSymbol(symbol), + "status": "FILLED", + "fillPrice": fillPrice, + "avgPrice": fillPrice, + }, nil +} + +// CloseLong closes a long position +func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { + symbol = t.convertSymbol(symbol) + + // If quantity is 0, get current position + if quantity == 0 { + positions, err := t.GetPositions() + if err != nil { + return nil, err + } + for _, pos := range positions { + posSymbol := t.convertSymbol(pos["symbol"].(string)) + if posSymbol == symbol && pos["side"] == "long" { + quantity = pos["positionAmt"].(float64) + break + } + } + if quantity == 0 { + return nil, fmt.Errorf("long position not found for %s", symbol) + } + } + + // Get contract info for size calculation + contract, err := t.getContract(symbol) + if err != nil { + return nil, err + } + + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + size := int64(quantity / quantoMultiplier) + if size <= 0 { + size = 1 + } + + // Close long = sell (use ReduceOnly, not Close which requires Size=0) + order := gateapi.FuturesOrder{ + Contract: symbol, + Size: -size, // Negative to close long + Price: "0", + Tif: "ioc", + ReduceOnly: true, + Text: "t-nofx-close", + } + + logger.Infof(" [Gate] CloseLong: symbol=%s, size=%d", symbol, -size) + + result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil) + if err != nil { + return nil, fmt.Errorf("failed to close long position: %w", err) + } + + // Clear cache + t.clearCache() + + // Parse fill price from result + fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64) + + logger.Infof(" [Gate] Closed long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice) + + return map[string]interface{}{ + "orderId": fmt.Sprintf("%d", result.Id), + "symbol": t.revertSymbol(symbol), + "status": "FILLED", + "fillPrice": fillPrice, + "avgPrice": fillPrice, + }, nil +} + +// CloseShort closes a short position +func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { + symbol = t.convertSymbol(symbol) + + // If quantity is 0, get current position + if quantity == 0 { + positions, err := t.GetPositions() + if err != nil { + return nil, err + } + for _, pos := range positions { + posSymbol := t.convertSymbol(pos["symbol"].(string)) + if posSymbol == symbol && pos["side"] == "short" { + quantity = pos["positionAmt"].(float64) + break + } + } + if quantity == 0 { + return nil, fmt.Errorf("short position not found for %s", symbol) + } + } + + // Ensure quantity is positive + if quantity < 0 { + quantity = -quantity + } + + // Get contract info for size calculation + contract, err := t.getContract(symbol) + if err != nil { + return nil, err + } + + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + size := int64(quantity / quantoMultiplier) + if size <= 0 { + size = 1 + } + + // Close short = buy (use ReduceOnly, not Close which requires Size=0) + order := gateapi.FuturesOrder{ + Contract: symbol, + Size: size, // Positive to close short + Price: "0", + Tif: "ioc", + ReduceOnly: true, + Text: "t-nofx-close", + } + + logger.Infof(" [Gate] CloseShort: symbol=%s, size=%d", symbol, size) + + result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil) + if err != nil { + return nil, fmt.Errorf("failed to close short position: %w", err) + } + + // Clear cache + t.clearCache() + + // Parse fill price from result + fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64) + + logger.Infof(" [Gate] Closed short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice) + + return map[string]interface{}{ + "orderId": fmt.Sprintf("%d", result.Id), + "symbol": t.revertSymbol(symbol), + "status": "FILLED", + "fillPrice": fillPrice, + "avgPrice": fillPrice, + }, nil +} + +// GetMarketPrice gets the current market price +func (t *GateTrader) GetMarketPrice(symbol string) (float64, error) { + symbol = t.convertSymbol(symbol) + + opts := &gateapi.ListFuturesTickersOpts{ + Contract: optional.NewString(symbol), + } + + tickers, _, err := t.client.FuturesApi.ListFuturesTickers(t.ctx, "usdt", opts) + if err != nil { + return 0, fmt.Errorf("failed to get market price: %w", err) + } + + if len(tickers) == 0 { + return 0, fmt.Errorf("no ticker data for %s", symbol) + } + + price, _ := strconv.ParseFloat(tickers[0].Last, 64) + return price, nil +} + +// SetStopLoss sets a stop loss order +func (t *GateTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { + symbol = t.convertSymbol(symbol) + + contract, err := t.getContract(symbol) + if err != nil { + return err + } + + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + size := int64(quantity / quantoMultiplier) + if size <= 0 { + size = 1 + } + + // For long position, stop loss means sell when price drops + // For short position, stop loss means buy when price rises + if strings.ToUpper(positionSide) == "LONG" { + size = -size + } + + // Use price trigger order + trigger := gateapi.FuturesPriceTriggeredOrder{ + Initial: gateapi.FuturesInitialOrder{ + Contract: symbol, + Size: size, + Price: "0", // Market order + Tif: "ioc", + ReduceOnly: true, + Close: true, + }, + Trigger: gateapi.FuturesPriceTrigger{ + StrategyType: 0, // Close position + PriceType: 0, // Latest price + Price: fmt.Sprintf("%.8f", stopPrice), + Rule: 1, // Price <= trigger price + }, + } + + if strings.ToUpper(positionSide) == "SHORT" { + trigger.Trigger.Rule = 2 // Price >= trigger price for short stop loss + } + + _, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger) + if err != nil { + return fmt.Errorf("failed to set stop loss: %w", err) + } + + logger.Infof(" [Gate] Stop loss set: %s @ %.4f", symbol, stopPrice) + return nil +} + +// SetTakeProfit sets a take profit order +func (t *GateTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { + symbol = t.convertSymbol(symbol) + + contract, err := t.getContract(symbol) + if err != nil { + return err + } + + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + size := int64(quantity / quantoMultiplier) + if size <= 0 { + size = 1 + } + + // For long position, take profit means sell when price rises + // For short position, take profit means buy when price drops + if strings.ToUpper(positionSide) == "LONG" { + size = -size + } + + trigger := gateapi.FuturesPriceTriggeredOrder{ + Initial: gateapi.FuturesInitialOrder{ + Contract: symbol, + Size: size, + Price: "0", // Market order + Tif: "ioc", + ReduceOnly: true, + Close: true, + }, + Trigger: gateapi.FuturesPriceTrigger{ + StrategyType: 0, // Close position + PriceType: 0, // Latest price + Price: fmt.Sprintf("%.8f", takeProfitPrice), + Rule: 2, // Price >= trigger price for long take profit + }, + } + + if strings.ToUpper(positionSide) == "SHORT" { + trigger.Trigger.Rule = 1 // Price <= trigger price for short take profit + } + + _, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger) + if err != nil { + return fmt.Errorf("failed to set take profit: %w", err) + } + + logger.Infof(" [Gate] Take profit set: %s @ %.4f", symbol, takeProfitPrice) + return nil +} + +// CancelStopLossOrders cancels stop loss orders +func (t *GateTrader) CancelStopLossOrders(symbol string) error { + return t.cancelTriggerOrders(symbol, "stop_loss") +} + +// CancelTakeProfitOrders cancels take profit orders +func (t *GateTrader) CancelTakeProfitOrders(symbol string) error { + return t.cancelTriggerOrders(symbol, "take_profit") +} + +// cancelTriggerOrders cancels trigger orders of a specific type +func (t *GateTrader) cancelTriggerOrders(symbol string, orderType string) error { + symbol = t.convertSymbol(symbol) + + opts := &gateapi.ListPriceTriggeredOrdersOpts{ + Contract: optional.NewString(symbol), + } + + orders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", opts) + if err != nil { + return err + } + + for _, order := range orders { + // Determine if it's stop loss or take profit based on trigger rule and position + // For simplicity, cancel all matching symbol orders + _, _, err := t.client.FuturesApi.CancelPriceTriggeredOrder(t.ctx, "usdt", fmt.Sprintf("%d", order.Id)) + if err != nil { + logger.Warnf(" [Gate] Failed to cancel trigger order %d: %v", order.Id, err) + } + } + + return nil +} + +// CancelAllOrders cancels all pending orders for a symbol +func (t *GateTrader) CancelAllOrders(symbol string) error { + symbol = t.convertSymbol(symbol) + + // Cancel regular orders + _, _, err := t.client.FuturesApi.CancelFuturesOrders(t.ctx, "usdt", symbol, nil) + if err != nil { + // Ignore if no orders to cancel + if !strings.Contains(err.Error(), "ORDER_NOT_FOUND") { + logger.Warnf(" [Gate] Error canceling orders: %v", err) + } + } + + // Cancel trigger orders + t.cancelTriggerOrders(symbol, "") + + return nil +} + +// CancelStopOrders cancels all stop orders (stop loss and take profit) +func (t *GateTrader) CancelStopOrders(symbol string) error { + t.CancelStopLossOrders(symbol) + t.CancelTakeProfitOrders(symbol) + return nil +} + +// FormatQuantity formats quantity to correct precision +func (t *GateTrader) FormatQuantity(symbol string, quantity float64) (string, error) { + contract, err := t.getContract(symbol) + if err != nil { + return fmt.Sprintf("%.4f", quantity), nil + } + + // Gate uses quanto_multiplier for contract size + quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + if quantoMultiplier > 0 { + // Calculate number of contracts + numContracts := quantity / quantoMultiplier + return fmt.Sprintf("%.0f", math.Floor(numContracts)), nil + } + + return fmt.Sprintf("%.4f", quantity), nil +} + +// GetOrderStatus gets the status of an order +func (t *GateTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + symbol = t.convertSymbol(symbol) + + order, _, err := t.client.FuturesApi.GetFuturesOrder(t.ctx, "usdt", orderID) + if err != nil { + return nil, fmt.Errorf("failed to get order status: %w", err) + } + + fillPrice, _ := strconv.ParseFloat(order.FillPrice, 64) + tkFee, _ := strconv.ParseFloat(order.Tkfr, 64) + mkFee, _ := strconv.ParseFloat(order.Mkfr, 64) + totalFee := tkFee + mkFee + + // Get quanto_multiplier to convert contracts to actual quantity + quantoMultiplier := 1.0 + contract, contractErr := t.getContract(symbol) + if contractErr == nil && contract != nil { + qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + if qm > 0 { + quantoMultiplier = qm + } + } + + // Map status + status := "NEW" + switch order.Status { + case "finished": + if order.FinishAs == "filled" { + status = "FILLED" + } else if order.FinishAs == "cancelled" { + status = "CANCELED" + } else { + status = "CLOSED" + } + case "open": + status = "NEW" + } + + side := "BUY" + if order.Size < 0 { + side = "SELL" + } + + // Convert contract count to actual token quantity + executedQty := math.Abs(float64(order.Size-order.Left)) * quantoMultiplier + + return map[string]interface{}{ + "orderId": orderID, + "symbol": t.revertSymbol(symbol), + "status": status, + "avgPrice": fillPrice, + "executedQty": executedQty, + "side": side, + "type": order.Tif, + "time": int64(order.CreateTime * 1000), + "updateTime": int64(order.FinishTime * 1000), + "commission": totalFee, + }, nil +} + +// GetClosedPnL retrieves closed position PnL records +func (t *GateTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 + } + + opts := &gateapi.ListPositionCloseOpts{ + Limit: optional.NewInt32(int32(limit)), + From: optional.NewInt64(startTime.Unix()), + } + + closedPositions, _, err := t.client.FuturesApi.ListPositionClose(t.ctx, "usdt", opts) + if err != nil { + return nil, fmt.Errorf("failed to get closed positions: %w", err) + } + + records := make([]types.ClosedPnLRecord, 0, len(closedPositions)) + for _, pos := range closedPositions { + pnl, _ := strconv.ParseFloat(pos.Pnl, 64) + + record := types.ClosedPnLRecord{ + Symbol: t.revertSymbol(pos.Contract), + Side: pos.Side, + RealizedPnL: pnl, + ExitTime: time.Unix(int64(pos.Time), 0).UTC(), + CloseType: "unknown", + } + + records = append(records, record) + } + + return records, nil +} + +// GetOpenOrders gets open/pending orders +func (t *GateTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { + symbol = t.convertSymbol(symbol) + + opts := &gateapi.ListFuturesOrdersOpts{ + Contract: optional.NewString(symbol), + } + + orders, _, err := t.client.FuturesApi.ListFuturesOrders(t.ctx, "usdt", "open", opts) + if err != nil { + return nil, fmt.Errorf("failed to get open orders: %w", err) + } + + // Get quanto_multiplier to convert contracts to actual quantity + quantoMultiplier := 1.0 + contract, err := t.getContract(symbol) + if err == nil && contract != nil { + qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64) + if qm > 0 { + quantoMultiplier = qm + } + } + + var result []types.OpenOrder + for _, order := range orders { + price, _ := strconv.ParseFloat(order.Price, 64) + + side := "BUY" + if order.Size < 0 { + side = "SELL" + } + + // Convert contract count to actual token quantity + quantity := math.Abs(float64(order.Size)) * quantoMultiplier + + result = append(result, types.OpenOrder{ + OrderID: fmt.Sprintf("%d", order.Id), + Symbol: t.revertSymbol(order.Contract), + Side: side, + Type: "LIMIT", + Price: price, + Quantity: quantity, + Status: "NEW", + }) + } + + // Also get trigger orders + triggerOpts := &gateapi.ListPriceTriggeredOrdersOpts{ + Contract: optional.NewString(symbol), + } + + triggerOrders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", triggerOpts) + if err == nil { + for _, order := range triggerOrders { + triggerPrice, _ := strconv.ParseFloat(order.Trigger.Price, 64) + + side := "BUY" + if order.Initial.Size < 0 { + side = "SELL" + } + + orderType := "STOP_MARKET" + if order.Trigger.Rule == 2 { + orderType = "TAKE_PROFIT_MARKET" + } + + // Convert contract count to actual token quantity + quantity := math.Abs(float64(order.Initial.Size)) * quantoMultiplier + + result = append(result, types.OpenOrder{ + OrderID: fmt.Sprintf("%d", order.Id), + Symbol: t.revertSymbol(order.Initial.Contract), + Side: side, + Type: orderType, + StopPrice: triggerPrice, + Quantity: quantity, + Status: "NEW", + }) + } + } + + return result, nil +} + +// clearCache clears all caches +func (t *GateTrader) clearCache() { + t.balanceCacheMutex.Lock() + t.cachedBalance = nil + t.balanceCacheMutex.Unlock() + + t.positionsCacheMutex.Lock() + t.cachedPositions = nil + t.positionsCacheMutex.Unlock() +} + +// Ensure GateTrader implements Trader interface +var _ types.Trader = (*GateTrader)(nil) diff --git a/trader/gate/trader_test.go b/trader/gate/trader_test.go new file mode 100644 index 00000000..33a15467 --- /dev/null +++ b/trader/gate/trader_test.go @@ -0,0 +1,337 @@ +package gate + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "nofx/trader/testutil" + "nofx/trader/types" +) + +// ============================================================ +// Part 1: GateTraderTestSuite - Inherits base test suite +// ============================================================ + +// GateTraderTestSuite Gate trader test suite +// Inherits TraderTestSuite and adds Gate-specific mock logic +type GateTraderTestSuite struct { + *testutil.TraderTestSuite + mockServer *httptest.Server +} + +// NewGateTraderTestSuite creates Gate test suite with mock server +func NewGateTraderTestSuite(t *testing.T) *GateTraderTestSuite { + // Create mock HTTP server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + var respBody interface{} + + switch { + // Mock GetBalance - /api/v4/futures/usdt/accounts + case strings.Contains(path, "/futures/usdt/accounts"): + respBody = map[string]interface{}{ + "total": "10000.00", + "unrealised_pnl": "100.50", + "available": "8000.00", + "currency": "USDT", + } + + // Mock GetPositions - /api/v4/futures/usdt/positions + case strings.Contains(path, "/futures/usdt/positions"): + respBody = []map[string]interface{}{ + { + "contract": "BTC_USDT", + "size": 500, + "entry_price": "50000.00", + "mark_price": "50500.00", + "unrealised_pnl": "250.00", + "liq_price": "45000.00", + "leverage": "10", + }, + } + + // Mock GetContract - /api/v4/futures/usdt/contracts/{contract} + case strings.Contains(path, "/futures/usdt/contracts/"): + respBody = map[string]interface{}{ + "name": "BTC_USDT", + "quanto_multiplier": "0.001", + "order_price_round": "0.1", + } + + // Mock ListFuturesContracts - /api/v4/futures/usdt/contracts + case strings.Contains(path, "/futures/usdt/contracts"): + respBody = []map[string]interface{}{ + { + "name": "BTC_USDT", + "quanto_multiplier": "0.001", + "order_price_round": "0.1", + }, + { + "name": "ETH_USDT", + "quanto_multiplier": "0.01", + "order_price_round": "0.01", + }, + } + + // Mock ListFuturesTickers - /api/v4/futures/usdt/tickers + case strings.Contains(path, "/futures/usdt/tickers"): + contract := r.URL.Query().Get("contract") + if contract == "" { + contract = "BTC_USDT" + } + price := "50000.00" + if contract == "ETH_USDT" { + price = "3000.00" + } + respBody = []map[string]interface{}{ + { + "contract": contract, + "last": price, + }, + } + + // Mock CreateFuturesOrder - /api/v4/futures/usdt/orders (POST) + case strings.Contains(path, "/futures/usdt/orders") && r.Method == "POST": + respBody = map[string]interface{}{ + "id": 123456, + "contract": "BTC_USDT", + "size": 100, + "status": "finished", + "finish_as": "filled", + "fill_price": "50000.00", + } + + // Mock ListFuturesOrders - /api/v4/futures/usdt/orders + case strings.Contains(path, "/futures/usdt/orders"): + respBody = []map[string]interface{}{} + + // Mock GetFuturesOrder - /api/v4/futures/usdt/orders/{order_id} + case strings.Contains(path, "/futures/usdt/orders/"): + respBody = map[string]interface{}{ + "id": 123456, + "contract": "BTC_USDT", + "size": 100, + "status": "finished", + "finish_as": "filled", + "fill_price": "50000.00", + "create_time": 1234567890.0, + "update_time": 1234567890.0, + "tkfr": "0.0005", + "mkfr": "0.0002", + } + + // Mock UpdatePositionLeverage + case strings.Contains(path, "/futures/usdt/positions/") && strings.Contains(path, "/leverage"): + respBody = map[string]interface{}{ + "leverage": 10, + } + + // Mock ListPriceTriggeredOrders + case strings.Contains(path, "/futures/usdt/price_orders"): + respBody = []map[string]interface{}{} + + // Mock ListPositionClose + case strings.Contains(path, "/futures/usdt/position_close"): + respBody = []map[string]interface{}{} + + // Default: empty response + default: + respBody = map[string]interface{}{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(respBody) + })) + + // Create trader instance (will need to override URL in actual usage) + traderInstance := NewGateTrader("test_api_key", "test_secret_key") + + // Create base suite + baseSuite := testutil.NewTraderTestSuite(t, traderInstance) + + return &GateTraderTestSuite{ + TraderTestSuite: baseSuite, + mockServer: mockServer, + } +} + +// Cleanup cleans up resources +func (s *GateTraderTestSuite) Cleanup() { + if s.mockServer != nil { + s.mockServer.Close() + } + s.TraderTestSuite.Cleanup() +} + +// ============================================================ +// Part 2: Interface compliance tests +// ============================================================ + +// TestGateTrader_InterfaceCompliance tests interface compliance +func TestGateTrader_InterfaceCompliance(t *testing.T) { + var _ types.Trader = (*GateTrader)(nil) +} + +// ============================================================ +// Part 3: Gate-specific feature unit tests +// ============================================================ + +// TestNewGateTrader tests creating Gate trader +func TestNewGateTrader(t *testing.T) { + tests := []struct { + name string + apiKey string + secretKey string + wantNil bool + }{ + { + name: "Successfully create", + apiKey: "test_api_key", + secretKey: "test_secret_key", + wantNil: false, + }, + { + name: "Empty API Key can still create", + apiKey: "", + secretKey: "test_secret_key", + wantNil: false, + }, + { + name: "Empty Secret Key can still create", + apiKey: "test_api_key", + secretKey: "", + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gt := NewGateTrader(tt.apiKey, tt.secretKey) + + if tt.wantNil { + assert.Nil(t, gt) + } else { + assert.NotNil(t, gt) + assert.NotNil(t, gt.client) + assert.Equal(t, tt.apiKey, gt.apiKey) + assert.Equal(t, tt.secretKey, gt.secretKey) + } + }) + } +} + +// TestGateTrader_SymbolConversion tests symbol format conversion +func TestGateTrader_SymbolConversion(t *testing.T) { + gt := NewGateTrader("test", "test") + + tests := []struct { + name string + input string + expected string + }{ + { + name: "BTCUSDT to BTC_USDT", + input: "BTCUSDT", + expected: "BTC_USDT", + }, + { + name: "ETHUSDT to ETH_USDT", + input: "ETHUSDT", + expected: "ETH_USDT", + }, + { + name: "Already converted format", + input: "BTC_USDT", + expected: "BTC_USDT", + }, + { + name: "SOL symbol", + input: "SOLUSDT", + expected: "SOL_USDT", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := gt.convertSymbol(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestGateTrader_RevertSymbol tests symbol reversion +func TestGateTrader_RevertSymbol(t *testing.T) { + gt := NewGateTrader("test", "test") + + tests := []struct { + name string + input string + expected string + }{ + { + name: "BTC_USDT to BTCUSDT", + input: "BTC_USDT", + expected: "BTCUSDT", + }, + { + name: "ETH_USDT to ETHUSDT", + input: "ETH_USDT", + expected: "ETHUSDT", + }, + { + name: "Already standard format", + input: "BTCUSDT", + expected: "BTCUSDT", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := gt.revertSymbol(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestGateTrader_CacheDuration tests cache duration +func TestGateTrader_CacheDuration(t *testing.T) { + gt := NewGateTrader("test", "test") + + // Verify default cache time is 15 seconds + assert.Equal(t, 15*time.Second, gt.cacheDuration) +} + +// TestGateTrader_ClearCache tests cache clearing +func TestGateTrader_ClearCache(t *testing.T) { + gt := NewGateTrader("test", "test") + + // Set some cached data + gt.cachedBalance = map[string]interface{}{"test": "data"} + gt.cachedPositions = []map[string]interface{}{{"test": "data"}} + + // Clear cache + gt.clearCache() + + // Verify cache is cleared + assert.Nil(t, gt.cachedBalance) + assert.Nil(t, gt.cachedPositions) +} + +// ============================================================ +// Part 4: Mock server integration tests +// ============================================================ + +// TestGateTrader_MockServerResponseFormat tests mock server response format +func TestGateTrader_MockServerResponseFormat(t *testing.T) { + suite := NewGateTraderTestSuite(t) + defer suite.Cleanup() + + // Verify mock server is running + assert.NotNil(t, suite.mockServer) + assert.NotEmpty(t, suite.mockServer.URL) +} diff --git a/trader/balance_test.go b/trader/hyperliquid/balance_test.go similarity index 99% rename from trader/balance_test.go rename to trader/hyperliquid/balance_test.go index 45056e72..491a8fd6 100644 --- a/trader/balance_test.go +++ b/trader/hyperliquid/balance_test.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "os" diff --git a/trader/hyperliquid_order_sync.go b/trader/hyperliquid/order_sync.go similarity index 99% rename from trader/hyperliquid_order_sync.go rename to trader/hyperliquid/order_sync.go index bd1dd996..752e5c3d 100644 --- a/trader/hyperliquid_order_sync.go +++ b/trader/hyperliquid/order_sync.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "fmt" diff --git a/trader/hyperliquid_sync_test.go b/trader/hyperliquid/sync_test.go similarity index 99% rename from trader/hyperliquid_sync_test.go rename to trader/hyperliquid/sync_test.go index 4eb4bd81..bc85ba7a 100644 --- a/trader/hyperliquid_sync_test.go +++ b/trader/hyperliquid/sync_test.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "math" diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid/trader.go similarity index 99% rename from trader/hyperliquid_trader.go rename to trader/hyperliquid/trader.go index aa89f39e..85496925 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid/trader.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "bytes" @@ -16,6 +16,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/sonirico/go-hyperliquid" + "nofx/trader/types" ) // HyperliquidTrader Hyperliquid trader @@ -249,7 +250,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { // AccountValue = Total account equity (includes idle funds + position value + unrealized PnL) // TotalMarginUsed = Margin used by positions (included in AccountValue, for display only) // - // To be compatible with auto_trader.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit) + // To be compatible with auto_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit) // Need to return "wallet balance without unrealized PnL" walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl @@ -1950,14 +1951,14 @@ func absFloat(x float64) float64 { // GetClosedPnL gets recent closing trades from Hyperliquid // Note: Hyperliquid does NOT have a position history API, only fill history. // This returns individual closing trades for real-time position closure detection. -func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { trades, err := t.GetTrades(startTime, limit) if err != nil { return nil, err } // Filter only closing trades (realizedPnl != 0) - var records []ClosedPnLRecord + var records []types.ClosedPnLRecord for _, trade := range trades { if trade.RealizedPnL == 0 { continue @@ -1981,7 +1982,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos } } - records = append(records, ClosedPnLRecord{ + records = append(records, types.ClosedPnLRecord{ Symbol: trade.Symbol, Side: side, EntryPrice: entryPrice, @@ -2001,7 +2002,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos } // GetTrades retrieves trade history from Hyperliquid -func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { +func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) { // Use UserFillsByTime API startTimeMs := startTime.UnixMilli() fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil) @@ -2009,7 +2010,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe return nil, fmt.Errorf("failed to get user fills: %w", err) } - var trades []TradeRecord + var trades []types.TradeRecord for _, fill := range fills { price, _ := strconv.ParseFloat(fill.Price, 64) qty, _ := strconv.ParseFloat(fill.Size, 64) @@ -2054,7 +2055,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe } // Hyperliquid uses one-way mode, so PositionSide is "BOTH" - trade := TradeRecord{ + trade := types.TradeRecord{ TradeID: strconv.FormatInt(fill.Tid, 10), Symbol: fill.Coin, Side: side, @@ -2082,13 +2083,13 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe var defaultBuilder *hyperliquid.BuilderInfo = nil // GetOpenOrders gets all open/pending orders for a symbol -func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { +func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) if err != nil { return nil, fmt.Errorf("failed to get open orders: %w", err) } - var result []OpenOrder + var result []types.OpenOrder for _, order := range openOrders { if order.Coin != symbol { continue @@ -2099,7 +2100,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { side = "SELL" } - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: fmt.Sprintf("%d", order.Oid), Symbol: order.Coin, Side: side, @@ -2117,7 +2118,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { // PlaceLimitOrder places a limit order for grid trading // Implements GridTrader interface -func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { coin := convertSymbolToHyperliquid(req.Symbol) // Set leverage if specified and not xyz dex @@ -2165,7 +2166,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrder logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f", coin, req.Side, roundedPrice) - return &LimitOrderResult{ + return &types.LimitOrderResult{ OrderID: orderID, ClientID: req.ClientID, Symbol: req.Symbol, diff --git a/trader/hyperliquid_trader_race_test.go b/trader/hyperliquid/trader_race_test.go similarity index 85% rename from trader/hyperliquid_trader_race_test.go rename to trader/hyperliquid/trader_race_test.go index 2853637a..1144c236 100644 --- a/trader/hyperliquid_trader_race_test.go +++ b/trader/hyperliquid/trader_race_test.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "context" @@ -11,7 +11,7 @@ import ( // TestMetaConcurrentAccess tests that concurrent access to meta field is safe func TestMetaConcurrentAccess(t *testing.T) { // Create a HyperliquidTrader instance with meta initialized - trader := &HyperliquidTrader{ + ht := &HyperliquidTrader{ ctx: context.Background(), meta: &hyperliquid.Meta{ Universe: []hyperliquid.AssetInfo{ @@ -32,7 +32,7 @@ func TestMetaConcurrentAccess(t *testing.T) { go func() { defer wg.Done() // This should not cause race conditions - decimals := trader.getSzDecimals("BTC") + decimals := ht.getSzDecimals("BTC") if decimals != 5 { t.Errorf("Expected decimals 5, got %d", decimals) } @@ -44,7 +44,7 @@ func TestMetaConcurrentAccess(t *testing.T) { // TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field func TestMetaConcurrentReadWrite(t *testing.T) { - trader := &HyperliquidTrader{ + ht := &HyperliquidTrader{ ctx: context.Background(), meta: &hyperliquid.Meta{ Universe: []hyperliquid.AssetInfo{ @@ -62,7 +62,7 @@ func TestMetaConcurrentReadWrite(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - trader.getSzDecimals("BTC") + ht.getSzDecimals("BTC") }() } @@ -72,36 +72,36 @@ func TestMetaConcurrentReadWrite(t *testing.T) { go func(iteration int) { defer wg.Done() // Simulate meta update - trader.metaMutex.Lock() - trader.meta = &hyperliquid.Meta{ + ht.metaMutex.Lock() + ht.meta = &hyperliquid.Meta{ Universe: []hyperliquid.AssetInfo{ {Name: "BTC", SzDecimals: 5 + iteration%3}, {Name: "ETH", SzDecimals: 4}, }, } - trader.metaMutex.Unlock() + ht.metaMutex.Unlock() }(i) } wg.Wait() // Verify meta is not nil after all operations - trader.metaMutex.RLock() - if trader.meta == nil { + ht.metaMutex.RLock() + if ht.meta == nil { t.Error("Meta should not be nil after concurrent operations") } - trader.metaMutex.RUnlock() + ht.metaMutex.RUnlock() } // TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta func TestGetSzDecimals_NilMeta(t *testing.T) { - trader := &HyperliquidTrader{ + ht := &HyperliquidTrader{ meta: nil, metaMutex: sync.RWMutex{}, } // Should return default value 4 when meta is nil - decimals := trader.getSzDecimals("BTC") + decimals := ht.getSzDecimals("BTC") expectedDecimals := 4 if decimals != expectedDecimals { @@ -111,7 +111,7 @@ func TestGetSzDecimals_NilMeta(t *testing.T) { // TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta func TestGetSzDecimals_ValidMeta(t *testing.T) { - trader := &HyperliquidTrader{ + ht := &HyperliquidTrader{ meta: &hyperliquid.Meta{ Universe: []hyperliquid.AssetInfo{ {Name: "BTC", SzDecimals: 5}, @@ -133,7 +133,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) { for _, tt := range tests { t.Run(tt.coin, func(t *testing.T) { - decimals := trader.getSzDecimals(tt.coin) + decimals := ht.getSzDecimals(tt.coin) if decimals != tt.expectedDecimals { t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals) } @@ -144,7 +144,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) { // TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues // Run with: go test -race -run TestMetaMutex_NoRaceCondition func TestMetaMutex_NoRaceCondition(t *testing.T) { - trader := &HyperliquidTrader{ + ht := &HyperliquidTrader{ ctx: context.Background(), meta: &hyperliquid.Meta{ Universe: []hyperliquid.AssetInfo{ @@ -163,8 +163,8 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - trader.getSzDecimals("BTC") - trader.getSzDecimals("ETH") + ht.getSzDecimals("BTC") + ht.getSzDecimals("ETH") }() } @@ -173,15 +173,15 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) { wg.Add(1) go func(idx int) { defer wg.Done() - trader.metaMutex.Lock() - trader.meta = &hyperliquid.Meta{ + ht.metaMutex.Lock() + ht.meta = &hyperliquid.Meta{ Universe: []hyperliquid.AssetInfo{ {Name: "BTC", SzDecimals: 5}, {Name: "ETH", SzDecimals: 4}, {Name: "SOL", SzDecimals: 3}, }, } - trader.metaMutex.Unlock() + ht.metaMutex.Unlock() }(i) } diff --git a/trader/hyperliquid_trader_test.go b/trader/hyperliquid/trader_test.go similarity index 97% rename from trader/hyperliquid_trader_test.go rename to trader/hyperliquid/trader_test.go index 95d5812e..668cd4ca 100644 --- a/trader/hyperliquid_trader_test.go +++ b/trader/hyperliquid/trader_test.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "context" @@ -11,6 +11,8 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/sonirico/go-hyperliquid" "github.com/stretchr/testify/assert" + "nofx/trader/testutil" + "nofx/trader/types" ) // ============================================================ @@ -20,9 +22,9 @@ import ( // HyperliquidTestSuite Hyperliquid trader test suite // Inherits TraderTestSuite and adds Hyperliquid-specific mock logic type HyperliquidTestSuite struct { - *TraderTestSuite // Embeds base test suite - mockServer *httptest.Server - privateKey *ecdsa.PrivateKey + *testutil.TraderTestSuite // Embeds base test suite + mockServer *httptest.Server + privateKey *ecdsa.PrivateKey } // NewHyperliquidTestSuite Create Hyperliquid test suite @@ -216,7 +218,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite { }, } - trader := &HyperliquidTrader{ + traderInstance := &HyperliquidTrader{ exchange: exchange, ctx: ctx, walletAddr: walletAddr, @@ -225,7 +227,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite { } // Create base suite - baseSuite := NewTraderTestSuite(t, trader) + baseSuite := testutil.NewTraderTestSuite(t, traderInstance) return &HyperliquidTestSuite{ TraderTestSuite: baseSuite, @@ -248,7 +250,7 @@ func (s *HyperliquidTestSuite) Cleanup() { // TestHyperliquidTrader_InterfaceCompliance Test interface compliance func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) { - var _ Trader = (*HyperliquidTrader)(nil) + var _ types.Trader = (*HyperliquidTrader)(nil) } // TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite @@ -562,8 +564,8 @@ func TestHyperliquidTrader_GetSzDecimals(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - trader := &HyperliquidTrader{meta: tt.meta} - result := trader.getSzDecimals(tt.coin) + ht := &HyperliquidTrader{meta: tt.meta} + result := ht.getSzDecimals(tt.coin) assert.Equal(t, tt.expected, result) }) } diff --git a/trader/xyz_dex_test.go b/trader/hyperliquid/xyz_dex_test.go similarity index 99% rename from trader/xyz_dex_test.go rename to trader/hyperliquid/xyz_dex_test.go index 8b46432d..e04f9622 100644 --- a/trader/xyz_dex_test.go +++ b/trader/hyperliquid/xyz_dex_test.go @@ -1,4 +1,4 @@ -package trader +package hyperliquid import ( "bytes" diff --git a/trader/interface.go b/trader/interface.go index 741e6e31..72e5e365 100644 --- a/trader/interface.go +++ b/trader/interface.go @@ -3,161 +3,19 @@ package trader import ( "fmt" "nofx/logger" - "time" + "nofx/trader/types" ) -// ClosedPnLRecord represents a single closed position record from exchange -type ClosedPnLRecord struct { - Symbol string // Trading pair (e.g., "BTCUSDT") - Side string // "long" or "short" - EntryPrice float64 // Entry price - ExitPrice float64 // Exit/close price - Quantity float64 // Position size - RealizedPnL float64 // Realized profit/loss - Fee float64 // Trading fee/commission - Leverage int // Leverage used - EntryTime time.Time // Position open time - ExitTime time.Time // Position close time - OrderID string // Close order ID - CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown" - ExchangeID string // Exchange-specific position ID -} - -// TradeRecord represents a single trade/fill from exchange -// Used for reconstructing position history with unified algorithm -type TradeRecord struct { - TradeID string // Unique trade ID from exchange - Symbol string // Trading pair (e.g., "BTCUSDT") - Side string // "BUY" or "SELL" - PositionSide string // "LONG", "SHORT", or "BOTH" (for one-way mode) - OrderAction string // "open_long", "open_short", "close_long", "close_short" (from exchange Dir field) - Price float64 // Execution price - Quantity float64 // Executed quantity - RealizedPnL float64 // Realized PnL (non-zero for closing trades) - Fee float64 // Trading fee/commission - Time time.Time // Trade execution time -} - -// Trader Unified trader interface -// Supports multiple trading platforms (Binance, Hyperliquid, etc.) -type Trader interface { - // GetBalance Get account balance - GetBalance() (map[string]interface{}, error) - - // GetPositions Get all positions - GetPositions() ([]map[string]interface{}, error) - - // OpenLong Open long position - OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) - - // OpenShort Open short position - OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) - - // CloseLong Close long position (quantity=0 means close all) - CloseLong(symbol string, quantity float64) (map[string]interface{}, error) - - // CloseShort Close short position (quantity=0 means close all) - CloseShort(symbol string, quantity float64) (map[string]interface{}, error) - - // SetLeverage Set leverage - SetLeverage(symbol string, leverage int) error - - // SetMarginMode Set position mode (true=cross margin, false=isolated margin) - SetMarginMode(symbol string, isCrossMargin bool) error - - // GetMarketPrice Get market price - GetMarketPrice(symbol string) (float64, error) - - // SetStopLoss Set stop-loss order - SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error - - // SetTakeProfit Set take-profit order - SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error - - // CancelStopLossOrders Cancel only stop-loss orders (BUG fix: don't delete take-profit when adjusting stop-loss) - CancelStopLossOrders(symbol string) error - - // CancelTakeProfitOrders Cancel only take-profit orders (BUG fix: don't delete stop-loss when adjusting take-profit) - CancelTakeProfitOrders(symbol string) error - - // CancelAllOrders Cancel all pending orders for this symbol - CancelAllOrders(symbol string) error - - // CancelStopOrders Cancel stop-loss/take-profit orders for this symbol (for adjusting stop-loss/take-profit positions) - CancelStopOrders(symbol string) error - - // FormatQuantity Format quantity to correct precision - FormatQuantity(symbol string, quantity float64) (string, error) - - // GetOrderStatus Get order status - // Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission - GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) - - // GetClosedPnL Get closed position PnL records from exchange - // startTime: start time for query (usually last sync time) - // limit: max number of records to return - // Returns accurate exit price, fees, and close reason for positions closed externally - GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) - - // GetOpenOrders Get open/pending orders from exchange - // Returns stop-loss, take-profit, and limit orders that haven't been filled - GetOpenOrders(symbol string) ([]OpenOrder, error) -} - -// OpenOrder represents a pending order on the exchange -type OpenOrder struct { - OrderID string `json:"order_id"` - Symbol string `json:"symbol"` - Side string `json:"side"` // BUY/SELL - PositionSide string `json:"position_side"` // LONG/SHORT - Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET - Price float64 `json:"price"` // Order price (for limit orders) - StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders) - Quantity float64 `json:"quantity"` - Status string `json:"status"` // NEW -} - -// LimitOrderRequest represents a limit order request for grid trading -type LimitOrderRequest struct { - Symbol string `json:"symbol"` - Side string `json:"side"` // BUY/SELL - PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode) - Price float64 `json:"price"` // Limit price - Quantity float64 `json:"quantity"` - Leverage int `json:"leverage"` - PostOnly bool `json:"post_only"` // Maker only order - ReduceOnly bool `json:"reduce_only"` // Reduce position only - ClientID string `json:"client_id"` // Client order ID for tracking -} - -// LimitOrderResult represents the result of placing a limit order -type LimitOrderResult struct { - OrderID string `json:"order_id"` - ClientID string `json:"client_id"` - Symbol string `json:"symbol"` - Side string `json:"side"` - PositionSide string `json:"position_side"` - Price float64 `json:"price"` - Quantity float64 `json:"quantity"` - Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED -} - -// GridTrader extends Trader interface with limit order support for grid trading -// Exchanges that support grid trading should implement this interface -type GridTrader interface { - Trader - - // PlaceLimitOrder places a limit order at specified price - // Returns order ID and status - PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) - - // CancelOrder cancels a specific order by ID - CancelOrder(symbol, orderID string) error - - // GetOrderBook gets current order book (for price validation) - // Returns best bid/ask prices - GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) -} +// Re-export types for backward compatibility +type ( + ClosedPnLRecord = types.ClosedPnLRecord + TradeRecord = types.TradeRecord + Trader = types.Trader + OpenOrder = types.OpenOrder + LimitOrderRequest = types.LimitOrderRequest + LimitOrderResult = types.LimitOrderResult + GridTrader = types.GridTrader +) // GridTraderAdapter wraps a basic Trader to provide GridTrader interface // Uses stop orders as a fallback when limit orders aren't directly available diff --git a/trader/lighter_trader_v2_account.go b/trader/lighter/account.go similarity index 99% rename from trader/lighter_trader_v2_account.go rename to trader/lighter/account.go index b2865c32..39c14130 100644 --- a/trader/lighter_trader_v2_account.go +++ b/trader/lighter/account.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "encoding/json" @@ -91,7 +91,7 @@ func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) { // Calculate wallet balance (total equity - unrealized PnL) walletBalance := balance.TotalEquity - balance.UnrealizedPnL - // Return in standard format compatible with auto_trader.go + // Return in standard format compatible with auto_types.go // (totalEquity = totalWalletBalance + totalUnrealizedProfit) return map[string]interface{}{ "totalWalletBalance": walletBalance, // Wallet balance (excluding unrealized PnL) @@ -165,7 +165,7 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) { result := make([]map[string]interface{}, 0, len(positions)) for _, pos := range positions { - // Return in standard format compatible with auto_trader.go + // Return in standard format compatible with auto_types.go result = append(result, map[string]interface{}{ "symbol": pos.Symbol, "side": pos.Side, diff --git a/trader/lighter_integration_test.go b/trader/lighter/integration_test.go similarity index 99% rename from trader/lighter_integration_test.go rename to trader/lighter/integration_test.go index 11281201..2b655ead 100644 --- a/trader/lighter_integration_test.go +++ b/trader/lighter/integration_test.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "fmt" @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + tradertypes "nofx/trader/types" ) // Test configuration - uses environment variables for security @@ -684,7 +686,7 @@ func TestLighterPlaceLimitOrder(t *testing.T) { limitPrice := marketPrice * 0.75 quantity := 0.01 - req := &LimitOrderRequest{ + req := &tradertypes.LimitOrderRequest{ Symbol: "ETH", Side: "BUY", PositionSide: "LONG", diff --git a/trader/lighter_order_sync.go b/trader/lighter/order_sync.go similarity index 99% rename from trader/lighter_order_sync.go rename to trader/lighter/order_sync.go index 1c84541f..24fab52d 100644 --- a/trader/lighter_order_sync.go +++ b/trader/lighter/order_sync.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "fmt" diff --git a/trader/lighter_trader_v2_orders.go b/trader/lighter/orders.go similarity index 99% rename from trader/lighter_trader_v2_orders.go rename to trader/lighter/orders.go index 024b512c..ff2375ca 100644 --- a/trader/lighter_trader_v2_orders.go +++ b/trader/lighter/orders.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "encoding/json" diff --git a/trader/lighter_trader_v2_orders_test.go b/trader/lighter/orders_test.go similarity index 99% rename from trader/lighter_trader_v2_orders_test.go rename to trader/lighter/orders_test.go index 7b84912f..c698ebea 100644 --- a/trader/lighter_trader_v2_orders_test.go +++ b/trader/lighter/orders_test.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "encoding/json" diff --git a/trader/lighter_trader_v2.go b/trader/lighter/trader.go similarity index 97% rename from trader/lighter_trader_v2.go rename to trader/lighter/trader.go index 60b11570..a7cfe921 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter/trader.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "context" @@ -16,6 +16,7 @@ import ( lighterClient "github.com/elliottech/lighter-go/client" lighterHTTP "github.com/elliottech/lighter-go/client/http" "github.com/ethereum/go-ethereum/common/hexutil" + tradertypes "nofx/trader/types" ) // AccountInfo LIGHTER account information @@ -398,14 +399,14 @@ func (t *LighterTraderV2) Cleanup() error { // 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) { +func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]tradertypes.ClosedPnLRecord, error) { trades, err := t.GetTrades(startTime, limit) if err != nil { return nil, err } // Filter only closing trades (realizedPnl != 0) - var records []ClosedPnLRecord + var records []tradertypes.ClosedPnLRecord for _, trade := range trades { if trade.RealizedPnL == 0 { continue @@ -427,7 +428,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]Closed } } - records = append(records, ClosedPnLRecord{ + records = append(records, tradertypes.ClosedPnLRecord{ Symbol: trade.Symbol, Side: side, EntryPrice: entryPrice, @@ -447,7 +448,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]Closed } // GetTrades retrieves trade history from Lighter -func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { +func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertypes.TradeRecord, error) { // Ensure we have account index if t.accountIndex == 0 { if err := t.initializeAccount(); err != nil { @@ -490,7 +491,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco if resp.StatusCode != http.StatusOK { logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body)) - return []TradeRecord{}, nil + return []tradertypes.TradeRecord{}, nil } // Debug: log raw response @@ -502,14 +503,14 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco 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 + return []tradertypes.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 + return []tradertypes.TradeRecord{}, nil } // Build market_id -> symbol map @@ -528,7 +529,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco } // Convert to unified TradeRecord format - var result []TradeRecord + var result []tradertypes.TradeRecord for _, lt := range response.Trades { price, _ := parseFloat(lt.Price) qty, _ := parseFloat(lt.Size) @@ -615,7 +616,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco openSide, openAction = "LONG", "open_long" } - closeTrade := TradeRecord{ + closeTrade := tradertypes.TradeRecord{ TradeID: fmt.Sprintf("%d_close", lt.TradeID), Symbol: symbol, Side: side, @@ -629,7 +630,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco } result = append(result, closeTrade) - openTrade := TradeRecord{ + openTrade := tradertypes.TradeRecord{ TradeID: fmt.Sprintf("%d_open", lt.TradeID), Symbol: symbol, Side: side, @@ -671,7 +672,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco } } - trade := TradeRecord{ + trade := tradertypes.TradeRecord{ TradeID: fmt.Sprintf("%d", lt.TradeID), Symbol: symbol, Side: side, diff --git a/trader/lighter_trader_v2_trading.go b/trader/lighter/trading.go similarity index 98% rename from trader/lighter_trader_v2_trading.go rename to trader/lighter/trading.go index 1f4ff417..ae617757 100644 --- a/trader/lighter_trader_v2_trading.go +++ b/trader/lighter/trading.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "bytes" @@ -13,6 +13,7 @@ import ( "time" "github.com/elliottech/lighter-go/types" + tradertypes "nofx/trader/types" ) // OpenLong Open long position (implements Trader interface) @@ -856,14 +857,14 @@ func pow10(n int) int64 { } // GetOpenOrders gets all open/pending orders for a symbol -func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) { +func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder, error) { // Get active orders from Lighter API activeOrders, err := t.GetActiveOrders(symbol) if err != nil { return nil, fmt.Errorf("failed to get active orders: %w", err) } - var result []OpenOrder + var result []tradertypes.OpenOrder for _, order := range activeOrders { // Convert side: Lighter uses is_ask (true=sell, false=buy) side := "BUY" @@ -905,7 +906,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) { } triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64) - openOrder := OpenOrder{ + openOrder := tradertypes.OpenOrder{ OrderID: order.OrderID, Symbol: symbol, Side: side, @@ -925,7 +926,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) { // PlaceLimitOrder implements GridTrader interface for grid trading // Places a limit order at the specified price -func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*tradertypes.LimitOrderResult, error) { if t.txClient == nil { return nil, fmt.Errorf("TxClient not initialized") } @@ -960,7 +961,7 @@ func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderRe logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s", req.Symbol, req.Side, req.Price, orderID) - return &LimitOrderResult{ + return &tradertypes.LimitOrderResult{ OrderID: orderID, ClientID: req.ClientID, Symbol: req.Symbol, diff --git a/trader/lighter_types.go b/trader/lighter/types.go similarity index 96% rename from trader/lighter_types.go rename to trader/lighter/types.go index e4670cdd..10352fcc 100644 --- a/trader/lighter_types.go +++ b/trader/lighter/types.go @@ -1,4 +1,4 @@ -package trader +package lighter import ( "fmt" @@ -7,6 +7,14 @@ import ( "golang.org/x/crypto/sha3" ) +// SymbolPrecision Symbol precision information +type SymbolPrecision struct { + PricePrecision int + QuantityPrecision int + TickSize float64 // Price tick size + StepSize float64 // Quantity step size +} + // AccountBalance Account balance information (Lighter) type AccountBalance struct { TotalEquity float64 `json:"total_equity"` // Total equity diff --git a/trader/okx_order_sync.go b/trader/okx/order_sync.go similarity index 99% rename from trader/okx_order_sync.go rename to trader/okx/order_sync.go index 89e8731b..a8246ecb 100644 --- a/trader/okx_order_sync.go +++ b/trader/okx/order_sync.go @@ -1,4 +1,4 @@ -package trader +package okx import ( "encoding/json" diff --git a/trader/okx_trader.go b/trader/okx/trader.go similarity index 98% rename from trader/okx_trader.go rename to trader/okx/trader.go index 5b614e58..a7f9eda8 100644 --- a/trader/okx_trader.go +++ b/trader/okx/trader.go @@ -1,4 +1,4 @@ -package trader +package okx import ( "bytes" @@ -16,6 +16,7 @@ import ( "strings" "sync" "time" + "nofx/trader/types" ) // OKX API endpoints @@ -1281,7 +1282,7 @@ var okxTag = func() string { // GetClosedPnL retrieves closed position PnL records from OKX // OKX API: /api/v5/account/positions-history -func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { +func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) { if limit <= 0 { limit = 100 } @@ -1328,10 +1329,10 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg) } - records := make([]ClosedPnLRecord, 0, len(resp.Data)) + records := make([]types.ClosedPnLRecord, 0, len(resp.Data)) for _, pos := range resp.Data { - record := ClosedPnLRecord{} + record := types.ClosedPnLRecord{} // Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT) parts := strings.Split(pos.InstID, "-") @@ -1389,9 +1390,9 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec } // GetOpenOrders gets all open/pending orders for a symbol -func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { +func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { instId := t.convertSymbol(symbol) - var result []OpenOrder + var result []types.OpenOrder // 1. Get pending limit orders path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId) @@ -1422,7 +1423,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { positionSide = "BOTH" } - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: order.OrdId, Symbol: symbol, Side: side, @@ -1471,7 +1472,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { if order.SlTriggerPx != "" { slPrice, _ := strconv.ParseFloat(order.SlTriggerPx, 64) if slPrice > 0 { - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: order.AlgoId + "_sl", Symbol: symbol, Side: side, @@ -1489,7 +1490,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { if order.TpTriggerPx != "" { tpPrice, _ := strconv.ParseFloat(order.TpTriggerPx, 64) if tpPrice > 0 { - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: order.AlgoId + "_tp", Symbol: symbol, Side: side, @@ -1507,7 +1508,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { if order.TriggerPx != "" && order.SlTriggerPx == "" && order.TpTriggerPx == "" { triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64) if triggerPrice > 0 { - result = append(result, OpenOrder{ + result = append(result, types.OpenOrder{ OrderID: order.AlgoId, Symbol: symbol, Side: side, @@ -1530,7 +1531,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { // PlaceLimitOrder places a limit order for grid trading // Implements GridTrader interface -func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { +func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { instId := t.convertSymbol(req.Symbol) // Get instrument info @@ -1604,7 +1605,7 @@ func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s", instId, side, req.Price, orders[0].OrdId) - return &LimitOrderResult{ + return &types.LimitOrderResult{ OrderID: orders[0].OrdId, ClientID: orders[0].ClOrdId, Symbol: req.Symbol, diff --git a/trader/trader_test_suite.go b/trader/testutil/test_suite.go similarity index 99% rename from trader/trader_test_suite.go rename to trader/testutil/test_suite.go index d1f8032c..a9b81131 100644 --- a/trader/trader_test_suite.go +++ b/trader/testutil/test_suite.go @@ -1,10 +1,11 @@ -package trader +package testutil import ( "testing" "github.com/agiledragon/gomonkey/v2" "github.com/stretchr/testify/assert" + "nofx/trader/types" ) // TraderTestSuite Generic Trader interface test suite (base suite) @@ -16,12 +17,12 @@ import ( // 3. Call RunAllTests() to run all generic tests type TraderTestSuite struct { T *testing.T - Trader Trader + Trader types.Trader Patches *gomonkey.Patches } // NewTraderTestSuite Create new base test suite -func NewTraderTestSuite(t *testing.T, trader Trader) *TraderTestSuite { +func NewTraderTestSuite(t *testing.T, trader types.Trader) *TraderTestSuite { return &TraderTestSuite{ T: t, Trader: trader, diff --git a/trader/types/interface.go b/trader/types/interface.go new file mode 100644 index 00000000..d5f550c8 --- /dev/null +++ b/trader/types/interface.go @@ -0,0 +1,230 @@ +package types + +import ( + "fmt" + "nofx/logger" + "time" +) + +// ClosedPnLRecord represents a single closed position record from exchange +type ClosedPnLRecord struct { + Symbol string // Trading pair (e.g., "BTCUSDT") + Side string // "long" or "short" + EntryPrice float64 // Entry price + ExitPrice float64 // Exit/close price + Quantity float64 // Position size + RealizedPnL float64 // Realized profit/loss + Fee float64 // Trading fee/commission + Leverage int // Leverage used + EntryTime time.Time // Position open time + ExitTime time.Time // Position close time + OrderID string // Close order ID + CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown" + ExchangeID string // Exchange-specific position ID +} + +// TradeRecord represents a single trade/fill from exchange +// Used for reconstructing position history with unified algorithm +type TradeRecord struct { + TradeID string // Unique trade ID from exchange + Symbol string // Trading pair (e.g., "BTCUSDT") + Side string // "BUY" or "SELL" + PositionSide string // "LONG", "SHORT", or "BOTH" (for one-way mode) + OrderAction string // "open_long", "open_short", "close_long", "close_short" (from exchange Dir field) + Price float64 // Execution price + Quantity float64 // Executed quantity + RealizedPnL float64 // Realized PnL (non-zero for closing trades) + Fee float64 // Trading fee/commission + Time time.Time // Trade execution time +} + +// Trader Unified trader interface +// Supports multiple trading platforms (Binance, Hyperliquid, etc.) +type Trader interface { + // GetBalance Get account balance + GetBalance() (map[string]interface{}, error) + + // GetPositions Get all positions + GetPositions() ([]map[string]interface{}, error) + + // OpenLong Open long position + OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) + + // OpenShort Open short position + OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) + + // CloseLong Close long position (quantity=0 means close all) + CloseLong(symbol string, quantity float64) (map[string]interface{}, error) + + // CloseShort Close short position (quantity=0 means close all) + CloseShort(symbol string, quantity float64) (map[string]interface{}, error) + + // SetLeverage Set leverage + SetLeverage(symbol string, leverage int) error + + // SetMarginMode Set position mode (true=cross margin, false=isolated margin) + SetMarginMode(symbol string, isCrossMargin bool) error + + // GetMarketPrice Get market price + GetMarketPrice(symbol string) (float64, error) + + // SetStopLoss Set stop-loss order + SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error + + // SetTakeProfit Set take-profit order + SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error + + // CancelStopLossOrders Cancel only stop-loss orders (BUG fix: don't delete take-profit when adjusting stop-loss) + CancelStopLossOrders(symbol string) error + + // CancelTakeProfitOrders Cancel only take-profit orders (BUG fix: don't delete stop-loss when adjusting take-profit) + CancelTakeProfitOrders(symbol string) error + + // CancelAllOrders Cancel all pending orders for this symbol + CancelAllOrders(symbol string) error + + // CancelStopOrders Cancel stop-loss/take-profit orders for this symbol (for adjusting stop-loss/take-profit positions) + CancelStopOrders(symbol string) error + + // FormatQuantity Format quantity to correct precision + FormatQuantity(symbol string, quantity float64) (string, error) + + // GetOrderStatus Get order status + // Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission + GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) + + // GetClosedPnL Get closed position PnL records from exchange + // startTime: start time for query (usually last sync time) + // limit: max number of records to return + // Returns accurate exit price, fees, and close reason for positions closed externally + GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) + + // GetOpenOrders Get open/pending orders from exchange + // Returns stop-loss, take-profit, and limit orders that haven't been filled + GetOpenOrders(symbol string) ([]OpenOrder, error) +} + +// OpenOrder represents a pending order on the exchange +type OpenOrder struct { + OrderID string `json:"order_id"` + Symbol string `json:"symbol"` + Side string `json:"side"` // BUY/SELL + PositionSide string `json:"position_side"` // LONG/SHORT + Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET + Price float64 `json:"price"` // Order price (for limit orders) + StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders) + Quantity float64 `json:"quantity"` + Status string `json:"status"` // NEW +} + +// LimitOrderRequest represents a limit order request for grid trading +type LimitOrderRequest struct { + Symbol string `json:"symbol"` + Side string `json:"side"` // BUY/SELL + PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode) + Price float64 `json:"price"` // Limit price + Quantity float64 `json:"quantity"` + Leverage int `json:"leverage"` + PostOnly bool `json:"post_only"` // Maker only order + ReduceOnly bool `json:"reduce_only"` // Reduce position only + ClientID string `json:"client_id"` // Client order ID for tracking +} + +// LimitOrderResult represents the result of placing a limit order +type LimitOrderResult struct { + OrderID string `json:"order_id"` + ClientID string `json:"client_id"` + Symbol string `json:"symbol"` + Side string `json:"side"` + PositionSide string `json:"position_side"` + Price float64 `json:"price"` + Quantity float64 `json:"quantity"` + Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED +} + +// GridTrader extends Trader interface with limit order support for grid trading +// Exchanges that support grid trading should implement this interface +type GridTrader interface { + Trader + + // PlaceLimitOrder places a limit order at specified price + // Returns order ID and status + PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) + + // CancelOrder cancels a specific order by ID + CancelOrder(symbol, orderID string) error + + // GetOrderBook gets current order book (for price validation) + // Returns best bid/ask prices + GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) +} + +// GridTraderAdapter wraps a basic Trader to provide GridTrader interface +// Uses stop orders as a fallback when limit orders aren't directly available +type GridTraderAdapter struct { + Trader +} + +// NewGridTraderAdapter creates an adapter for basic Trader +func NewGridTraderAdapter(t Trader) *GridTraderAdapter { + return &GridTraderAdapter{Trader: t} +} + +// PlaceLimitOrder implements limit order using available methods +// For exchanges without native limit order support, this uses conditional orders +func (a *GridTraderAdapter) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { + // CRITICAL FIX: Set leverage before placing order + if req.Leverage > 0 { + if err := a.Trader.SetLeverage(req.Symbol, req.Leverage); err != nil { + logger.Warnf("[Grid] Failed to set leverage %dx: %v", req.Leverage, err) + // Continue anyway - some exchanges don't require explicit leverage setting + } + } + + // Use SetStopLoss/SetTakeProfit as conditional limit orders + // For buy orders below current price, use stop-loss mechanism + // For sell orders above current price, use take-profit mechanism + var err error + if req.Side == "BUY" { + err = a.Trader.SetStopLoss(req.Symbol, "SHORT", req.Quantity, req.Price) + } else { + err = a.Trader.SetTakeProfit(req.Symbol, "LONG", req.Quantity, req.Price) + } + if err != nil { + return nil, err + } + return &LimitOrderResult{ + OrderID: req.ClientID, + ClientID: req.ClientID, + Symbol: req.Symbol, + Side: req.Side, + PositionSide: req.PositionSide, + Price: req.Price, + Quantity: req.Quantity, + Status: "NEW", + }, nil +} + +// CancelOrder cancels a specific order +func (a *GridTraderAdapter) CancelOrder(symbol, orderID string) error { + // Try to use CancelOrder if trader supports it directly + if canceler, ok := a.Trader.(interface { + CancelOrder(symbol, orderID string) error + }); ok { + return canceler.CancelOrder(symbol, orderID) + } + + // For traders that only support CancelAllOrders, log a warning + // This is a limitation - we cannot cancel individual orders + logger.Warnf("[Grid] Trader does not support individual order cancellation, "+ + "cannot cancel order %s. Consider using exchange-specific GridTrader implementation.", orderID) + + // Return error instead of canceling all orders + return fmt.Errorf("individual order cancellation not supported for this exchange") +} + +// GetOrderBook returns empty order book (not supported in basic Trader) +func (a *GridTraderAdapter) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) { + // Not supported, return empty + return nil, nil, nil +} diff --git a/web/public/exchange-icons/gate.svg b/web/public/exchange-icons/gate.svg new file mode 100644 index 00000000..a8dcda73 --- /dev/null +++ b/web/public/exchange-icons/gate.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/exchange-icons/kucoin.svg b/web/public/exchange-icons/kucoin.svg new file mode 100644 index 00000000..47647f25 --- /dev/null +++ b/web/public/exchange-icons/kucoin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/components/ExchangeIcons.tsx b/web/src/components/ExchangeIcons.tsx index 86494bfb..60f0b980 100644 --- a/web/src/components/ExchangeIcons.tsx +++ b/web/src/components/ExchangeIcons.tsx @@ -12,6 +12,7 @@ const ICON_PATHS: Record = { bybit: '/exchange-icons/bybit.png', okx: '/exchange-icons/okx.svg', bitget: '/exchange-icons/bitget.svg', + gate: '/exchange-icons/gate.svg', kucoin: '/exchange-icons/kucoin.svg', hyperliquid: '/exchange-icons/hyperliquid.png', aster: '/exchange-icons/aster.svg', @@ -90,15 +91,17 @@ export const getExchangeIcon = ( ? 'okx' : lowerType.includes('bitget') ? 'bitget' - : lowerType.includes('kucoin') - ? 'kucoin' - : lowerType.includes('hyperliquid') - ? 'hyperliquid' - : lowerType.includes('aster') - ? 'aster' - : lowerType.includes('lighter') - ? 'lighter' - : lowerType + : lowerType.includes('gate') + ? 'gate' + : lowerType.includes('kucoin') + ? 'kucoin' + : lowerType.includes('hyperliquid') + ? 'hyperliquid' + : lowerType.includes('aster') + ? 'aster' + : lowerType.includes('lighter') + ? 'lighter' + : lowerType const iconProps = { width: props.width || 24, diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index bceffa21..41ae7c27 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -25,6 +25,7 @@ const SUPPORTED_EXCHANGE_TEMPLATES = [ { exchange_type: 'bybit', name: 'Bybit Futures', type: 'cex' as const }, { exchange_type: 'okx', name: 'OKX Futures', type: 'cex' as const }, { exchange_type: 'bitget', name: 'Bitget Futures', type: 'cex' as const }, + { exchange_type: 'gate', name: 'Gate.io Futures', type: 'cex' as const }, { exchange_type: 'kucoin', name: 'KuCoin Futures', type: 'cex' as const }, { exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const }, { exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const }, @@ -198,6 +199,7 @@ export function ExchangeConfigModal({ okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true }, bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true }, bitget: { url: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172', hasReferral: true }, + gate: { url: 'https://www.gatenode.xyz/share/VQBGUAxY', hasReferral: true }, kucoin: { url: 'https://www.kucoin.com/r/broker/CXEV7XKK', hasReferral: true }, hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true }, aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true }, @@ -501,7 +503,7 @@ export function ExchangeConfigModal({ {/* CEX Fields */} - {(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin') && ( + {(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'gate' || currentExchangeType === 'kucoin') && ( <> {currentExchangeType === 'binance' && (