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' && (