feat(gate): complete Gate.io exchange integration with trader refactoring

Gate.io Integration:
- Add Gate trader with full Trader interface implementation
- Add order_sync.go for background trade synchronization
- Fix quantity display (convert contracts to actual tokens via quanto_multiplier)
- Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort
- Add Gate-specific CoinAnk K-line data source support
- Add Gate to supported exchanges in frontend and backend
- Add Gate/KuCoin logo SVG icons

Trader Package Refactoring:
- Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/)
- Create types/ package for shared types to avoid circular dependencies
- Move TraderTestSuite to trader/testutil package to avoid import cycles
- Update market.GetWithExchange to support exchange-specific data
This commit is contained in:
tinkle-community
2026-01-31 23:15:17 +08:00
parent 40474d258c
commit 093d2a329d
54 changed files with 2183 additions and 424 deletions
+2 -1
View File
@@ -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)
+49 -23
View File
@@ -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"},
+2 -1
View File
@@ -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 (分散型永久先物取引所)
+2 -1
View File
@@ -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 (탈중앙화 영구 선물 거래소)
+2 -1
View File
@@ -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 (Децентрализованные биржи)
+2 -1
View File
@@ -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 (Децентралізовані біржі)
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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 (去中心化永续交易所)
+2
View File
@@ -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
+4
View File
@@ -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=
+3
View File
@@ -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
+58 -14
View File
@@ -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 {
@@ -1,4 +1,4 @@
package trader
package aster
import (
"fmt"
@@ -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,
@@ -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)
}
}
})
+43 -20
View File
@@ -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
}
@@ -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),
@@ -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")
})
}
@@ -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 {
@@ -1,4 +1,4 @@
package trader
package binance
import (
"context"
@@ -1,4 +1,4 @@
package trader
package binance
import (
"nofx/store"
@@ -1,4 +1,4 @@
package trader
package binance
import (
"context"
@@ -1,4 +1,4 @@
package trader
package bitget
import (
"encoding/json"
@@ -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,
@@ -1,4 +1,4 @@
package trader
package bybit
import (
"crypto/hmac"
@@ -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,
@@ -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)
}
// ============================================================
+282
View File
@@ -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)
}
+897
View File
@@ -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)
+337
View File
@@ -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)
}
@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"os"
@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"fmt"
@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"math"
@@ -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,
@@ -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)
}
@@ -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)
})
}
@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"bytes"
+11 -153
View File
@@ -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
@@ -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,
@@ -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",
@@ -1,4 +1,4 @@
package trader
package lighter
import (
"fmt"
@@ -1,4 +1,4 @@
package trader
package lighter
import (
"encoding/json"
@@ -1,4 +1,4 @@
package trader
package lighter
import (
"encoding/json"
@@ -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,
@@ -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,
@@ -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
@@ -1,4 +1,4 @@
package trader
package okx
import (
"encoding/json"
+13 -12
View File
@@ -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,
@@ -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,
+230
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1C1C28"/>
<g transform="translate(8, 8)">
<path d="M12 18.6c-3.64 0-6.6-2.96-6.6-6.6s2.96-6.6 6.6-6.6V0C5.37 0 0 5.38 0 12s5.37 12 12 12c6.62 0 12-5.38 12-12h-5.4c0 3.64-2.96 6.6-6.6 6.6z" fill="#2354e6"/>
<path d="M12 12h6.6V5.4H12z" fill="#17e6a1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 394 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1C1C28"/>
<g transform="translate(5, 5) scale(0.15)">
<path d="M57.7007 99.9146L116.94 159.158L154.381 121.714C160.964 115.131 171.734 115.131 178.317 121.714C184.899 128.297 184.899 139.068 178.317 145.651L128.908 195.063C122.326 201.646 111.555 201.646 104.973 195.063L34.0221 123.937V166.339C34.0221 175.572 26.4997 183.351 17.0111 183.351C7.52258 183.351 0 175.828 0 166.339V34.003C0 24.5138 7.52258 16.9908 17.0111 16.9908C26.4997 16.9908 34.0221 24.5138 34.0221 34.003V76.0633L105.143 4.93695C111.726 -1.64565 122.496 -1.64565 129.079 4.93695L178.488 54.3492C185.07 60.9318 185.07 71.7034 178.488 78.286C171.905 84.8686 161.135 84.8686 154.552 78.286L117.111 40.8421L57.7007 100.085V99.9146ZM117.111 82.9024C107.622 82.9024 100.1 90.4254 100.1 99.9146C100.1 109.404 107.622 116.927 117.111 116.927C126.6 116.927 134.122 109.404 134.122 99.9146C133.951 90.4254 126.429 82.9024 117.111 82.9024Z" fill="#00B47D"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+12 -9
View File
@@ -12,6 +12,7 @@ const ICON_PATHS: Record<string, string> = {
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,
@@ -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({
</div>
{/* 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' && (
<div