Compare commits
1 Commits
7b9a0740c1
...
palette-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a0cb589b |
3
.Jules/palette.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## 2025-05-14 - Password Visibility Toggle Accessibility
|
||||
**Learning:** Icon-only buttons for toggling password visibility are often missing `aria-label` and `aria-pressed` states, making them unusable for screen reader users who need to verify their input.
|
||||
**Action:** Always include dynamic `aria-label` (Switching between "Show password" and "Hide password") and `aria-pressed` state for password toggles.
|
||||
37
README.ja.md
@@ -103,43 +103,6 @@ Binance互換の分散型無期限先物取引所!
|
||||
|
||||
---
|
||||
|
||||
## 対応取引所
|
||||
|
||||
### CEX(中央集権型取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録(手数料割引) |
|
||||
|:-------|:----------:|:-------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX(分散型無期限取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録(手数料割引) |
|
||||
|:-------|:----------:|:-------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 対応AIモデル
|
||||
|
||||
| AIモデル | ステータス | APIキー取得 |
|
||||
|:---------|:----------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [APIキー取得](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [APIキー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [APIキー取得](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [APIキー取得](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [APIキー取得](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [APIキー取得](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [APIキー取得](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 📸 スクリーンショット
|
||||
|
||||
### 🏆 競争モード - リアルタイムAIバトル
|
||||
|
||||
64
README.md
@@ -1,21 +1,9 @@
|
||||
<h1 align="center">NOFX — Open Source AI Trading OS</h1>
|
||||
# NOFX - Agentic Trading OS
|
||||
|
||||
<p align="center">
|
||||
<strong>The infrastructure layer for AI-powered financial trading.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
| CONTRIBUTOR AIRDROP PROGRAM |
|
||||
|:----------------------------------:|
|
||||
@@ -26,6 +14,10 @@
|
||||
|
||||
---
|
||||
|
||||
## AI-Powered Multi-Asset Trading Platform
|
||||
|
||||
**NOFX** is an open-source AI trading system that lets you run multiple AI models to trade automatically. Configure strategies through a web interface, monitor performance in real-time, and let AI agents compete to find the best trading approach.
|
||||
|
||||
### Supported Markets
|
||||
|
||||
| Market | Trading | Status |
|
||||
@@ -38,7 +30,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, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
|
||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, 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
|
||||
@@ -78,35 +70,33 @@ To use NOFX, you'll need:
|
||||
### CEX (Centralized Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -832,8 +832,6 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
|
||||
provider = "google"
|
||||
} else if strings.Contains(modelNameLower, "deepseek") {
|
||||
provider = "deepseek"
|
||||
} else if strings.Contains(modelNameLower, "minimax") {
|
||||
provider = "minimax"
|
||||
} else if model.CustomAPIURL != "" {
|
||||
provider = "custom"
|
||||
} else {
|
||||
|
||||
252
api/register_otp_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MockUser Mock user structure
|
||||
type MockUser struct {
|
||||
ID int
|
||||
Email string
|
||||
OTPSecret string
|
||||
OTPVerified bool
|
||||
}
|
||||
|
||||
// TestOTPRefetchLogic Test OTP refetch logic
|
||||
func TestOTPRefetchLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existingUser *MockUser
|
||||
userExists bool
|
||||
expectedAction string // "allow_refetch", "reject_duplicate", "create_new"
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "New user registration - email does not exist",
|
||||
existingUser: nil,
|
||||
userExists: false,
|
||||
expectedAction: "create_new",
|
||||
expectedMessage: "Create new user",
|
||||
},
|
||||
{
|
||||
name: "Incomplete OTP verification - allow refetch",
|
||||
existingUser: &MockUser{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
OTPSecret: "SECRET123",
|
||||
OTPVerified: false,
|
||||
},
|
||||
userExists: true,
|
||||
expectedAction: "allow_refetch",
|
||||
expectedMessage: "Incomplete registration detected, please continue OTP setup",
|
||||
},
|
||||
{
|
||||
name: "Completed OTP verification - reject duplicate registration",
|
||||
existingUser: &MockUser{
|
||||
ID: 2,
|
||||
Email: "verified@example.com",
|
||||
OTPSecret: "SECRET456",
|
||||
OTPVerified: true,
|
||||
},
|
||||
userExists: true,
|
||||
expectedAction: "reject_duplicate",
|
||||
expectedMessage: "Email already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate logic processing flow
|
||||
var actualAction string
|
||||
var actualMessage string
|
||||
|
||||
if !tt.userExists {
|
||||
// User does not exist, create new user
|
||||
actualAction = "create_new"
|
||||
actualMessage = "Create new user"
|
||||
} else {
|
||||
// User exists, check OTP verification status
|
||||
if !tt.existingUser.OTPVerified {
|
||||
// OTP verification incomplete, allow refetch
|
||||
actualAction = "allow_refetch"
|
||||
actualMessage = "Incomplete registration detected, please continue OTP setup"
|
||||
} else {
|
||||
// Verification completed, reject duplicate registration
|
||||
actualAction = "reject_duplicate"
|
||||
actualMessage = "Email already registered"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if actualAction != tt.expectedAction {
|
||||
t.Errorf("Action mismatch: got %s, want %s", actualAction, tt.expectedAction)
|
||||
}
|
||||
if actualMessage != tt.expectedMessage {
|
||||
t.Errorf("Message mismatch: got %s, want %s", actualMessage, tt.expectedMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOTPVerificationStates Test OTP verification state determination
|
||||
func TestOTPVerificationStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
otpVerified bool
|
||||
shouldAllowRefetch bool
|
||||
}{
|
||||
{
|
||||
name: "OTP verified - disallow refetch",
|
||||
otpVerified: true,
|
||||
shouldAllowRefetch: false,
|
||||
},
|
||||
{
|
||||
name: "OTP not verified - allow refetch",
|
||||
otpVerified: false,
|
||||
shouldAllowRefetch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate verification logic
|
||||
allowRefetch := !tt.otpVerified
|
||||
|
||||
if allowRefetch != tt.shouldAllowRefetch {
|
||||
t.Errorf("Refetch logic error: OTPVerified=%v, allowRefetch=%v, expected=%v",
|
||||
tt.otpVerified, allowRefetch, tt.shouldAllowRefetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistrationFlow Test complete registration flow logic branches
|
||||
func TestRegistrationFlow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scenario string
|
||||
userExists bool
|
||||
otpVerified bool
|
||||
expectHTTPCode int // Simulated HTTP status code
|
||||
expectResponse string
|
||||
}{
|
||||
{
|
||||
name: "Scenario 1: New user first registration",
|
||||
scenario: "New user first accesses registration endpoint",
|
||||
userExists: false,
|
||||
otpVerified: false,
|
||||
expectHTTPCode: 200,
|
||||
expectResponse: "Create user and return OTP setup information",
|
||||
},
|
||||
{
|
||||
name: "Scenario 2: User re-accesses after interrupting registration",
|
||||
scenario: "User registered previously but did not complete OTP setup, now re-accessing",
|
||||
userExists: true,
|
||||
otpVerified: false,
|
||||
expectHTTPCode: 200,
|
||||
expectResponse: "Return existing user's OTP information, allow continuation",
|
||||
},
|
||||
{
|
||||
name: "Scenario 3: Registered user attempts duplicate registration",
|
||||
scenario: "User already completed registration, attempts to register again with same email",
|
||||
userExists: true,
|
||||
otpVerified: true,
|
||||
expectHTTPCode: 409, // Conflict
|
||||
expectResponse: "Email already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate registration flow logic
|
||||
var actualHTTPCode int
|
||||
var actualResponse string
|
||||
|
||||
if !tt.userExists {
|
||||
// New user, create and return OTP information
|
||||
actualHTTPCode = 200
|
||||
actualResponse = "Create user and return OTP setup information"
|
||||
} else {
|
||||
// User exists
|
||||
if !tt.otpVerified {
|
||||
// OTP verification incomplete, allow refetch
|
||||
actualHTTPCode = 200
|
||||
actualResponse = "Return existing user's OTP information, allow continuation"
|
||||
} else {
|
||||
// Verification completed, reject duplicate registration
|
||||
actualHTTPCode = 409
|
||||
actualResponse = "Email already registered"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
if actualHTTPCode != tt.expectHTTPCode {
|
||||
t.Errorf("HTTP code mismatch: got %d, want %d (scenario: %s)",
|
||||
actualHTTPCode, tt.expectHTTPCode, tt.scenario)
|
||||
}
|
||||
if actualResponse != tt.expectResponse {
|
||||
t.Errorf("Response mismatch: got %s, want %s (scenario: %s)",
|
||||
actualResponse, tt.expectResponse, tt.scenario)
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: HTTP %d, %s", tt.scenario, actualHTTPCode, actualResponse)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCases Test edge cases
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user *MockUser
|
||||
expectAllow bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User ID is 0 - treated as new user",
|
||||
user: &MockUser{
|
||||
ID: 0,
|
||||
Email: "new@example.com",
|
||||
OTPVerified: false,
|
||||
},
|
||||
expectAllow: true,
|
||||
description: "ID of 0 usually indicates user has not been created yet",
|
||||
},
|
||||
{
|
||||
name: "OTPSecret is empty - still can refetch",
|
||||
user: &MockUser{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
OTPSecret: "",
|
||||
OTPVerified: false,
|
||||
},
|
||||
expectAllow: true,
|
||||
description: "Even if OTPSecret is empty, as long as not verified, refetch is allowed",
|
||||
},
|
||||
{
|
||||
name: "OTPSecret exists but already verified - not allowed",
|
||||
user: &MockUser{
|
||||
ID: 2,
|
||||
Email: "verified@example.com",
|
||||
OTPSecret: "SECRET789",
|
||||
OTPVerified: true,
|
||||
},
|
||||
expectAllow: false,
|
||||
description: "Users with verified OTP cannot refetch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Core logic: as long as OTPVerified is false, refetch is allowed
|
||||
allowRefetch := !tt.user.OTPVerified
|
||||
|
||||
if allowRefetch != tt.expectAllow {
|
||||
t.Errorf("Edge case failed: %s\nUser: ID=%d, OTPVerified=%v\nExpected allow=%v, got=%v",
|
||||
tt.description, tt.user.ID, tt.user.OTPVerified, tt.expectAllow, allowRefetch)
|
||||
}
|
||||
|
||||
t.Logf("✓ %s", tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
277
api/server.go
@@ -12,7 +12,6 @@ import (
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/security"
|
||||
"nofx/market"
|
||||
"nofx/provider/alpaca"
|
||||
"nofx/provider/coinank/coinank_api"
|
||||
@@ -21,15 +20,6 @@ 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/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -143,6 +133,8 @@ func (s *Server) setupRoutes() {
|
||||
// Authentication related routes (no authentication required)
|
||||
api.POST("/register", s.handleRegister)
|
||||
api.POST("/login", s.handleLogin)
|
||||
api.POST("/verify-otp", s.handleVerifyOTP)
|
||||
api.POST("/complete-registration", s.handleCompleteRegistration)
|
||||
|
||||
// Routes requiring authentication
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
@@ -264,14 +256,13 @@ func (s *Server) handleGetServerIP(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// getPublicIPFromAPI Get public IP via third-party API (IPv4 only)
|
||||
// getPublicIPFromAPI Get public IP via third-party API
|
||||
func getPublicIPFromAPI() string {
|
||||
// Try multiple public IP query services (IPv4-only endpoints)
|
||||
// Try multiple public IP query services
|
||||
services := []string{
|
||||
"https://api4.ipify.org?format=text", // IPv4 only
|
||||
"https://ipv4.icanhazip.com", // IPv4 only
|
||||
"https://v4.ident.me", // IPv4 only
|
||||
"https://api.ipify.org?format=text", // May return IPv4 or IPv6
|
||||
"https://api.ipify.org?format=text",
|
||||
"https://icanhazip.com",
|
||||
"https://ifconfig.me",
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
@@ -293,9 +284,8 @@ func getPublicIPFromAPI() string {
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body[:n]))
|
||||
parsedIP := net.ParseIP(ip)
|
||||
// Verify if it's a valid IPv4 address (not containing ":")
|
||||
if parsedIP != nil && parsedIP.To4() != nil {
|
||||
// Verify if it's a valid IP address
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
@@ -483,7 +473,6 @@ type UpdateExchangeConfigRequest struct {
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
@@ -594,44 +583,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
// Convert EncryptedString fields to string
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
||||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey), // private key
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = aster.NewAsterTrader(
|
||||
tempTrader, createErr = trader.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "bybit":
|
||||
tempTrader = bybit.NewBybitTrader(
|
||||
tempTrader = trader.NewBybitTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "okx":
|
||||
tempTrader = okx.NewOKXTrader(
|
||||
tempTrader = trader.NewOKXTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "bitget":
|
||||
tempTrader = bitget.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "gate":
|
||||
tempTrader = gate.NewGateTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "kucoin":
|
||||
tempTrader = kucoin.NewKuCoinTrader(
|
||||
tempTrader = trader.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
@@ -639,7 +616,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
||||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
@@ -1164,44 +1141,32 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
// Convert EncryptedString fields to string
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
||||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = aster.NewAsterTrader(
|
||||
tempTrader, createErr = trader.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "bybit":
|
||||
tempTrader = bybit.NewBybitTrader(
|
||||
tempTrader = trader.NewBybitTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "okx":
|
||||
tempTrader = okx.NewOKXTrader(
|
||||
tempTrader = trader.NewOKXTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "bitget":
|
||||
tempTrader = bitget.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "gate":
|
||||
tempTrader = gate.NewGateTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "kucoin":
|
||||
tempTrader = kucoin.NewKuCoinTrader(
|
||||
tempTrader = trader.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
@@ -1209,7 +1174,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
||||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
@@ -1328,44 +1293,32 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
// Convert EncryptedString fields to string
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
||||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = aster.NewAsterTrader(
|
||||
tempTrader, createErr = trader.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "bybit":
|
||||
tempTrader = bybit.NewBybitTrader(
|
||||
tempTrader = trader.NewBybitTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "okx":
|
||||
tempTrader = okx.NewOKXTrader(
|
||||
tempTrader = trader.NewOKXTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "bitget":
|
||||
tempTrader = bitget.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "gate":
|
||||
tempTrader = gate.NewGateTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "kucoin":
|
||||
tempTrader = kucoin.NewKuCoinTrader(
|
||||
tempTrader = trader.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
@@ -1373,7 +1326,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
||||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
@@ -1452,7 +1405,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", "gate":
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||||
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
|
||||
return
|
||||
}
|
||||
@@ -1684,7 +1637,6 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
|
||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
|
||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
|
||||
}
|
||||
c.JSON(http.StatusOK, defaultModels)
|
||||
return
|
||||
@@ -1771,15 +1723,6 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
// Update each model's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for modelID, modelData := range req.Models {
|
||||
// SSRF protection: validate custom_api_url before storing
|
||||
if modelData.CustomAPIURL != "" {
|
||||
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
|
||||
if err := security.ValidateURL(cleanURL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find traders using this AI model BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
||||
for _, t := range traders {
|
||||
@@ -1919,7 +1862,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
@@ -1953,7 +1896,6 @@ type CreateExchangeRequest struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
@@ -2017,7 +1959,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
// Validate exchange type
|
||||
validTypes := map[string]bool{
|
||||
"binance": true, "bybit": true, "okx": true, "bitget": true,
|
||||
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
|
||||
"hyperliquid": true, "aster": true, "lighter": true,
|
||||
}
|
||||
if !validTypes[req.ExchangeType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
||||
@@ -2028,8 +1970,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
id, err := s.store.Exchange().Create(
|
||||
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -2550,16 +2491,11 @@ 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":
|
||||
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
|
||||
coinankExchange = coinank_enum.Binance
|
||||
case "kucoin":
|
||||
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
|
||||
coinankExchange = coinank_enum.Binance
|
||||
default:
|
||||
// For any unknown exchange, default to Binance
|
||||
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
|
||||
@@ -3104,9 +3040,29 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
_, err := s.store.User().GetByEmail(req.Email)
|
||||
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
|
||||
existingUser, err := s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
// User exists, check OTP verification status
|
||||
if !existingUser.OTPVerified {
|
||||
// OTP not verified, verify password first for security
|
||||
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
// Password correct, allow user to continue OTP setup
|
||||
// Return existing OTP information
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": existingUser.ID,
|
||||
"email": existingUser.Email,
|
||||
"otp_secret": existingUser.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"message": "Incomplete registration detected, please continue OTP setup",
|
||||
})
|
||||
return
|
||||
}
|
||||
// OTP already verified, reject duplicate registration
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
@@ -3132,12 +3088,21 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
// Generate OTP secret
|
||||
otpSecret, err := auth.GenerateOTPSecret()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "OTP secret generation failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create user (unverified OTP status)
|
||||
userID := uuid.New().String()
|
||||
user := &store.User{
|
||||
ID: userID,
|
||||
Email: req.Email,
|
||||
PasswordHash: passwordHash,
|
||||
OTPSecret: otpSecret,
|
||||
OTPVerified: false,
|
||||
}
|
||||
|
||||
err = s.store.User().Create(user)
|
||||
@@ -3146,6 +3111,49 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return OTP setup information
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID,
|
||||
"email": req.Email,
|
||||
"otp_secret": otpSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"message": "Please scan the QR code with Google Authenticator and verify OTP",
|
||||
})
|
||||
}
|
||||
|
||||
// handleCompleteRegistration Complete registration (verify OTP)
|
||||
func (s *Server) handleCompleteRegistration(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
OTPCode string `json:"otp_code" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByID(req.UserID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "User")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OTP code error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user OTP verified status
|
||||
err = s.store.User().UpdateOTPVerified(req.UserID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
@@ -3163,7 +3171,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
"token": token,
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"message": "Registration successful",
|
||||
"message": "Registration completed",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3192,7 +3200,56 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Issue token directly after password verification.
|
||||
// Check if OTP is verified
|
||||
if !user.OTPVerified {
|
||||
// Return OTP info so user can complete setup
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"otp_secret": user.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"requires_otp_setup": true,
|
||||
"message": "Please complete OTP setup first",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return status requiring OTP verification
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"message": "Please enter Google Authenticator code",
|
||||
"requires_otp": true,
|
||||
})
|
||||
}
|
||||
|
||||
// handleVerifyOTP Verify OTP and complete login
|
||||
func (s *Server) handleVerifyOTP(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
OTPCode string `json:"otp_code" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByID(req.UserID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "User")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Verification code error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
@@ -3207,11 +3264,12 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleResetPassword Reset password via email and new password
|
||||
// handleResetPassword Reset password (via email + OTP verification)
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
OTPCode string `json:"otp_code" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -3226,6 +3284,12 @@ func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Google Authenticator code error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new password hash
|
||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
@@ -3259,11 +3323,10 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
|
||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
|
||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-5-20251101"},
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedModels)
|
||||
@@ -3277,8 +3340,6 @@ 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: "kucoin", Name: "KuCoin Futures", Type: "cex"},
|
||||
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
|
||||
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
|
||||
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
|
||||
|
||||
@@ -625,9 +625,6 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
case "openai":
|
||||
aiClient = mcp.NewOpenAIClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "minimax":
|
||||
aiClient = mcp.NewMiniMaxClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
default:
|
||||
// Use generic client
|
||||
aiClient = mcp.NewClient()
|
||||
|
||||
35
auth/auth.go
@@ -1,12 +1,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -22,6 +25,9 @@ var tokenBlacklist = struct {
|
||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||
const maxBlacklistEntries = 100_000
|
||||
|
||||
// OTPIssuer is the OTP issuer name
|
||||
const OTPIssuer = "nofxAI"
|
||||
|
||||
// SetJWTSecret sets the JWT secret key
|
||||
func SetJWTSecret(secret string) {
|
||||
JWTSecret = []byte(secret)
|
||||
@@ -81,6 +87,30 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateOTPSecret generates OTP secret
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: OTPIssuer,
|
||||
AccountName: uuid.New().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return key.Secret(), nil
|
||||
}
|
||||
|
||||
// VerifyOTP verifies OTP code
|
||||
func VerifyOTP(secret, code string) bool {
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// GenerateJWT generates JWT token
|
||||
func GenerateJWT(userID, email string) (string, error) {
|
||||
claims := Claims{
|
||||
@@ -117,3 +147,8 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// GetOTPQRCodeURL gets OTP QR code URL
|
||||
func GetOTPQRCodeURL(secret, email string) string {
|
||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
|
||||
}
|
||||
|
||||
@@ -71,13 +71,6 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
||||
oaiC := mcp.NewOpenAIClientWithOptions()
|
||||
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return oaiC, nil
|
||||
case "minimax":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("minimax provider requires api key")
|
||||
}
|
||||
mmC := mcp.NewMiniMaxClientWithOptions()
|
||||
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return mmC, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
@@ -132,11 +125,6 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.MiniMaxClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
// Fall back to a new default client
|
||||
return mcp.NewClient().(*mcp.Client)
|
||||
|
||||
@@ -97,8 +97,6 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
|
||||
client = mcp.NewGrokClient()
|
||||
case "kimi":
|
||||
client = mcp.NewKimiClient()
|
||||
case "minimax":
|
||||
client = mcp.NewMiniMaxClient()
|
||||
default:
|
||||
client = mcp.New()
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# ⚠️ Official Accounts & Anti-Impersonation Notice
|
||||
|
||||
## Legal Entity
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| Company Name | **Cryonic Holdings Limited** |
|
||||
| Company No. | 2193977 |
|
||||
| Jurisdiction | British Virgin Islands |
|
||||
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
|
||||
| Contact Email | 0xccfelix@gmail.com |
|
||||
|
||||
## Official Social Media & Channels
|
||||
|
||||
| Platform | Official Account | Link | Status |
|
||||
|----------|-----------------|------|--------|
|
||||
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
|
||||
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
|
||||
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
|
||||
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
|
||||
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
|
||||
|
||||
## ⛔ Known Impersonation Accounts
|
||||
|
||||
The following accounts are **NOT affiliated** with the NoFx project:
|
||||
|
||||
| Platform | Account | Status |
|
||||
|----------|---------|--------|
|
||||
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
|
||||
|
||||
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
|
||||
|
||||
## How to Verify Authenticity
|
||||
|
||||
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
|
||||
2. Our GitHub repository sidebar links directly to our official Twitter
|
||||
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
|
||||
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
|
||||
5. Official contact email: 0xccfelix@gmail.com
|
||||
|
||||
## Report Impersonation
|
||||
|
||||
If you encounter accounts impersonating NoFx, please:
|
||||
1. Report them on the respective platform
|
||||
2. Open an issue in this repository to notify our team
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-01*
|
||||
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*
|
||||
@@ -241,7 +241,6 @@ NOFX offers bounties for valuable contributions:
|
||||
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
|
||||
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
||||
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
||||
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
<h1 align="center">NOFX — オープンソース AI トレーディング OS</h1>
|
||||
# NOFX - AI トレーディングシステム
|
||||
|
||||
<p align="center">
|
||||
<strong>AI 駆動金融取引のインフラストラクチャレイヤー</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
|
||||
|
||||
---
|
||||
|
||||
## AI 駆動の暗号通貨取引プラットフォーム
|
||||
|
||||
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
|
||||
|
||||
### コア機能
|
||||
|
||||
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
||||
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||
@@ -63,8 +55,6 @@ NOFXを使用するには以下が必要です:
|
||||
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
||||
| **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 (分散型永久先物取引所)
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
<h1 align="center">NOFX — 오픈소스 AI 트레이딩 OS</h1>
|
||||
# NOFX - AI 트레이딩 시스템
|
||||
|
||||
<p align="center">
|
||||
<strong>AI 기반 금융 거래를 위한 인프라 레이어</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
|
||||
|
||||
---
|
||||
|
||||
## AI 기반 암호화폐 거래 플랫폼
|
||||
|
||||
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
||||
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
@@ -63,8 +55,6 @@ NOFX를 사용하려면 다음이 필요합니다:
|
||||
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
||||
| **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 (탈중앙화 영구 선물 거래소)
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
<h1 align="center">NOFX — Open Source AI Торговая ОС</h1>
|
||||
# NOFX - AI Торговая Система
|
||||
|
||||
<p align="center">
|
||||
<strong>Инфраструктурный слой для AI-powered финансовой торговли</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
**Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Криптовалютная торговая платформа на базе ИИ
|
||||
|
||||
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
|
||||
|
||||
### Основные функции
|
||||
|
||||
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
|
||||
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
|
||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||
@@ -63,8 +55,6 @@
|
||||
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| **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 (Децентрализованные биржи)
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
<h1 align="center">NOFX — Open Source AI Торгова ОС</h1>
|
||||
# NOFX - AI Торгова Система
|
||||
|
||||
<p align="center">
|
||||
<strong>Інфраструктурний рівень для AI-powered фінансової торгівлі</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
**Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Криптовалютна торгова платформа на базі ШІ
|
||||
|
||||
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
|
||||
|
||||
### Основні функції
|
||||
|
||||
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
|
||||
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
|
||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||
@@ -63,8 +55,6 @@
|
||||
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| **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 (Децентралізовані біржі)
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
<h1 align="center">NOFX — Hệ Điều Hành Giao Dịch AI Mã Nguồn Mở</h1>
|
||||
# NOFX - Hệ Thống Giao Dịch AI
|
||||
|
||||
<p align="center">
|
||||
<strong>Lớp cơ sở hạ tầng cho giao dịch tài chính AI-powered</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
**Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Nền Tảng Giao Dịch Crypto Sử Dụng AI
|
||||
|
||||
**NOFX** là hệ thống giao dịch AI mã nguồn mở cho phép bạn chạy nhiều mô hình AI để tự động giao dịch hợp đồng tương lai crypto. Cấu hình chiến lược qua giao diện web, theo dõi hiệu suất theo thời gian thực, và để các AI agent cạnh tranh tìm ra phương pháp giao dịch tốt nhất.
|
||||
|
||||
### 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, Bitget, KuCoin, Gate, 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, 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
|
||||
@@ -63,8 +55,6 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
|
||||
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| **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)
|
||||
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
<h1 align="center">NOFX — 开源 AI 交易操作系统</h1>
|
||||
# NOFX - AI 交易系统
|
||||
|
||||
<p align="center">
|
||||
<strong>AI 驱动金融交易的基础设施层</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
|
||||
|
||||
@@ -28,10 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 驱动的加密货币交易平台
|
||||
|
||||
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案。
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
|
||||
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
|
||||
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
|
||||
@@ -75,8 +67,6 @@
|
||||
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
||||
| **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 (去中心化永续交易所)
|
||||
|
||||
|
||||
3
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
@@ -22,7 +23,6 @@ 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,7 +44,6 @@ 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
|
||||
|
||||
6
go.sum
@@ -8,8 +8,6 @@ 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=
|
||||
@@ -70,8 +68,6 @@ 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=
|
||||
@@ -186,6 +182,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
|
||||
124
kernel/engine.go
@@ -1,7 +1,6 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
@@ -492,44 +490,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_all":
|
||||
// All Hyperliquid perp coins
|
||||
if !coinSource.UseHyperAll {
|
||||
logger.Infof("⚠️ source_type is 'hyper_all' but use_hyper_all is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
coins, err := e.getHyperAllCoins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_main":
|
||||
// Top N Hyperliquid coins by 24h volume
|
||||
if !coinSource.UseHyperMain {
|
||||
logger.Infof("⚠️ source_type is 'hyper_main' but use_hyper_main is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
coins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
if coinSource.UseAI500 {
|
||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
@@ -564,28 +524,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseHyperAll {
|
||||
hyperCoins, err := e.getHyperAllCoins()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get Hyperliquid All coins: %v", err)
|
||||
} else {
|
||||
for _, coin := range hyperCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_all")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseHyperMain {
|
||||
hyperMainCoins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get Hyperliquid Main coins: %v", err)
|
||||
} else {
|
||||
for _, coin := range hyperMainCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_main")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
if _, exists := symbolSources[symbol]; !exists {
|
||||
@@ -702,52 +640,6 @@ func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// getHyperAllCoins returns all available Hyperliquid perpetual coins
|
||||
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
|
||||
ctx := context.Background()
|
||||
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for _, symbol := range symbols {
|
||||
// Add USDT suffix for compatibility
|
||||
normalizedSymbol := market.Normalize(symbol + "USDT")
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: normalizedSymbol,
|
||||
Sources: []string{"hyper_all"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
|
||||
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for _, symbol := range symbols {
|
||||
// Add USDT suffix for compatibility
|
||||
normalizedSymbol := market.Normalize(symbol + "USDT")
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: normalizedSymbol,
|
||||
Sources: []string{"hyper_main"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -1458,8 +1350,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
hasAI500 := false
|
||||
hasOITop := false
|
||||
hasOILow := false
|
||||
hasHyperAll := false
|
||||
hasHyperMain := false
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
@@ -1468,10 +1358,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = true
|
||||
case "hyper_all":
|
||||
hasHyperAll = true
|
||||
case "hyper_main":
|
||||
hasHyperMain = true
|
||||
}
|
||||
}
|
||||
if hasAI500 && hasOITop {
|
||||
@@ -1483,12 +1369,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if hasOITop && hasOILow {
|
||||
return " (OI_Top+OI_Low)"
|
||||
}
|
||||
if hasHyperMain && hasAI500 {
|
||||
return " (HyperMain+AI500)"
|
||||
}
|
||||
if hasHyperAll || hasHyperMain {
|
||||
return " (Hyperliquid)"
|
||||
}
|
||||
return " (Multiple sources)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
@@ -1500,10 +1380,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
return " (OI_Low 持仓减少)"
|
||||
case "static":
|
||||
return " (Manual selection)"
|
||||
case "hyper_all":
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -84,9 +84,6 @@ type GridContext struct {
|
||||
|
||||
// Box indicators (Donchian Channels)
|
||||
BoxData *market.BoxData `json:"box_data,omitempty"`
|
||||
|
||||
// Grid direction (neutral, long, short, long_bias, short_bias)
|
||||
CurrentDirection string `json:"current_direction,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -282,20 +279,6 @@ func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescZh := map[string]string{
|
||||
"neutral": "中性 (50%买+50%卖)",
|
||||
"long": "做多 (100%买)",
|
||||
"short": "做空 (100%卖)",
|
||||
"long_bias": "偏多 (70%买+30%卖)",
|
||||
"short_bias": "偏空 (30%买+70%卖)",
|
||||
}
|
||||
desc := directionDescZh[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
@@ -393,20 +376,6 @@ func buildGridUserPromptEn(ctx *GridContext) string {
|
||||
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescEn := map[string]string{
|
||||
"neutral": "Neutral (50% buy + 50% sell)",
|
||||
"long": "Long (100% buy)",
|
||||
"short": "Short (100% sell)",
|
||||
"long_bias": "Long Bias (70% buy + 30% sell)",
|
||||
"short_bias": "Short Bias (30% buy + 70% sell)",
|
||||
}
|
||||
desc := directionDescEn[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
|
||||
@@ -407,6 +407,7 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
// RemoveTrader removes a trader from memory (does not affect database)
|
||||
// Used to force reload when updating trader configuration
|
||||
// If the trader is running, it will be stopped first
|
||||
@@ -663,11 +664,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
QwenKey: "",
|
||||
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
||||
CustomModelName: aiModelCfg.CustomModelName,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
}
|
||||
|
||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||
@@ -689,17 +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 "kucoin":
|
||||
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "hyperliquid":
|
||||
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
|
||||
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
|
||||
case "aster":
|
||||
traderConfig.AsterUser = exchangeCfg.AsterUser
|
||||
traderConfig.AsterSigner = exchangeCfg.AsterSigner
|
||||
@@ -710,9 +703,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
|
||||
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
||||
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
||||
case "indodax":
|
||||
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
|
||||
}
|
||||
|
||||
// Set API keys based on AI model (convert EncryptedString to string)
|
||||
|
||||
@@ -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, exchange string, limit int) ([]Kline, error) {
|
||||
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||
// Map interval string to coinank enum
|
||||
var coinankInterval coinank_enum.Interval
|
||||
switch interval {
|
||||
@@ -67,44 +67,13 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
|
||||
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, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
// 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)
|
||||
}
|
||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||
}
|
||||
|
||||
// Convert coinank kline format to market.Kline format
|
||||
@@ -165,13 +134,8 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// Get retrieves market data for the specified token (uses Binance data by default)
|
||||
// Get retrieves market data for the specified token
|
||||
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
|
||||
@@ -180,21 +144,18 @@ func GetWithExchange(symbol, exchange 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 useHyperliquidAPI {
|
||||
if isXyzAsset {
|
||||
// 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 with exchange-specific data
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,15 +166,15 @@ func GetWithExchange(symbol, exchange string) (*Data, error) {
|
||||
}
|
||||
|
||||
// Get 4-hour K-line data
|
||||
if useHyperliquidAPI {
|
||||
if isXyzAsset {
|
||||
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", exchange, 100)
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +290,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets (default to Binance)
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||||
continue
|
||||
@@ -1107,11 +1068,6 @@ 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
|
||||
@@ -1327,7 +1283,7 @@ func GetBoxData(symbol string) (*BoxData, error) {
|
||||
if IsXyzDexAsset(symbol) {
|
||||
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
||||
} else {
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -226,37 +226,3 @@ const (
|
||||
BreakoutMid BreakoutLevel = "mid"
|
||||
BreakoutLong BreakoutLevel = "long"
|
||||
)
|
||||
|
||||
// GridDirection represents the current grid trading direction bias
|
||||
type GridDirection string
|
||||
|
||||
const (
|
||||
GridDirectionNeutral GridDirection = "neutral" // 50% buy + 50% sell
|
||||
GridDirectionLong GridDirection = "long" // 100% buy
|
||||
GridDirectionShort GridDirection = "short" // 100% sell
|
||||
GridDirectionLongBias GridDirection = "long_bias" // 70% buy + 30% sell (default)
|
||||
GridDirectionShortBias GridDirection = "short_bias" // 30% buy + 70% sell (default)
|
||||
)
|
||||
|
||||
// GetBuySellRatio returns the buy and sell ratio for this direction
|
||||
// biasRatio is the ratio for biased directions (default 0.7 means 70%/30%)
|
||||
func (d GridDirection) GetBuySellRatio(biasRatio float64) (buyRatio, sellRatio float64) {
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7 // Default 70%/30%
|
||||
}
|
||||
|
||||
switch d {
|
||||
case GridDirectionNeutral:
|
||||
return 0.5, 0.5
|
||||
case GridDirectionLong:
|
||||
return 1.0, 0.0
|
||||
case GridDirectionShort:
|
||||
return 0.0, 1.0
|
||||
case GridDirectionLongBias:
|
||||
return biasRatio, 1.0 - biasRatio
|
||||
case GridDirectionShortBias:
|
||||
return 1.0 - biasRatio, biasRatio
|
||||
default:
|
||||
return 0.5, 0.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
const (
|
||||
ProviderClaude = "claude"
|
||||
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
|
||||
DefaultClaudeModel = "claude-opus-4-6"
|
||||
DefaultClaudeModel = "claude-opus-4-5-20251101"
|
||||
)
|
||||
|
||||
type ClaudeClient struct {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
)
|
||||
|
||||
// Config client configuration (centralized management of all configurations)
|
||||
@@ -49,7 +48,7 @@ func DefaultConfig() *Config {
|
||||
|
||||
// Default dependencies (use global logger)
|
||||
Logger: logger.NewMCPLogger(),
|
||||
HTTPClient: security.SafeHTTPClient(DefaultTimeout),
|
||||
HTTPClient: &http.Client{Timeout: DefaultTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderMiniMax = "minimax"
|
||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.5"
|
||||
)
|
||||
|
||||
type MiniMaxClient struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// NewMiniMaxClient creates MiniMax client (backward compatible)
|
||||
func NewMiniMaxClient() AIClient {
|
||||
return NewMiniMaxClientWithOptions()
|
||||
}
|
||||
|
||||
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
|
||||
//
|
||||
// Usage examples:
|
||||
//
|
||||
// // Basic usage
|
||||
// client := mcp.NewMiniMaxClientWithOptions()
|
||||
//
|
||||
// // Custom configuration
|
||||
// client := mcp.NewMiniMaxClientWithOptions(
|
||||
// mcp.WithAPIKey("sk-xxx"),
|
||||
// mcp.WithLogger(customLogger),
|
||||
// mcp.WithTimeout(60*time.Second),
|
||||
// )
|
||||
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
|
||||
// 1. Create MiniMax preset options
|
||||
minimaxOpts := []ClientOption{
|
||||
WithProvider(ProviderMiniMax),
|
||||
WithModel(DefaultMiniMaxModel),
|
||||
WithBaseURL(DefaultMiniMaxBaseURL),
|
||||
}
|
||||
|
||||
// 2. Merge user options (user options have higher priority)
|
||||
allOpts := append(minimaxOpts, opts...)
|
||||
|
||||
// 3. Create base client
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
|
||||
// 4. Create MiniMax client
|
||||
minimaxClient := &MiniMaxClient{
|
||||
Client: baseClient,
|
||||
}
|
||||
|
||||
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
|
||||
baseClient.hooks = minimaxClient
|
||||
|
||||
return minimaxClient
|
||||
}
|
||||
|
||||
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
|
||||
if len(apiKey) > 8 {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||
}
|
||||
if customURL != "" {
|
||||
c.BaseURL = customURL
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// MiniMax uses standard OpenAI-compatible API with Bearer auth
|
||||
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
|
||||
c.Client.setAuthHeader(reqHeaders)
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Test MiniMaxClient Creation and Configuration
|
||||
// ============================================================
|
||||
|
||||
func TestNewMiniMaxClient_Default(t *testing.T) {
|
||||
client := NewMiniMaxClient()
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("client should not be nil")
|
||||
}
|
||||
|
||||
// Type assertion check
|
||||
mmClient, ok := client.(*MiniMaxClient)
|
||||
if !ok {
|
||||
t.Fatal("client should be *MiniMaxClient")
|
||||
}
|
||||
|
||||
// Verify default values
|
||||
if mmClient.Provider != ProviderMiniMax {
|
||||
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
|
||||
}
|
||||
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
|
||||
}
|
||||
|
||||
if mmClient.Model != DefaultMiniMaxModel {
|
||||
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
|
||||
}
|
||||
|
||||
if mmClient.logger == nil {
|
||||
t.Error("logger should not be nil")
|
||||
}
|
||||
|
||||
if mmClient.httpClient == nil {
|
||||
t.Error("httpClient should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMiniMaxClientWithOptions(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
customModel := "MiniMax-M2.5-highspeed"
|
||||
customAPIKey := "sk-custom-key"
|
||||
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
WithModel(customModel),
|
||||
WithAPIKey(customAPIKey),
|
||||
WithMaxTokens(4000),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Verify custom options are applied
|
||||
if mmClient.logger != mockLogger {
|
||||
t.Error("logger should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.Model != customModel {
|
||||
t.Error("Model should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.APIKey != customAPIKey {
|
||||
t.Error("APIKey should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.MaxTokens != 4000 {
|
||||
t.Error("MaxTokens should be 4000")
|
||||
}
|
||||
|
||||
// Verify MiniMax default values are retained
|
||||
if mmClient.Provider != ProviderMiniMax {
|
||||
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
|
||||
}
|
||||
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test SetAPIKey
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Test setting API Key (default URL and Model)
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
|
||||
|
||||
if mmClient.APIKey != "sk-test-key-12345678" {
|
||||
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
if len(logs) == 0 {
|
||||
t.Error("should have logged API key setting")
|
||||
}
|
||||
|
||||
// Verify BaseURL and Model remain default
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Error("BaseURL should remain default")
|
||||
}
|
||||
|
||||
if mmClient.Model != DefaultMiniMaxModel {
|
||||
t.Error("Model should remain default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
customURL := "https://api.minimaxi.com/v1"
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
|
||||
|
||||
if mmClient.BaseURL != customURL {
|
||||
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
hasCustomURLLog := false
|
||||
for _, log := range logs {
|
||||
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
|
||||
hasCustomURLLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasCustomURLLog {
|
||||
t.Error("should have logged custom BaseURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
customModel := "MiniMax-M2.5-highspeed"
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
|
||||
|
||||
if mmClient.Model != customModel {
|
||||
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
hasCustomModelLog := false
|
||||
for _, log := range logs {
|
||||
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
|
||||
hasCustomModelLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasCustomModelLog {
|
||||
t.Error("should have logged custom Model")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test Integration Features
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.SetSuccessResponse("MiniMax AI response")
|
||||
mockLogger := NewMockLogger()
|
||||
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(mockLogger),
|
||||
WithAPIKey("sk-test-key"),
|
||||
)
|
||||
|
||||
result, err := client.CallWithMessages("system prompt", "user prompt")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
|
||||
if result != "MiniMax AI response" {
|
||||
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
|
||||
}
|
||||
|
||||
// Verify request
|
||||
requests := mockHTTP.GetRequests()
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", len(requests))
|
||||
}
|
||||
|
||||
req := requests[0]
|
||||
|
||||
// Verify URL
|
||||
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
|
||||
if req.URL.String() != expectedURL {
|
||||
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
|
||||
}
|
||||
|
||||
// Verify Authorization header
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if authHeader != "Bearer sk-test-key" {
|
||||
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
|
||||
}
|
||||
|
||||
// Verify Content-Type
|
||||
if req.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("Content-Type should be application/json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_Timeout(t *testing.T) {
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithTimeout(30 * time.Second),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
if mmClient.httpClient.Timeout != 30*time.Second {
|
||||
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
|
||||
}
|
||||
|
||||
// Test SetTimeout
|
||||
client.SetTimeout(60 * time.Second)
|
||||
|
||||
if mmClient.httpClient.Timeout != 60*time.Second {
|
||||
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test hooks Mechanism
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
|
||||
client := NewMiniMaxClientWithOptions()
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Verify hooks point to mmClient itself (implements polymorphism)
|
||||
if mmClient.hooks != mmClient {
|
||||
t.Error("hooks should point to mmClient for polymorphism")
|
||||
}
|
||||
|
||||
// Verify buildUrl uses MiniMax configuration
|
||||
url := mmClient.buildUrl()
|
||||
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
|
||||
if url != expectedURL {
|
||||
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
const (
|
||||
ProviderOpenAI = "openai"
|
||||
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
DefaultOpenAIModel = "gpt-5.4"
|
||||
DefaultOpenAIModel = "gpt-5.2"
|
||||
)
|
||||
|
||||
type OpenAIClient struct {
|
||||
|
||||
@@ -22,11 +22,7 @@ func WithLogger(logger Logger) ClientOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets custom HTTP client.
|
||||
//
|
||||
// WARNING: The default client uses security.SafeHTTPClient() with SSRF protection
|
||||
// (blocks private IPs, cloud metadata, validates redirects). Overriding it bypasses
|
||||
// these protections. Only use in tests or with a client providing equivalent safeguards.
|
||||
// WithHTTPClient sets custom HTTP client
|
||||
//
|
||||
// Usage example:
|
||||
// httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
@@ -164,17 +160,3 @@ func WithQwenConfig(apiKey string) ClientOption {
|
||||
c.Model = DefaultQwenModel
|
||||
}
|
||||
}
|
||||
|
||||
// WithMiniMaxConfig sets MiniMax configuration
|
||||
//
|
||||
// Usage example:
|
||||
//
|
||||
// client := mcp.NewClient(mcp.WithMiniMaxConfig("sk-xxx"))
|
||||
func WithMiniMaxConfig(apiKey string) ClientOption {
|
||||
return func(c *Config) {
|
||||
c.Provider = ProviderMiniMax
|
||||
c.APIKey = apiKey
|
||||
c.BaseURL = DefaultMiniMaxBaseURL
|
||||
c.Model = DefaultMiniMaxModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
||||
cacheDuration = 24 * time.Hour // Cache for 24 hours
|
||||
)
|
||||
|
||||
// CoinInfo represents basic coin information
|
||||
type CoinInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
|
||||
}
|
||||
|
||||
// CoinProvider provides Hyperliquid coin lists
|
||||
type CoinProvider struct {
|
||||
mu sync.RWMutex
|
||||
allCoins []CoinInfo
|
||||
mainCoins []CoinInfo
|
||||
lastUpdated time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
defaultProvider *CoinProvider
|
||||
providerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetProvider returns the singleton CoinProvider instance
|
||||
func GetProvider() *CoinProvider {
|
||||
providerOnce.Do(func() {
|
||||
defaultProvider = &CoinProvider{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
})
|
||||
return defaultProvider
|
||||
}
|
||||
|
||||
// metaResponse represents the response from Hyperliquid meta endpoint
|
||||
type metaResponse struct {
|
||||
Universe []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"universe"`
|
||||
}
|
||||
|
||||
// assetCtx represents asset context with volume data
|
||||
type assetCtx struct {
|
||||
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
|
||||
}
|
||||
|
||||
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume
|
||||
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
|
||||
// Request metaAndAssetCtxs to get both coin names and volume data
|
||||
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch coin data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Response is an array: [meta, [assetCtxs...]]
|
||||
var rawResp []json.RawMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(rawResp) < 2 {
|
||||
return fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
// Parse meta
|
||||
var meta metaResponse
|
||||
if err := json.Unmarshal(rawResp[0], &meta); err != nil {
|
||||
return fmt.Errorf("failed to parse meta: %w", err)
|
||||
}
|
||||
|
||||
// Parse asset contexts
|
||||
var ctxs []assetCtx
|
||||
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
|
||||
return fmt.Errorf("failed to parse asset contexts: %w", err)
|
||||
}
|
||||
|
||||
// Build coin list with volume
|
||||
var coins []CoinInfo
|
||||
for i, u := range meta.Universe {
|
||||
var vol float64
|
||||
if i < len(ctxs) {
|
||||
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol)
|
||||
}
|
||||
coins = append(coins, CoinInfo{
|
||||
Symbol: u.Name,
|
||||
Volume24h: vol,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by volume descending
|
||||
sort.Slice(coins, func(i, j int) bool {
|
||||
return coins[i].Volume24h > coins[j].Volume24h
|
||||
})
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.allCoins = coins
|
||||
// Main coins are top 20 by volume
|
||||
if len(coins) > 20 {
|
||||
p.mainCoins = coins[:20]
|
||||
} else {
|
||||
p.mainCoins = coins
|
||||
}
|
||||
p.lastUpdated = time.Now()
|
||||
|
||||
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureUpdated checks if cache is stale and refreshes if needed
|
||||
func (p *CoinProvider) ensureUpdated(ctx context.Context) error {
|
||||
p.mu.RLock()
|
||||
needsUpdate := time.Since(p.lastUpdated) > cacheDuration || len(p.allCoins) == 0
|
||||
p.mu.RUnlock()
|
||||
|
||||
if needsUpdate {
|
||||
return p.fetchCoins(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllCoins returns all available Hyperliquid perp coins
|
||||
func (p *CoinProvider) GetAllCoins(ctx context.Context) ([]CoinInfo, error) {
|
||||
if err := p.ensureUpdated(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
// Return a copy to avoid mutation
|
||||
result := make([]CoinInfo, len(p.allCoins))
|
||||
copy(result, p.allCoins)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMainCoins returns top N coins by 24h volume
|
||||
func (p *CoinProvider) GetMainCoins(ctx context.Context, limit int) ([]CoinInfo, error) {
|
||||
if err := p.ensureUpdated(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Return top N coins
|
||||
count := limit
|
||||
if count > len(p.allCoins) {
|
||||
count = len(p.allCoins)
|
||||
}
|
||||
|
||||
result := make([]CoinInfo, count)
|
||||
copy(result, p.allCoins[:count])
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCoinSymbols returns just the symbol names (for compatibility)
|
||||
func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
|
||||
coins, err := GetProvider().GetAllCoins(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
symbols := make([]string, len(coins))
|
||||
for i, c := range coins {
|
||||
symbols[i] = c.Symbol
|
||||
}
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetMainCoinSymbols returns top N coin symbols by volume
|
||||
func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
|
||||
coins, err := GetProvider().GetMainCoins(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
symbols := make([]string, len(coins))
|
||||
for i, c := range coins {
|
||||
symbols[i] = c.Symbol
|
||||
}
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// ForceRefresh forces a refresh of the coin cache
|
||||
func (p *CoinProvider) ForceRefresh(ctx context.Context) error {
|
||||
return p.fetchCoins(ctx)
|
||||
}
|
||||
@@ -105,8 +105,7 @@ func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
|
||||
}
|
||||
|
||||
if len(availableCoins) == 0 {
|
||||
// Empty list is normal - just return empty slice, not an error
|
||||
return []string{}, nil
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
// Sort by Score descending (bubble sort)
|
||||
@@ -148,7 +147,10 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Empty list is normal - just return empty slice, not an error
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ func (s *EquityStore) Save(snapshot *EquitySnapshot) error {
|
||||
snapshot.Timestamp = snapshot.Timestamp.UTC()
|
||||
}
|
||||
|
||||
// Omit ID to let PostgreSQL sequence auto-generate it
|
||||
// Without this, GORM inserts ID=0 which causes duplicate key errors
|
||||
if err := s.db.Omit("ID").Create(snapshot).Error; err != nil {
|
||||
if err := s.db.Create(snapshot).Error; err != nil {
|
||||
return fmt.Errorf("failed to save equity snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -17,28 +17,27 @@ type ExchangeStore struct {
|
||||
|
||||
// Exchange exchange configuration
|
||||
type Exchange struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
||||
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
||||
Testnet bool `gorm:"default:false" json:"testnet"`
|
||||
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
||||
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
|
||||
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
||||
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
||||
Testnet bool `gorm:"default:false" json:"testnet"`
|
||||
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
||||
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
||||
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
||||
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
||||
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
||||
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
||||
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
||||
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
||||
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Exchange) TableName() string { return "exchanges" }
|
||||
@@ -174,8 +173,6 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
||||
return "Aster DEX", "dex"
|
||||
case "lighter":
|
||||
return "LIGHTER DEX", "dex"
|
||||
case "indodax":
|
||||
return "Indodax", "cex"
|
||||
default:
|
||||
return exchangeType + " Exchange", "cex"
|
||||
}
|
||||
@@ -184,8 +181,7 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
||||
// Create creates a new exchange account with UUID
|
||||
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
||||
apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||
asterUser, asterSigner, asterPrivateKey,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
|
||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
||||
|
||||
id := uuid.New().String()
|
||||
@@ -211,7 +207,6 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
Passphrase: crypto.EncryptedString(passphrase),
|
||||
Testnet: testnet,
|
||||
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
||||
AsterUser: asterUser,
|
||||
AsterSigner: asterSigner,
|
||||
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
||||
@@ -229,21 +224,19 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
|
||||
// Update updates exchange configuration by UUID
|
||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||
|
||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"enabled": enabled,
|
||||
"testnet": testnet,
|
||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||
"aster_user": asterUser,
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
"lighter_api_key_index": lighterApiKeyIndex,
|
||||
"updated_at": time.Now().UTC(),
|
||||
"enabled": enabled,
|
||||
"testnet": testnet,
|
||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||
"aster_user": asterUser,
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
"lighter_api_key_index": lighterApiKeyIndex,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
// Only update encrypted fields if not empty
|
||||
@@ -314,8 +307,7 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
|
||||
// Check if this is an old-style ID (exchange type as ID)
|
||||
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
|
||||
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
|
||||
hyperliquidWalletAddr, true, // Default to Unified Account mode
|
||||
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,6 @@ type GridConfigModel struct {
|
||||
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
|
||||
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
|
||||
IsActive bool `json:"is_active" gorm:"default:false"`
|
||||
|
||||
// Direction adjustment settings
|
||||
EnableDirectionAdjust bool `json:"enable_direction_adjust" gorm:"default:false"`
|
||||
DirectionBiasRatio float64 `json:"direction_bias_ratio" gorm:"default:0.7"`
|
||||
}
|
||||
|
||||
func (GridConfigModel) TableName() string {
|
||||
@@ -112,11 +108,6 @@ type GridInstanceModel struct {
|
||||
// Position adjustment due to breakout
|
||||
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced
|
||||
|
||||
// Grid direction adjustment state
|
||||
CurrentDirection string `json:"current_direction" gorm:"default:neutral"`
|
||||
DirectionChangedAt time.Time `json:"direction_changed_at"`
|
||||
DirectionChangeCount int `json:"direction_change_count" gorm:"default:0"`
|
||||
|
||||
TotalProfit float64 `json:"total_profit" gorm:"default:0"`
|
||||
TotalFees float64 `json:"total_fees" gorm:"default:0"`
|
||||
TotalTrades int `json:"total_trades" gorm:"default:0"`
|
||||
|
||||
@@ -3,63 +3,12 @@ package store
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// adaptivePriceRound rounds a price based on its magnitude to preserve meaningful precision.
|
||||
// For small prices (like meme coins), it preserves more decimal places.
|
||||
// It detects the number of decimal places needed from the reference price(s).
|
||||
func adaptivePriceRound(price float64, referencePrices ...float64) float64 {
|
||||
if price == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find the minimum magnitude among all prices (including the price itself)
|
||||
minMagnitude := math.Abs(price)
|
||||
for _, ref := range referencePrices {
|
||||
if ref > 0 && ref < minMagnitude {
|
||||
minMagnitude = ref
|
||||
}
|
||||
}
|
||||
|
||||
// Determine decimal places needed based on price magnitude
|
||||
// For price 0.000000541, we need ~15 decimal places
|
||||
// For price 0.0001, we need ~8 decimal places
|
||||
// For price 1.0, we need ~4 decimal places
|
||||
var multiplier float64
|
||||
switch {
|
||||
case minMagnitude < 0.000001: // Ultra small (meme coins like CHEEMS, SHIB)
|
||||
multiplier = 1e15 // 15 decimal places
|
||||
case minMagnitude < 0.0001: // Very small (PEPE, FLOKI)
|
||||
multiplier = 1e12 // 12 decimal places
|
||||
case minMagnitude < 0.01: // Small
|
||||
multiplier = 1e10 // 10 decimal places
|
||||
case minMagnitude < 1: // Medium
|
||||
multiplier = 1e8 // 8 decimal places
|
||||
default: // Large
|
||||
multiplier = 1e6 // 6 decimal places
|
||||
}
|
||||
|
||||
return math.Round(price*multiplier) / multiplier
|
||||
}
|
||||
|
||||
// getPriceDecimalPlaces returns the number of decimal places in a price string
|
||||
func getPriceDecimalPlaces(price float64) int {
|
||||
if price == 0 {
|
||||
return 0
|
||||
}
|
||||
s := strconv.FormatFloat(price, 'f', -1, 64)
|
||||
idx := strings.Index(s, ".")
|
||||
if idx == -1 {
|
||||
return 0
|
||||
}
|
||||
return len(s) - idx - 1
|
||||
}
|
||||
|
||||
// TraderStats trading statistics metrics
|
||||
type TraderStats struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
@@ -207,8 +156,7 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
|
||||
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
|
||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
newFee := pos.Fee + addFee
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
@@ -239,8 +187,7 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
||||
var newExitPrice float64
|
||||
if newClosedQty > 0 {
|
||||
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
||||
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||
newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice)
|
||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
@@ -147,8 +147,7 @@ func (pb *PositionBuilder) handleClose(
|
||||
var finalExitPrice float64
|
||||
if totalClosed > 0 {
|
||||
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
|
||||
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
|
||||
finalExitPrice = math.Round(finalExitPrice*100) / 100
|
||||
} else {
|
||||
finalExitPrice = price
|
||||
}
|
||||
|
||||
@@ -81,10 +81,6 @@ type GridStrategyConfig struct {
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
||||
// Use maker-only orders for lower fees
|
||||
UseMakerOnly bool `json:"use_maker_only"`
|
||||
// Enable automatic grid direction adjustment based on box breakouts
|
||||
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
|
||||
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
|
||||
DirectionBiasRatio float64 `json:"direction_bias_ratio"`
|
||||
}
|
||||
|
||||
// PromptSectionsConfig editable sections of System Prompt
|
||||
@@ -119,12 +115,6 @@ type CoinSourceConfig struct {
|
||||
UseOILow bool `json:"use_oi_low"`
|
||||
// OI Low maximum count
|
||||
OILowLimit int `json:"oi_low_limit,omitempty"`
|
||||
// whether to use Hyperliquid All coins (all available perp pairs)
|
||||
UseHyperAll bool `json:"use_hyper_all"`
|
||||
// whether to use Hyperliquid Main coins (top N by 24h volume)
|
||||
UseHyperMain bool `json:"use_hyper_main"`
|
||||
// Hyperliquid Main maximum count (default 20)
|
||||
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
|
||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -16,12 +18,24 @@ type User struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
||||
OTPSecret string `gorm:"column:otp_secret" json:"-"`
|
||||
OTPVerified bool `gorm:"column:otp_verified;default:false" json:"otp_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
// GenerateOTPSecret generates OTP secret
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
// NewUserStore creates a new UserStore
|
||||
func NewUserStore(db *gorm.DB) *UserStore {
|
||||
return &UserStore{db: db}
|
||||
@@ -40,6 +54,9 @@ func (s *UserStore) initTables() error {
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
||||
// OTP columns (added later)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_secret TEXT DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_verified BOOLEAN DEFAULT FALSE`)
|
||||
|
||||
// Ensure unique index exists on email (don't care about the name)
|
||||
var indexExists int64
|
||||
@@ -97,6 +114,11 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
|
||||
return userIDs, err
|
||||
}
|
||||
|
||||
// UpdateOTPVerified updates OTP verification status
|
||||
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
|
||||
return s.db.Model(&User{}).Where("id = ?", userID).Update("otp_verified", verified).Error
|
||||
}
|
||||
|
||||
// UpdatePassword updates password
|
||||
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
@@ -116,5 +138,7 @@ func (s *UserStore) EnsureAdmin() error {
|
||||
ID: "admin",
|
||||
Email: "admin@localhost",
|
||||
PasswordHash: "",
|
||||
OTPSecret: "",
|
||||
OTPVerified: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package aster
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package aster
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,7 +23,6 @@ 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
|
||||
@@ -1296,14 +1295,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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []types.ClosedPnLRecord
|
||||
var records []ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
@@ -1331,7 +1330,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Clos
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
records = append(records, ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -1367,7 +1366,7 @@ type AsterTradeRecord struct {
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Aster
|
||||
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
@@ -1382,24 +1381,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRe
|
||||
body, err := t.request("GET", "/fapi/v3/userTrades", params)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Aster userTrades API error: %v", err)
|
||||
return []types.TradeRecord{}, nil
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
var asterTrades []AsterTradeRecord
|
||||
if err := json.Unmarshal(body, &asterTrades); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
|
||||
return []types.TradeRecord{}, nil
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Convert to unified TradeRecord format
|
||||
var result []types.TradeRecord
|
||||
var result []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 := types.TradeRecord{
|
||||
trade := TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: at.Side,
|
||||
@@ -1417,7 +1416,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRe
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
@@ -1443,13 +1442,13 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []types.OpenOrder
|
||||
var result []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, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: order.Side,
|
||||
@@ -1467,7 +1466,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
@@ -1533,7 +1532,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Limi
|
||||
clientOrderID = cid
|
||||
}
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: clientOrderID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package aster
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -21,8 +19,8 @@ import (
|
||||
// AsterTraderTestSuite Aster trader test suite
|
||||
// Inherits TraderTestSuite and adds Aster specific mock logic
|
||||
type AsterTraderTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewAsterTraderTestSuite creates Aster test suite
|
||||
@@ -193,7 +191,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
||||
privateKey, _ := crypto.GenerateKey()
|
||||
|
||||
// Create mock trader using mock server's URL
|
||||
traderInstance := &AsterTrader{
|
||||
trader := &AsterTrader{
|
||||
ctx: context.Background(),
|
||||
user: "0x1234567890123456789012345678901234567890",
|
||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
@@ -204,7 +202,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &AsterTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -226,7 +224,7 @@ func (s *AsterTraderTestSuite) Cleanup() {
|
||||
|
||||
// TestAsterTrader_InterfaceCompliance tests interface compliance
|
||||
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*AsterTrader)(nil)
|
||||
var _ Trader = (*AsterTrader)(nil)
|
||||
}
|
||||
|
||||
// TestAsterTrader_CommonInterface runs all common interface tests using test suite
|
||||
@@ -279,21 +277,21 @@ func TestNewAsterTrader(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
at, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
|
||||
trader, 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, at)
|
||||
assert.Nil(t, trader)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4,22 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/experience"
|
||||
"nofx/kernel"
|
||||
"nofx/experience"
|
||||
"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/indodax"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -33,7 +23,7 @@ type AutoTraderConfig struct {
|
||||
AIModel string // AI model: "qwen" or "deepseek"
|
||||
|
||||
// Trading platform selection
|
||||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
|
||||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
|
||||
ExchangeID string // Exchange account UUID (for multi-account support)
|
||||
|
||||
// Binance API configuration
|
||||
@@ -45,33 +35,19 @@ type AutoTraderConfig struct {
|
||||
BybitSecretKey string
|
||||
|
||||
// OKX API configuration
|
||||
OKXAPIKey string
|
||||
OKXSecretKey string
|
||||
OKXAPIKey string
|
||||
OKXSecretKey string
|
||||
OKXPassphrase string
|
||||
|
||||
// Bitget API configuration
|
||||
BitgetAPIKey string
|
||||
BitgetSecretKey string
|
||||
BitgetAPIKey string
|
||||
BitgetSecretKey string
|
||||
BitgetPassphrase string
|
||||
|
||||
// Gate API configuration
|
||||
GateAPIKey string
|
||||
GateSecretKey string
|
||||
|
||||
// KuCoin API configuration
|
||||
KuCoinAPIKey string
|
||||
KuCoinSecretKey string
|
||||
KuCoinPassphrase string
|
||||
|
||||
// Indodax API configuration
|
||||
IndodaxAPIKey string
|
||||
IndodaxSecretKey string
|
||||
|
||||
// Hyperliquid configuration
|
||||
HyperliquidPrivateKey string
|
||||
HyperliquidWalletAddr string
|
||||
HyperliquidTestnet bool
|
||||
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
|
||||
HyperliquidPrivateKey string
|
||||
HyperliquidWalletAddr string
|
||||
HyperliquidTestnet bool
|
||||
|
||||
// Aster configuration
|
||||
AsterUser string // Aster main wallet address
|
||||
@@ -127,9 +103,9 @@ type AutoTrader struct {
|
||||
config AutoTraderConfig
|
||||
trader Trader // Use Trader interface (supports multiple platforms)
|
||||
mcpClient mcp.AIClient
|
||||
store *store.Store // Data storage (decision records, etc.)
|
||||
store *store.Store // Data storage (decision records, etc.)
|
||||
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||
cycleNumber int // Current cycle number
|
||||
cycleNumber int // Current cycle number
|
||||
initialBalance float64
|
||||
dailyPnL float64
|
||||
customPrompt string // Custom trading strategy prompt
|
||||
@@ -201,11 +177,6 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
|
||||
|
||||
case "minimax":
|
||||
mcpClient = mcp.NewMiniMaxClient()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using MiniMax AI", config.Name)
|
||||
|
||||
case "qwen":
|
||||
mcpClient = mcp.NewQwenClient()
|
||||
apiKey := config.QwenKey
|
||||
@@ -253,31 +224,25 @@ 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 = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||||
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||||
case "bybit":
|
||||
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
|
||||
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||||
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||||
case "okx":
|
||||
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
|
||||
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
||||
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
||||
case "bitget":
|
||||
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
|
||||
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 "kucoin":
|
||||
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
|
||||
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
|
||||
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
|
||||
case "hyperliquid":
|
||||
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
|
||||
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
|
||||
trader, err = 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 = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||||
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
|
||||
}
|
||||
@@ -289,7 +254,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
}
|
||||
|
||||
// Lighter only supports mainnet (testnet disabled)
|
||||
trader, err = lighter.NewLighterTraderV2(
|
||||
trader, err = NewLighterTraderV2(
|
||||
config.LighterWalletAddr,
|
||||
config.LighterAPIKeyPrivateKey,
|
||||
config.LighterAPIKeyIndex,
|
||||
@@ -299,9 +264,6 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
|
||||
}
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully")
|
||||
case "indodax":
|
||||
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
|
||||
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||||
}
|
||||
@@ -401,7 +363,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Lighter order sync if using Lighter exchange
|
||||
if at.exchange == "lighter" {
|
||||
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
|
||||
if lighterTrader, ok := at.trader.(*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)
|
||||
}
|
||||
@@ -409,7 +371,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Hyperliquid order sync if using Hyperliquid exchange
|
||||
if at.exchange == "hyperliquid" {
|
||||
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
|
||||
if hyperliquidTrader, ok := at.trader.(*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)
|
||||
}
|
||||
@@ -417,7 +379,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Bybit order sync if using Bybit exchange
|
||||
if at.exchange == "bybit" {
|
||||
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
|
||||
if bybitTrader, ok := at.trader.(*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)
|
||||
}
|
||||
@@ -425,7 +387,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start OKX order sync if using OKX exchange
|
||||
if at.exchange == "okx" {
|
||||
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
|
||||
if okxTrader, ok := at.trader.(*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)
|
||||
}
|
||||
@@ -433,7 +395,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Bitget order sync if using Bitget exchange
|
||||
if at.exchange == "bitget" {
|
||||
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
|
||||
if bitgetTrader, ok := at.trader.(*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)
|
||||
}
|
||||
@@ -441,7 +403,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Aster order sync if using Aster exchange
|
||||
if at.exchange == "aster" {
|
||||
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
|
||||
if asterTrader, ok := at.trader.(*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)
|
||||
}
|
||||
@@ -449,28 +411,12 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Binance order sync if using Binance exchange
|
||||
if at.exchange == "binance" {
|
||||
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
|
||||
if binanceTrader, ok := at.trader.(*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)
|
||||
}
|
||||
}
|
||||
|
||||
// Start KuCoin order sync if using KuCoin exchange
|
||||
if at.exchange == "kucoin" {
|
||||
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
|
||||
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -588,26 +534,15 @@ func (at *AutoTrader) runCycle() error {
|
||||
return fmt.Errorf("failed to build trading context: %w", err)
|
||||
}
|
||||
|
||||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||||
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
|
||||
at.saveEquitySnapshot(ctx)
|
||||
|
||||
// 如果没有候选币种,记录但不报错
|
||||
// 如果没有候选币种,友好提示并跳过本周期
|
||||
if len(ctx.CandidateCoins) == 0 {
|
||||
logger.Infof("ℹ️ No candidate coins available, skipping this cycle")
|
||||
record.Success = true // 不是错误,只是没有候选币
|
||||
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
|
||||
record.AccountState = store.AccountSnapshot{
|
||||
TotalBalance: ctx.Account.TotalEquity,
|
||||
AvailableBalance: ctx.Account.AvailableBalance,
|
||||
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
|
||||
PositionCount: ctx.Account.PositionCount,
|
||||
InitialBalance: at.initialBalance,
|
||||
}
|
||||
at.saveDecision(record)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||||
at.saveEquitySnapshot(ctx)
|
||||
|
||||
logger.Info(strings.Repeat("=", 70))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||||
@@ -886,19 +821,14 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
}
|
||||
|
||||
// 3. Use strategy engine to get candidate coins (must have strategy engine)
|
||||
var candidateCoins []kernel.CandidateCoin
|
||||
if at.strategyEngine == nil {
|
||||
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
|
||||
} else {
|
||||
coins, err := at.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
// Log warning but don't fail - equity snapshot should still be saved
|
||||
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
|
||||
} else {
|
||||
candidateCoins = coins
|
||||
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
|
||||
}
|
||||
return nil, fmt.Errorf("trader has no strategy engine configured")
|
||||
}
|
||||
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get candidate coins: %w", err)
|
||||
}
|
||||
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
|
||||
|
||||
// 4. Calculate total P&L
|
||||
totalPnL := totalEquity - at.initialBalance
|
||||
@@ -1120,7 +1050,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
|
||||
}
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1237,7 +1167,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
|
||||
}
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1336,7 +1266,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
|
||||
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1400,7 +1330,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
|
||||
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1996,7 +1926,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
||||
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
||||
// This ensures accurate data from GetTrades API and avoids duplicate records
|
||||
switch at.exchange {
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||||
return
|
||||
}
|
||||
@@ -2194,22 +2124,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
||||
normalizedSymbol := market.Normalize(symbol)
|
||||
|
||||
fill := &store.TraderFill{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchangeID,
|
||||
ExchangeType: at.exchange,
|
||||
OrderID: orderRecordID,
|
||||
ExchangeOrderID: exchangeOrderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: side,
|
||||
Price: price,
|
||||
Quantity: quantity,
|
||||
QuoteQuantity: price * quantity,
|
||||
Commission: fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0, // Will be calculated for close orders
|
||||
IsMaker: false, // Market orders are usually taker
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchangeID,
|
||||
ExchangeType: at.exchange,
|
||||
OrderID: orderRecordID,
|
||||
ExchangeOrderID: exchangeOrderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: side,
|
||||
Price: price,
|
||||
Quantity: quantity,
|
||||
QuoteQuantity: price * quantity,
|
||||
Commission: fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0, // Will be calculated for close orders
|
||||
IsMaker: false, // Market orders are usually taker
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
// Calculate realized PnL for close orders
|
||||
@@ -2337,3 +2267,4 @@ func getSideFromAction(action string) string {
|
||||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
return at.trader.GetOpenOrders(symbol)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,20 +65,14 @@ type GridState struct {
|
||||
|
||||
// Current regime level
|
||||
CurrentRegimeLevel string
|
||||
|
||||
// Grid direction adjustment
|
||||
CurrentDirection market.GridDirection
|
||||
DirectionChangedAt time.Time
|
||||
DirectionChangeCount int
|
||||
}
|
||||
|
||||
// NewGridState creates a new grid state
|
||||
func NewGridState(config *store.GridStrategyConfig) *GridState {
|
||||
return &GridState{
|
||||
Config: config,
|
||||
Levels: make([]kernel.GridLevelInfo, 0),
|
||||
OrderBook: make(map[string]int),
|
||||
CurrentDirection: market.GridDirectionNeutral,
|
||||
Config: config,
|
||||
Levels: make([]kernel.GridLevelInfo, 0),
|
||||
OrderBook: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,17 +325,7 @@ func (at *AutoTrader) checkBoxBreakout() error {
|
||||
}
|
||||
|
||||
// Take action based on breakout level
|
||||
// Use direction-aware action if enabled
|
||||
enableDirectionAdjust := gridConfig.EnableDirectionAdjust
|
||||
action := getBreakoutActionWithDirection(breakoutLevel, enableDirectionAdjust)
|
||||
|
||||
// If direction adjustment action, determine the new direction
|
||||
if action == BreakoutActionAdjustDirection {
|
||||
box, _ := market.GetBoxData(gridConfig.Symbol)
|
||||
newDirection := determineGridDirection(box, at.gridState.CurrentDirection, breakoutLevel, direction)
|
||||
return at.executeDirectionAdjustment(newDirection)
|
||||
}
|
||||
|
||||
action := getBreakoutAction(breakoutLevel)
|
||||
return at.executeBreakoutAction(action)
|
||||
}
|
||||
|
||||
@@ -374,38 +358,11 @@ func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
|
||||
logger.Infof("Failed to cancel orders: %v", err)
|
||||
}
|
||||
return at.closeAllPositions()
|
||||
|
||||
case BreakoutActionAdjustDirection:
|
||||
// Direction adjustment is handled separately via executeDirectionAdjustment
|
||||
// This case should not be reached, but handle gracefully
|
||||
logger.Infof("Direction adjustment action received via executeBreakoutAction")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDirectionAdjustment handles grid direction changes based on box breakout
|
||||
func (at *AutoTrader) executeDirectionAdjustment(newDirection market.GridDirection) error {
|
||||
at.gridState.mu.RLock()
|
||||
oldDirection := at.gridState.CurrentDirection
|
||||
at.gridState.mu.RUnlock()
|
||||
|
||||
if oldDirection == newDirection {
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
logger.Infof("[Grid] Direction adjustment: %s → %s", oldDirection, newDirection)
|
||||
|
||||
// Cancel existing orders before adjusting
|
||||
if err := at.cancelAllGridOrders(); err != nil {
|
||||
logger.Warnf("[Grid] Failed to cancel orders during direction adjustment: %v", err)
|
||||
}
|
||||
|
||||
// Apply the new direction
|
||||
return at.adjustGridDirection(newDirection)
|
||||
}
|
||||
|
||||
// closeAllPositions closes all open positions for the grid symbol
|
||||
func (at *AutoTrader) closeAllPositions() error {
|
||||
gridConfig := at.config.StrategyConfig.GridConfig
|
||||
@@ -453,16 +410,10 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
||||
breakoutLevel := at.gridState.BreakoutLevel
|
||||
isPaused := at.gridState.IsPaused
|
||||
positionReduction := at.gridState.PositionReductionPct
|
||||
currentDirection := at.gridState.CurrentDirection
|
||||
at.gridState.mu.RUnlock()
|
||||
|
||||
// Only check if we had a breakout or non-neutral direction
|
||||
needsRecoveryCheck := breakoutLevel != string(market.BreakoutNone) ||
|
||||
positionReduction != 0 ||
|
||||
isPaused ||
|
||||
(gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral)
|
||||
|
||||
if !needsRecoveryCheck {
|
||||
// Only check if we had a breakout
|
||||
if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -485,18 +436,6 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
||||
at.gridState.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check for direction recovery toward neutral (if direction adjustment is enabled)
|
||||
if gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral {
|
||||
if shouldRecoverDirection(box, currentDirection) {
|
||||
newDirection := determineRecoveryDirection(box.CurrentPrice, box, currentDirection)
|
||||
if newDirection != currentDirection {
|
||||
logger.Infof("[Grid] Direction recovery: %s → %s (price back in short box)",
|
||||
currentDirection, newDirection)
|
||||
at.adjustGridDirection(newDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -631,128 +570,6 @@ func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.G
|
||||
}
|
||||
|
||||
at.gridState.Levels = levels
|
||||
|
||||
// Apply direction-based side assignment if enabled
|
||||
if config.EnableDirectionAdjust {
|
||||
at.applyGridDirection(currentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// applyGridDirection adjusts grid level sides based on the current direction
|
||||
// This redistributes buy/sell levels according to the direction bias ratio
|
||||
func (at *AutoTrader) applyGridDirection(currentPrice float64) {
|
||||
config := at.gridState.Config
|
||||
direction := at.gridState.CurrentDirection
|
||||
|
||||
// Get bias ratio from config, default to 0.7 (70%/30%)
|
||||
biasRatio := config.DirectionBiasRatio
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7
|
||||
}
|
||||
|
||||
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
|
||||
|
||||
// Calculate how many levels should be buy vs sell based on direction
|
||||
totalLevels := len(at.gridState.Levels)
|
||||
targetBuyLevels := int(float64(totalLevels) * buyRatio)
|
||||
|
||||
// For neutral: use price-based assignment (buy below, sell above)
|
||||
if direction == market.GridDirectionNeutral {
|
||||
for i := range at.gridState.Levels {
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For long/long_bias: more buy levels
|
||||
// For short/short_bias: more sell levels
|
||||
switch direction {
|
||||
case market.GridDirectionLong:
|
||||
// 100% buy - all levels are buy
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
}
|
||||
|
||||
case market.GridDirectionShort:
|
||||
// 100% sell - all levels are sell
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
|
||||
case market.GridDirectionLongBias, market.GridDirectionShortBias:
|
||||
// Assign sides based on position relative to current price
|
||||
// For long_bias: keep all below as buy, convert some above to buy
|
||||
// For short_bias: keep all above as sell, convert some below to sell
|
||||
buyCount := 0
|
||||
sellCount := 0
|
||||
|
||||
for i := range at.gridState.Levels {
|
||||
needMoreBuys := buyCount < targetBuyLevels
|
||||
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
|
||||
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
// Level below or at current price
|
||||
if needMoreBuys {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
}
|
||||
} else {
|
||||
// Level above current price
|
||||
if needMoreSells && direction == market.GridDirectionShortBias {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else if needMoreBuys && direction == market.GridDirectionLongBias {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else if needMoreSells {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("[Grid] Applied direction %s: buy_ratio=%.0f%%, levels reconfigured",
|
||||
direction, buyRatio*100)
|
||||
}
|
||||
|
||||
// adjustGridDirection handles runtime direction adjustment when breakout is detected
|
||||
func (at *AutoTrader) adjustGridDirection(newDirection market.GridDirection) error {
|
||||
at.gridState.mu.Lock()
|
||||
defer at.gridState.mu.Unlock()
|
||||
|
||||
oldDirection := at.gridState.CurrentDirection
|
||||
if oldDirection == newDirection {
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
at.gridState.CurrentDirection = newDirection
|
||||
at.gridState.DirectionChangedAt = time.Now()
|
||||
at.gridState.DirectionChangeCount++
|
||||
|
||||
logger.Infof("[Grid] Direction changed: %s → %s (change count: %d)",
|
||||
oldDirection, newDirection, at.gridState.DirectionChangeCount)
|
||||
|
||||
// Get current price for recalculation
|
||||
currentPrice, err := at.trader.GetMarketPrice(at.gridState.Config.Symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
// Reapply direction to grid levels
|
||||
at.applyGridDirection(currentPrice)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunGridCycle executes one grid trading cycle
|
||||
@@ -1553,85 +1370,6 @@ func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *s
|
||||
}
|
||||
|
||||
at.gridState.Levels = levels
|
||||
|
||||
// Apply direction-based side assignment if enabled (note: caller holds lock)
|
||||
if config.EnableDirectionAdjust {
|
||||
at.applyGridDirectionLocked(currentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// applyGridDirectionLocked adjusts grid level sides based on the current direction (caller must hold lock)
|
||||
func (at *AutoTrader) applyGridDirectionLocked(currentPrice float64) {
|
||||
config := at.gridState.Config
|
||||
direction := at.gridState.CurrentDirection
|
||||
|
||||
// Get bias ratio from config, default to 0.7 (70%/30%)
|
||||
biasRatio := config.DirectionBiasRatio
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7
|
||||
}
|
||||
|
||||
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
|
||||
|
||||
// For neutral: use price-based assignment (buy below, sell above)
|
||||
if direction == market.GridDirectionNeutral {
|
||||
for i := range at.gridState.Levels {
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
totalLevels := len(at.gridState.Levels)
|
||||
targetBuyLevels := int(float64(totalLevels) * buyRatio)
|
||||
|
||||
switch direction {
|
||||
case market.GridDirectionLong:
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
}
|
||||
|
||||
case market.GridDirectionShort:
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
|
||||
case market.GridDirectionLongBias, market.GridDirectionShortBias:
|
||||
buyCount := 0
|
||||
sellCount := 0
|
||||
|
||||
for i := range at.gridState.Levels {
|
||||
needMoreBuys := buyCount < targetBuyLevels
|
||||
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
|
||||
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
if needMoreBuys {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
}
|
||||
} else {
|
||||
if needMoreSells && direction == market.GridDirectionShortBias {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else if needMoreBuys && direction == market.GridDirectionLongBias {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else if needMoreSells {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GridRiskInfo contains risk information for frontend display
|
||||
@@ -1659,11 +1397,6 @@ type GridRiskInfo struct {
|
||||
|
||||
BreakoutLevel string `json:"breakout_level"`
|
||||
BreakoutDirection string `json:"breakout_direction"`
|
||||
|
||||
// Grid direction
|
||||
CurrentGridDirection string `json:"current_grid_direction"`
|
||||
DirectionChangeCount int `json:"direction_change_count"`
|
||||
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
|
||||
}
|
||||
|
||||
// GetGridRiskInfo returns current risk information for frontend display
|
||||
@@ -1780,10 +1513,6 @@ func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
|
||||
|
||||
BreakoutLevel: at.gridState.BreakoutLevel,
|
||||
BreakoutDirection: at.gridState.BreakoutDirection,
|
||||
|
||||
CurrentGridDirection: string(at.gridState.CurrentDirection),
|
||||
DirectionChangeCount: at.gridState.DirectionChangeCount,
|
||||
EnableDirectionAdjust: gridConfig.EnableDirectionAdjust,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"nofx/hook"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -719,7 +718,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 *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity to correct precision
|
||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
@@ -771,7 +770,7 @@ func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Li
|
||||
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
ClientID: order.ClientOrderID,
|
||||
Symbol: order.Symbol,
|
||||
@@ -897,8 +896,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
var result []types.OpenOrder
|
||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get legacy open orders
|
||||
orders, err := t.client.NewListOpenOrdersService().
|
||||
@@ -914,7 +913,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
@@ -937,7 +936,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error)
|
||||
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
||||
Symbol: algoOrder.Symbol,
|
||||
Side: string(algoOrder.Side),
|
||||
@@ -1248,14 +1247,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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]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 []types.ClosedPnLRecord
|
||||
var records []ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue // Skip opening trades
|
||||
@@ -1284,7 +1283,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Cl
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
records = append(records, ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -1305,7 +1304,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Cl
|
||||
|
||||
// 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) ([]types.TradeRecord, error) {
|
||||
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1323,7 +1322,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
|
||||
return nil, fmt.Errorf("failed to get income history: %w", err)
|
||||
}
|
||||
|
||||
var trades []types.TradeRecord
|
||||
var trades []TradeRecord
|
||||
for _, income := range incomes {
|
||||
pnl, _ := strconv.ParseFloat(income.Income, 64)
|
||||
if pnl == 0 {
|
||||
@@ -1332,7 +1331,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
|
||||
|
||||
// Income API doesn't provide full trade details, create a minimal record
|
||||
// This is mainly used for detecting recent closures, not historical reconstruction
|
||||
trade := types.TradeRecord{
|
||||
trade := TradeRecord{
|
||||
TradeID: strconv.FormatInt(income.TranID, 10),
|
||||
Symbol: income.Symbol,
|
||||
RealizedPnL: pnl,
|
||||
@@ -1348,7 +1347,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
|
||||
|
||||
// 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) ([]types.TradeRecord, error) {
|
||||
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1365,14 +1364,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 []types.TradeRecord
|
||||
var trades []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 := types.TradeRecord{
|
||||
trade := TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: string(at.Side),
|
||||
@@ -1391,7 +1390,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) ([]types.TradeRecord, error) {
|
||||
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1408,14 +1407,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 []types.TradeRecord
|
||||
var trades []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 := types.TradeRecord{
|
||||
trade := TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: string(at.Side),
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
|
||||
"github.com/adshao/go-binance/v2/futures"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -22,8 +20,8 @@ import (
|
||||
// BinanceFuturesTestSuite Binance Futures trader test suite
|
||||
// Inherits TraderTestSuite and adds Binance Futures specific mock logic
|
||||
type BinanceFuturesTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewBinanceFuturesTestSuite Creates Binance Futures test suite
|
||||
@@ -272,13 +270,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
|
||||
client.HTTPClient = mockServer.Client()
|
||||
|
||||
// Create FuturesTrader
|
||||
traderInstance := &FuturesTrader{
|
||||
trader := &FuturesTrader{
|
||||
client: client,
|
||||
cacheDuration: 0, // disable cache for testing
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &BinanceFuturesTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -300,7 +298,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() {
|
||||
|
||||
// TestFuturesTrader_InterfaceCompliance tests interface compliance
|
||||
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*FuturesTrader)(nil)
|
||||
var _ Trader = (*FuturesTrader)(nil)
|
||||
}
|
||||
|
||||
// TestFuturesTrader_CommonInterface runs all common interface tests using test suite
|
||||
@@ -345,20 +343,20 @@ func TestNewFuturesTrader(t *testing.T) {
|
||||
defer mockServer.Close()
|
||||
|
||||
// Test successful creation
|
||||
t1 := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
||||
trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
||||
|
||||
// Modify client to use mock server
|
||||
t1.client.BaseURL = mockServer.URL
|
||||
t1.client.HTTPClient = mockServer.Client()
|
||||
trader.client.BaseURL = mockServer.URL
|
||||
trader.client.HTTPClient = mockServer.Client()
|
||||
|
||||
assert.NotNil(t, t1)
|
||||
assert.NotNil(t, t1.client)
|
||||
assert.Equal(t, 15*time.Second, t1.cacheDuration)
|
||||
assert.NotNil(t, trader)
|
||||
assert.NotNil(t, trader.client)
|
||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
||||
}
|
||||
|
||||
// TestCalculatePositionSize tests position size calculation
|
||||
func TestCalculatePositionSize(t *testing.T) {
|
||||
ft := &FuturesTrader{}
|
||||
trader := &FuturesTrader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -396,7 +394,7 @@ func TestCalculatePositionSize(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
quantity := ft.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
||||
quantity := trader.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
||||
assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect")
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"nofx/trader/types"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -127,11 +126,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 []types.TradeRecord
|
||||
var allTrades []TradeRecord
|
||||
var failedSymbols []string
|
||||
apiCalls := 0
|
||||
for _, symbol := range changedSymbols {
|
||||
var trades []types.TradeRecord
|
||||
var trades []TradeRecord
|
||||
var queryErr error
|
||||
|
||||
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/store"
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package bitget
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -48,82 +48,52 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
||||
return nil, fmt.Errorf("failed to get fill history: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Bitget fill structure - supports both one-way and hedge mode
|
||||
type BitgetFill struct {
|
||||
TradeID string `json:"tradeId"`
|
||||
Symbol string `json:"symbol"`
|
||||
OrderID string `json:"orderId"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Price string `json:"price"` // Fill price
|
||||
BaseVolume string `json:"baseVolume"` // Fill size in base currency
|
||||
Profit string `json:"profit"` // Realized PnL
|
||||
CTime string `json:"cTime"` // Fill time (ms)
|
||||
TradeSide string `json:"tradeSide"` // one-way: buy_single/sell_single, hedge: open/close
|
||||
FeeDetail []struct {
|
||||
FeeCoin string `json:"feeCoin"`
|
||||
TotalFee string `json:"totalFee"`
|
||||
} `json:"feeDetail"`
|
||||
var resp struct {
|
||||
FillList []struct {
|
||||
TradeID string `json:"tradeId"`
|
||||
Symbol string `json:"symbol"`
|
||||
OrderID string `json:"orderId"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Price string `json:"price"` // Fill price
|
||||
BaseVolume string `json:"baseVolume"` // Fill size in base currency
|
||||
Fee string `json:"fee"` // Fee (negative for cost)
|
||||
FeeCcy string `json:"feeCcy"` // Fee currency
|
||||
Profit string `json:"profit"` // Realized PnL
|
||||
CTime string `json:"cTime"` // Fill time (ms)
|
||||
TradeSide string `json:"tradeSide"` // open, close
|
||||
} `json:"fillList"`
|
||||
}
|
||||
|
||||
// Try parsing as wrapped response first (fillList field)
|
||||
var wrappedResp struct {
|
||||
FillList []BitgetFill `json:"fillList"`
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse fills: %w", err)
|
||||
}
|
||||
|
||||
// Try direct array format (Bitget V2 API returns data as direct array)
|
||||
var directFills []BitgetFill
|
||||
trades := make([]BitgetTrade, 0, len(resp.FillList))
|
||||
|
||||
// Try wrapped format first
|
||||
if err := json.Unmarshal(data, &wrappedResp); err == nil && len(wrappedResp.FillList) > 0 {
|
||||
logger.Infof("🔍 Bitget: parsed as wrapped format, fillList count: %d", len(wrappedResp.FillList))
|
||||
directFills = wrappedResp.FillList
|
||||
} else {
|
||||
// Try direct array format
|
||||
if err := json.Unmarshal(data, &directFills); err != nil {
|
||||
logger.Infof("⚠️ Bitget fill-history parse failed, raw: %s", string(data))
|
||||
return nil, fmt.Errorf("failed to parse fills: %w", err)
|
||||
}
|
||||
logger.Infof("🔍 Bitget: parsed as direct array, fills count: %d", len(directFills))
|
||||
}
|
||||
|
||||
trades := make([]BitgetTrade, 0, len(directFills))
|
||||
|
||||
for _, fill := range directFills {
|
||||
for _, fill := range resp.FillList {
|
||||
fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
|
||||
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
|
||||
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
||||
profit, _ := strconv.ParseFloat(fill.Profit, 64)
|
||||
cTime, _ := strconv.ParseInt(fill.CTime, 10, 64)
|
||||
|
||||
// Extract fee from feeDetail array (Bitget V2 API)
|
||||
var fee float64
|
||||
var feeAsset string
|
||||
if len(fill.FeeDetail) > 0 {
|
||||
fee, _ = strconv.ParseFloat(fill.FeeDetail[0].TotalFee, 64)
|
||||
feeAsset = fill.FeeDetail[0].FeeCoin
|
||||
}
|
||||
|
||||
// Determine order action based on side and tradeSide
|
||||
// Bitget one-way mode: buy_single (open long), sell_single (close long)
|
||||
// Bitget hedge mode: open + buy = open_long, close + sell = close_long
|
||||
// Bitget one-way mode:
|
||||
// - buy + open = open long
|
||||
// - sell + open = open short
|
||||
// - sell + close = close long
|
||||
// - buy + close = close short
|
||||
orderAction := "open_long"
|
||||
side := strings.ToLower(fill.Side)
|
||||
tradeSide := strings.ToLower(fill.TradeSide)
|
||||
|
||||
// One-way position mode (buy_single/sell_single)
|
||||
if tradeSide == "buy_single" {
|
||||
orderAction = "open_long"
|
||||
} else if tradeSide == "sell_single" {
|
||||
orderAction = "close_long"
|
||||
} else if tradeSide == "open" {
|
||||
// Hedge mode: open
|
||||
if tradeSide == "open" {
|
||||
if side == "buy" {
|
||||
orderAction = "open_long"
|
||||
} else {
|
||||
orderAction = "open_short"
|
||||
}
|
||||
} else if tradeSide == "close" {
|
||||
// Hedge mode: close
|
||||
if side == "sell" {
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
@@ -138,8 +108,8 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
||||
Side: fill.Side,
|
||||
FillPrice: fillPrice,
|
||||
FillQty: fillQty,
|
||||
Fee: -fee, // Bitget returns negative fee, convert to positive
|
||||
FeeAsset: feeAsset,
|
||||
Fee: -fee, // Bitget returns negative fee
|
||||
FeeAsset: fill.FeeCcy,
|
||||
ExecTime: time.UnixMilli(cTime).UTC(),
|
||||
ProfitLoss: profit,
|
||||
OrderType: "MARKET",
|
||||
@@ -1,4 +1,4 @@
|
||||
package bitget
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// Bitget API endpoints (V2)
|
||||
@@ -1014,7 +1013,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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1052,9 +1051,9 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Clo
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
records := make([]types.ClosedPnLRecord, 0, len(resp.List))
|
||||
records := make([]ClosedPnLRecord, 0, len(resp.List))
|
||||
for _, pos := range resp.List {
|
||||
record := types.ClosedPnLRecord{
|
||||
record := ClosedPnLRecord{
|
||||
Symbol: pos.Symbol,
|
||||
Side: pos.HoldSide,
|
||||
}
|
||||
@@ -1099,9 +1098,9 @@ func genBitgetClientOid() string {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
var result []types.OpenOrder
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
params := map[string]interface{}{
|
||||
@@ -1136,7 +1135,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -1152,10 +1151,9 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
}
|
||||
|
||||
// 2. Get pending plan orders (stop-loss/take-profit)
|
||||
// Bitget V2 API requires planType parameter: profit_loss for SL/TP orders
|
||||
planParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"planType": "profit_loss",
|
||||
}
|
||||
|
||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||
@@ -1165,53 +1163,33 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
if err == nil && planData != nil {
|
||||
var planOrders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
PlanType string `json:"planType"` // pos_loss, pos_profit
|
||||
TriggerPrice string `json:"triggerPrice"`
|
||||
StopLossTriggerPrice string `json:"stopLossTriggerPrice"`
|
||||
StopSurplusTriggerPrice string `json:"stopSurplusTriggerPrice"`
|
||||
Size string `json:"size"`
|
||||
PlanStatus string `json:"planStatus"`
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
|
||||
TriggerPrice string `json:"triggerPrice"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
||||
for _, order := range planOrders.EntrustedList {
|
||||
// Filter by symbol if specified
|
||||
if symbol != "" && order.Symbol != symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine trigger price based on plan type
|
||||
var triggerPrice float64
|
||||
orderType := "STOP_MARKET"
|
||||
|
||||
if order.PlanType == "pos_profit" {
|
||||
// Take profit order
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
if order.StopSurplusTriggerPrice != "" {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.StopSurplusTriggerPrice, 64)
|
||||
} else {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
}
|
||||
} else {
|
||||
// Stop loss order (pos_loss)
|
||||
if order.StopLossTriggerPrice != "" {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.StopLossTriggerPrice, 64)
|
||||
} else {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
}
|
||||
}
|
||||
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
// Map Bitget plan type to order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.PlanType == "profit_plan" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: order.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
@@ -1230,7 +1208,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
symbol := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Set leverage if specified
|
||||
@@ -1286,7 +1264,7 @@ func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Lim
|
||||
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
symbol, side, req.Price, order.OrderId)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: order.OrderId,
|
||||
ClientID: order.ClientOid,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package bybit
|
||||
package trader
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
@@ -1,4 +1,4 @@
|
||||
package bybit
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
bybit "github.com/bybit-exchange/bybit.go.api"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// BybitTrader Bybit USDT Perpetual Futures Trader
|
||||
@@ -901,13 +900,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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]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
|
||||
@@ -968,14 +967,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]typ
|
||||
}
|
||||
|
||||
// parseClosedPnLResult parses the closed PnL result from Bybit API
|
||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
|
||||
data, ok := resultData.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result format")
|
||||
}
|
||||
|
||||
list, _ := data["list"].([]interface{})
|
||||
var records []types.ClosedPnLRecord
|
||||
var records []ClosedPnLRecord
|
||||
|
||||
for _, item := range list {
|
||||
pnl, ok := item.(map[string]interface{})
|
||||
@@ -1024,7 +1023,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.Clos
|
||||
normalizedSide = "short"
|
||||
}
|
||||
|
||||
record := types.ClosedPnLRecord{
|
||||
record := ClosedPnLRecord{
|
||||
Symbol: symbol,
|
||||
Side: normalizedSide,
|
||||
EntryPrice: avgEntryPrice,
|
||||
@@ -1047,8 +1046,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.Clos
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
var result []types.OpenOrder
|
||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
|
||||
// Get conditional orders (stop-loss, take-profit)
|
||||
params := map[string]interface{}{
|
||||
@@ -1089,7 +1088,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
displayType = stopOrderType
|
||||
}
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: orderId,
|
||||
Symbol: sym,
|
||||
Side: side,
|
||||
@@ -1109,7 +1108,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity
|
||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
@@ -1170,7 +1169,7 @@ func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Limi
|
||||
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package bybit
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -20,8 +18,8 @@ import (
|
||||
// BybitTraderTestSuite Bybit trader test suite
|
||||
// Inherits TraderTestSuite and adds Bybit-specific mock logic
|
||||
type BybitTraderTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewBybitTraderTestSuite Create Bybit test suite
|
||||
@@ -68,10 +66,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
|
||||
}))
|
||||
|
||||
// Create real Bybit trader (for interface compliance testing)
|
||||
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
|
||||
trader := NewBybitTrader("test_api_key", "test_secret_key")
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &BybitTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -93,7 +91,7 @@ func (s *BybitTraderTestSuite) Cleanup() {
|
||||
|
||||
// TestBybitTrader_InterfaceCompliance Test interface compliance
|
||||
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*BybitTrader)(nil)
|
||||
var _ Trader = (*BybitTrader)(nil)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -130,13 +128,13 @@ func TestNewBybitTrader(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
|
||||
trader := NewBybitTrader(tt.apiKey, tt.secretKey)
|
||||
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, bt)
|
||||
assert.Nil(t, trader)
|
||||
} else {
|
||||
assert.NotNil(t, bt)
|
||||
assert.NotNil(t, bt.client)
|
||||
assert.NotNil(t, trader)
|
||||
assert.NotNil(t, trader.client)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -178,7 +176,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) {
|
||||
|
||||
// TestBybitTrader_FormatQuantity Test quantity formatting
|
||||
func TestBybitTrader_FormatQuantity(t *testing.T) {
|
||||
bt := NewBybitTrader("test", "test")
|
||||
trader := NewBybitTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -212,7 +210,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
|
||||
result, err := trader.FormatQuantity(tt.symbol, tt.quantity)
|
||||
if tt.hasError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
@@ -337,19 +335,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)
|
||||
bt := NewBybitTrader("test", "test")
|
||||
assert.NotNil(t, bt)
|
||||
trader := NewBybitTrader("test", "test")
|
||||
assert.NotNil(t, trader)
|
||||
|
||||
// Verify default configuration
|
||||
assert.NotNil(t, bt.client)
|
||||
assert.NotNil(t, trader.client)
|
||||
}
|
||||
|
||||
// TestBybitTrader_CacheDuration Test cache duration
|
||||
func TestBybitTrader_CacheDuration(t *testing.T) {
|
||||
bt := NewBybitTrader("test", "test")
|
||||
trader := NewBybitTrader("test", "test")
|
||||
|
||||
// Verify default cache time is 15 seconds
|
||||
assert.Equal(t, 15*time.Second, bt.cacheDuration)
|
||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1,304 +0,0 @@
|
||||
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, err := strconv.ParseFloat(trade.Price, 64)
|
||||
if err != nil || fillPrice == 0 {
|
||||
logger.Infof("⚠️ Gate trade %d: fillPrice parse issue - raw='%s' parsed=%.8f err=%v",
|
||||
trade.Id, trade.Price, fillPrice, err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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"
|
||||
}
|
||||
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
|
||||
// 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 {
|
||||
// Order exists, but still try to update position for close trades
|
||||
// This handles the case where order was created but position update failed
|
||||
if strings.HasPrefix(trade.OrderAction, "close_") && trade.FillPrice > 0 {
|
||||
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(" ⚠️ Retry position update for existing trade %s failed: %v", trade.TradeID, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
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
|
||||
// Debug: Log the price being passed to ensure it's not 0
|
||||
if trade.FillPrice <= 0 {
|
||||
logger.Infof(" ⚠️ WARNING: trade %s has FillPrice=%.10f (invalid), skipping position update", trade.TradeID, trade.FillPrice)
|
||||
} else {
|
||||
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, price: %.10f)", trade.TradeID, trade.OrderAction, trade.FillQty, trade.FillPrice)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,898 +0,0 @@
|
||||
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()
|
||||
config.AddDefaultHeader("X-Gate-Channel-Id", "nofx")
|
||||
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)
|
||||
@@ -1,337 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -194,119 +194,3 @@ func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 10: Grid Direction Adjustment
|
||||
// ============================================================================
|
||||
|
||||
const (
|
||||
// BreakoutActionAdjustDirection adjusts grid direction based on breakout
|
||||
BreakoutActionAdjustDirection BreakoutAction = 4
|
||||
)
|
||||
|
||||
// determineGridDirection determines the new grid direction based on box breakout
|
||||
// currentDirection: the current grid direction
|
||||
// breakoutLevel: which box level has been broken (short/mid/long)
|
||||
// direction: breakout direction ("up" or "down")
|
||||
// Returns: the new grid direction
|
||||
func determineGridDirection(box *market.BoxData, currentDirection market.GridDirection, breakoutLevel market.BreakoutLevel, direction string) market.GridDirection {
|
||||
if box == nil {
|
||||
return currentDirection
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
|
||||
switch breakoutLevel {
|
||||
case market.BreakoutShort:
|
||||
// Short box breakout: bias direction
|
||||
// Still within mid box, so not a full trend yet
|
||||
if direction == "up" {
|
||||
return market.GridDirectionLongBias
|
||||
}
|
||||
return market.GridDirectionShortBias
|
||||
|
||||
case market.BreakoutMid:
|
||||
// Mid box breakout: full direction
|
||||
// More significant move, commit fully
|
||||
if direction == "up" {
|
||||
return market.GridDirectionLong
|
||||
}
|
||||
return market.GridDirectionShort
|
||||
|
||||
case market.BreakoutLong:
|
||||
// Long box breakout: handled by existing emergency logic
|
||||
// Return current direction, let existing handlers take over
|
||||
return currentDirection
|
||||
|
||||
case market.BreakoutNone:
|
||||
// No breakout - check if we should recover toward neutral
|
||||
return determineRecoveryDirection(price, box, currentDirection)
|
||||
|
||||
default:
|
||||
return currentDirection
|
||||
}
|
||||
}
|
||||
|
||||
// determineRecoveryDirection determines if grid direction should recover toward neutral
|
||||
// This implements the gradual recovery logic: long → long_bias → neutral ← short_bias ← short
|
||||
func determineRecoveryDirection(price float64, box *market.BoxData, currentDirection market.GridDirection) market.GridDirection {
|
||||
// Check if price is back inside the short box
|
||||
insideShortBox := price >= box.ShortLower && price <= box.ShortUpper
|
||||
|
||||
if !insideShortBox {
|
||||
// Still outside short box, maintain current direction
|
||||
return currentDirection
|
||||
}
|
||||
|
||||
// Price is inside short box, start recovery toward neutral
|
||||
switch currentDirection {
|
||||
case market.GridDirectionLong:
|
||||
// Full long → bias long
|
||||
return market.GridDirectionLongBias
|
||||
case market.GridDirectionLongBias:
|
||||
// Bias long → neutral
|
||||
return market.GridDirectionNeutral
|
||||
case market.GridDirectionShort:
|
||||
// Full short → bias short
|
||||
return market.GridDirectionShortBias
|
||||
case market.GridDirectionShortBias:
|
||||
// Bias short → neutral
|
||||
return market.GridDirectionNeutral
|
||||
default:
|
||||
return currentDirection
|
||||
}
|
||||
}
|
||||
|
||||
// getBreakoutActionWithDirection returns the appropriate action for a breakout level
|
||||
// when direction adjustment is enabled
|
||||
func getBreakoutActionWithDirection(level market.BreakoutLevel, enableDirectionAdjust bool) BreakoutAction {
|
||||
if !enableDirectionAdjust {
|
||||
// Fall back to original behavior
|
||||
return getBreakoutAction(level)
|
||||
}
|
||||
|
||||
switch level {
|
||||
case market.BreakoutShort:
|
||||
// Short box breakout with direction adjustment: adjust direction instead of reducing position
|
||||
return BreakoutActionAdjustDirection
|
||||
case market.BreakoutMid:
|
||||
// Mid box breakout with direction adjustment: adjust to full direction
|
||||
return BreakoutActionAdjustDirection
|
||||
case market.BreakoutLong:
|
||||
// Long box breakout: always trigger emergency handling
|
||||
return BreakoutActionCloseAll
|
||||
default:
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// shouldRecoverDirection checks if the current grid direction should start recovering toward neutral
|
||||
func shouldRecoverDirection(box *market.BoxData, currentDirection market.GridDirection) bool {
|
||||
if box == nil || currentDirection == market.GridDirectionNeutral {
|
||||
return false
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
// Check if price is back inside the short box
|
||||
return price >= box.ShortLower && price <= box.ShortUpper
|
||||
}
|
||||
|
||||
@@ -120,223 +120,3 @@ func TestGetBreakoutAction(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Direction Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetBuySellRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
direction market.GridDirection
|
||||
biasRatio float64
|
||||
wantBuy float64
|
||||
wantSell float64
|
||||
}{
|
||||
{"neutral", market.GridDirectionNeutral, 0.7, 0.5, 0.5},
|
||||
{"long", market.GridDirectionLong, 0.7, 1.0, 0.0},
|
||||
{"short", market.GridDirectionShort, 0.7, 0.0, 1.0},
|
||||
{"long_bias_default", market.GridDirectionLongBias, 0.7, 0.7, 0.3},
|
||||
{"short_bias_default", market.GridDirectionShortBias, 0.7, 0.3, 0.7},
|
||||
{"long_bias_custom", market.GridDirectionLongBias, 0.8, 0.8, 0.2},
|
||||
{"short_bias_custom", market.GridDirectionShortBias, 0.8, 0.2, 0.8},
|
||||
{"invalid_bias_uses_default", market.GridDirectionLongBias, 0, 0.7, 0.3},
|
||||
{"negative_bias_uses_default", market.GridDirectionLongBias, -1, 0.7, 0.3},
|
||||
}
|
||||
|
||||
const tolerance = 0.0001
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buy, sell := tt.direction.GetBuySellRatio(tt.biasRatio)
|
||||
buyDiff := buy - tt.wantBuy
|
||||
sellDiff := sell - tt.wantSell
|
||||
if buyDiff < -tolerance || buyDiff > tolerance || sellDiff < -tolerance || sellDiff > tolerance {
|
||||
t.Errorf("GetBuySellRatio(%v, %v) = (%v, %v), want (%v, %v)",
|
||||
tt.direction, tt.biasRatio, buy, sell, tt.wantBuy, tt.wantSell)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineGridDirection(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
currentDirection market.GridDirection
|
||||
breakoutLevel market.BreakoutLevel
|
||||
direction string
|
||||
expected market.GridDirection
|
||||
}{
|
||||
// Short box breakouts
|
||||
{
|
||||
name: "short_breakout_up_neutral",
|
||||
currentDirection: market.GridDirectionNeutral,
|
||||
breakoutLevel: market.BreakoutShort,
|
||||
direction: "up",
|
||||
expected: market.GridDirectionLongBias,
|
||||
},
|
||||
{
|
||||
name: "short_breakout_down_neutral",
|
||||
currentDirection: market.GridDirectionNeutral,
|
||||
breakoutLevel: market.BreakoutShort,
|
||||
direction: "down",
|
||||
expected: market.GridDirectionShortBias,
|
||||
},
|
||||
// Mid box breakouts
|
||||
{
|
||||
name: "mid_breakout_up",
|
||||
currentDirection: market.GridDirectionLongBias,
|
||||
breakoutLevel: market.BreakoutMid,
|
||||
direction: "up",
|
||||
expected: market.GridDirectionLong,
|
||||
},
|
||||
{
|
||||
name: "mid_breakout_down",
|
||||
currentDirection: market.GridDirectionShortBias,
|
||||
breakoutLevel: market.BreakoutMid,
|
||||
direction: "down",
|
||||
expected: market.GridDirectionShort,
|
||||
},
|
||||
// Long box breakout - maintains current (emergency handling)
|
||||
{
|
||||
name: "long_breakout_maintains",
|
||||
currentDirection: market.GridDirectionLong,
|
||||
breakoutLevel: market.BreakoutLong,
|
||||
direction: "up",
|
||||
expected: market.GridDirectionLong,
|
||||
},
|
||||
// No breakout - tests recovery logic
|
||||
{
|
||||
name: "no_breakout_neutral_stays",
|
||||
currentDirection: market.GridDirectionNeutral,
|
||||
breakoutLevel: market.BreakoutNone,
|
||||
direction: "",
|
||||
expected: market.GridDirectionNeutral,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := determineGridDirection(box, tt.currentDirection, tt.breakoutLevel, tt.direction)
|
||||
if result != tt.expected {
|
||||
t.Errorf("determineGridDirection() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineRecoveryDirection(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95, // Inside short box
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price float64
|
||||
currentDirection market.GridDirection
|
||||
expected market.GridDirection
|
||||
}{
|
||||
// Inside short box - should recover
|
||||
{"long_to_long_bias", 95, market.GridDirectionLong, market.GridDirectionLongBias},
|
||||
{"long_bias_to_neutral", 95, market.GridDirectionLongBias, market.GridDirectionNeutral},
|
||||
{"short_to_short_bias", 95, market.GridDirectionShort, market.GridDirectionShortBias},
|
||||
{"short_bias_to_neutral", 95, market.GridDirectionShortBias, market.GridDirectionNeutral},
|
||||
{"neutral_stays_neutral", 95, market.GridDirectionNeutral, market.GridDirectionNeutral},
|
||||
|
||||
// Outside short box - should maintain
|
||||
{"long_outside_stays", 101, market.GridDirectionLong, market.GridDirectionLong},
|
||||
{"short_outside_stays", 89, market.GridDirectionShort, market.GridDirectionShort},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := determineRecoveryDirection(tt.price, box, tt.currentDirection)
|
||||
if result != tt.expected {
|
||||
t.Errorf("determineRecoveryDirection(%v, %v) = %v, want %v",
|
||||
tt.price, tt.currentDirection, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBreakoutActionWithDirection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level market.BreakoutLevel
|
||||
enableDirectionAdjust bool
|
||||
expected BreakoutAction
|
||||
}{
|
||||
// Direction adjustment disabled - original behavior
|
||||
{"short_disabled", market.BreakoutShort, false, BreakoutActionReducePosition},
|
||||
{"mid_disabled", market.BreakoutMid, false, BreakoutActionPauseGrid},
|
||||
{"long_disabled", market.BreakoutLong, false, BreakoutActionCloseAll},
|
||||
|
||||
// Direction adjustment enabled
|
||||
{"short_enabled", market.BreakoutShort, true, BreakoutActionAdjustDirection},
|
||||
{"mid_enabled", market.BreakoutMid, true, BreakoutActionAdjustDirection},
|
||||
{"long_enabled", market.BreakoutLong, true, BreakoutActionCloseAll}, // Long always triggers emergency
|
||||
{"none_enabled", market.BreakoutNone, true, BreakoutActionNone},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action := getBreakoutActionWithDirection(tt.level, tt.enableDirectionAdjust)
|
||||
if action != tt.expected {
|
||||
t.Errorf("getBreakoutActionWithDirection(%v, %v) = %v, want %v",
|
||||
tt.level, tt.enableDirectionAdjust, action, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRecoverDirection(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price float64
|
||||
direction market.GridDirection
|
||||
expected bool
|
||||
}{
|
||||
{"neutral_inside_no_recovery", 95, market.GridDirectionNeutral, false},
|
||||
{"long_inside_should_recover", 95, market.GridDirectionLong, true},
|
||||
{"long_outside_no_recovery", 101, market.GridDirectionLong, false},
|
||||
{"short_inside_should_recover", 95, market.GridDirectionShort, true},
|
||||
{"short_outside_no_recovery", 89, market.GridDirectionShort, false},
|
||||
{"long_bias_inside_should_recover", 95, market.GridDirectionLongBias, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
box.CurrentPrice = tt.price
|
||||
result := shouldRecoverDirection(box, tt.direction)
|
||||
if result != tt.expected {
|
||||
t.Errorf("shouldRecoverDirection(price=%v, %v) = %v, want %v",
|
||||
tt.price, tt.direction, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"math"
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -16,18 +16,16 @@ import (
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// HyperliquidTrader Hyperliquid trader
|
||||
type HyperliquidTrader struct {
|
||||
exchange *hyperliquid.Exchange
|
||||
ctx context.Context
|
||||
walletAddr string
|
||||
meta *hyperliquid.Meta // Cache meta information (including precision)
|
||||
metaMutex sync.RWMutex // Protect concurrent access to meta field
|
||||
isCrossMargin bool // Whether to use cross margin mode
|
||||
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
|
||||
exchange *hyperliquid.Exchange
|
||||
ctx context.Context
|
||||
walletAddr string
|
||||
meta *hyperliquid.Meta // Cache meta information (including precision)
|
||||
metaMutex sync.RWMutex // Protect concurrent access to meta field
|
||||
isCrossMargin bool // Whether to use cross margin mode
|
||||
// xyz dex support (stocks, forex, commodities)
|
||||
xyzMeta *xyzDexMeta
|
||||
xyzMetaMutex sync.RWMutex
|
||||
@@ -81,8 +79,7 @@ func isXyzDexAsset(symbol string) bool {
|
||||
}
|
||||
|
||||
// NewHyperliquidTrader creates a Hyperliquid trader
|
||||
// unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
|
||||
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
|
||||
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
|
||||
// Remove 0x prefix from private key (if present, case-insensitive)
|
||||
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
||||
|
||||
@@ -177,19 +174,14 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool,
|
||||
}
|
||||
}
|
||||
|
||||
if unifiedAccount {
|
||||
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
|
||||
}
|
||||
|
||||
return &HyperliquidTrader{
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
meta: meta,
|
||||
isCrossMargin: true, // Use cross margin mode by default
|
||||
isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
|
||||
privateKey: privateKey,
|
||||
isTestnet: testnet,
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
meta: meta,
|
||||
isCrossMargin: true, // Use cross margin mode by default
|
||||
privateKey: privateKey,
|
||||
isTestnet: testnet,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -257,7 +249,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_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||
// To be compatible with auto_trader.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||
// Need to return "wallet balance without unrealized PnL"
|
||||
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
|
||||
|
||||
@@ -311,18 +303,9 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
|
||||
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
|
||||
|
||||
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
|
||||
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
|
||||
if t.isUnifiedAccount && spotUSDCBalance > 0 {
|
||||
// Add Spot balance to available balance for trading
|
||||
availableBalance = availableBalance + spotUSDCBalance
|
||||
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
|
||||
spotUSDCBalance, availableBalance)
|
||||
}
|
||||
|
||||
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
|
||||
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
|
||||
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
|
||||
result["availableBalance"] = availableBalance // Available balance (Perpetuals only)
|
||||
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
|
||||
result["spotBalance"] = spotUSDCBalance // Spot balance
|
||||
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
|
||||
@@ -1967,14 +1950,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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []types.ClosedPnLRecord
|
||||
var records []ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
@@ -1998,7 +1981,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]type
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
records = append(records, ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -2018,7 +2001,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]type
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Hyperliquid
|
||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
// Use UserFillsByTime API
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
|
||||
@@ -2026,7 +2009,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.T
|
||||
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
||||
}
|
||||
|
||||
var trades []types.TradeRecord
|
||||
var trades []TradeRecord
|
||||
for _, fill := range fills {
|
||||
price, _ := strconv.ParseFloat(fill.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
||||
@@ -2071,7 +2054,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.T
|
||||
}
|
||||
|
||||
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
|
||||
trade := types.TradeRecord{
|
||||
trade := TradeRecord{
|
||||
TradeID: strconv.FormatInt(fill.Tid, 10),
|
||||
Symbol: fill.Coin,
|
||||
Side: side,
|
||||
@@ -2099,13 +2082,13 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.T
|
||||
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]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 []types.OpenOrder
|
||||
var result []OpenOrder
|
||||
for _, order := range openOrders {
|
||||
if order.Coin != symbol {
|
||||
continue
|
||||
@@ -2116,7 +2099,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.Oid),
|
||||
Symbol: order.Coin,
|
||||
Side: side,
|
||||
@@ -2134,7 +2117,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||
|
||||
// Set leverage if specified and not xyz dex
|
||||
@@ -2182,7 +2165,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type
|
||||
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||
coin, req.Side, roundedPrice)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
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
|
||||
ht := &HyperliquidTrader{
|
||||
trader := &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 := ht.getSzDecimals("BTC")
|
||||
decimals := trader.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) {
|
||||
ht := &HyperliquidTrader{
|
||||
trader := &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()
|
||||
ht.getSzDecimals("BTC")
|
||||
trader.getSzDecimals("BTC")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -72,36 +72,36 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
|
||||
go func(iteration int) {
|
||||
defer wg.Done()
|
||||
// Simulate meta update
|
||||
ht.metaMutex.Lock()
|
||||
ht.meta = &hyperliquid.Meta{
|
||||
trader.metaMutex.Lock()
|
||||
trader.meta = &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5 + iteration%3},
|
||||
{Name: "ETH", SzDecimals: 4},
|
||||
},
|
||||
}
|
||||
ht.metaMutex.Unlock()
|
||||
trader.metaMutex.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify meta is not nil after all operations
|
||||
ht.metaMutex.RLock()
|
||||
if ht.meta == nil {
|
||||
trader.metaMutex.RLock()
|
||||
if trader.meta == nil {
|
||||
t.Error("Meta should not be nil after concurrent operations")
|
||||
}
|
||||
ht.metaMutex.RUnlock()
|
||||
trader.metaMutex.RUnlock()
|
||||
}
|
||||
|
||||
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
|
||||
func TestGetSzDecimals_NilMeta(t *testing.T) {
|
||||
ht := &HyperliquidTrader{
|
||||
trader := &HyperliquidTrader{
|
||||
meta: nil,
|
||||
metaMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
// Should return default value 4 when meta is nil
|
||||
decimals := ht.getSzDecimals("BTC")
|
||||
decimals := trader.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) {
|
||||
ht := &HyperliquidTrader{
|
||||
trader := &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 := ht.getSzDecimals(tt.coin)
|
||||
decimals := trader.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) {
|
||||
ht := &HyperliquidTrader{
|
||||
trader := &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()
|
||||
ht.getSzDecimals("BTC")
|
||||
ht.getSzDecimals("ETH")
|
||||
trader.getSzDecimals("BTC")
|
||||
trader.getSzDecimals("ETH")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -173,15 +173,15 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ht.metaMutex.Lock()
|
||||
ht.meta = &hyperliquid.Meta{
|
||||
trader.metaMutex.Lock()
|
||||
trader.meta = &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5},
|
||||
{Name: "ETH", SzDecimals: 4},
|
||||
{Name: "SOL", SzDecimals: 3},
|
||||
},
|
||||
}
|
||||
ht.metaMutex.Unlock()
|
||||
trader.metaMutex.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -22,9 +20,9 @@ import (
|
||||
// HyperliquidTestSuite Hyperliquid trader test suite
|
||||
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
|
||||
type HyperliquidTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
privateKey *ecdsa.PrivateKey
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewHyperliquidTestSuite Create Hyperliquid test suite
|
||||
@@ -218,7 +216,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
||||
},
|
||||
}
|
||||
|
||||
traderInstance := &HyperliquidTrader{
|
||||
trader := &HyperliquidTrader{
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
@@ -227,7 +225,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
|
||||
return &HyperliquidTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -250,7 +248,7 @@ func (s *HyperliquidTestSuite) Cleanup() {
|
||||
|
||||
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
|
||||
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*HyperliquidTrader)(nil)
|
||||
var _ Trader = (*HyperliquidTrader)(nil)
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
|
||||
@@ -564,8 +562,8 @@ func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ht := &HyperliquidTrader{meta: tt.meta}
|
||||
result := ht.getSzDecimals(tt.coin)
|
||||
trader := &HyperliquidTrader{meta: tt.meta}
|
||||
result := trader.getSzDecimals(tt.coin)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
@@ -1,878 +0,0 @@
|
||||
package indodax
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Indodax API endpoints
|
||||
const (
|
||||
indodaxBaseURL = "https://indodax.com"
|
||||
indodaxPublicAPI = "/api"
|
||||
indodaxPrivateAPI = "/tapi"
|
||||
)
|
||||
|
||||
// IndodaxTrader implements types.Trader interface for Indodax Spot Exchange
|
||||
// Indodax is Indonesia's largest crypto exchange, supporting IDR (Indonesian Rupiah) pairs.
|
||||
// Since Indodax is spot-only, futures-specific methods (OpenShort, CloseShort, leverage, etc.)
|
||||
// are gracefully stubbed.
|
||||
type IndodaxTrader struct {
|
||||
apiKey string
|
||||
secretKey string
|
||||
|
||||
httpClient *http.Client
|
||||
nonce int64
|
||||
nonceMutex sync.Mutex
|
||||
|
||||
// Cache for pair info
|
||||
pairCache map[string]*IndodaxPair
|
||||
pairCacheMutex sync.RWMutex
|
||||
pairCacheTime time.Time
|
||||
|
||||
// Cache for balance
|
||||
cachedBalance map[string]interface{}
|
||||
cachedPositions []map[string]interface{}
|
||||
balanceCacheTime time.Time
|
||||
positionCacheTime time.Time
|
||||
cacheDuration time.Duration
|
||||
cacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// IndodaxPair represents a trading pair on Indodax
|
||||
type IndodaxPair struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
BaseCurrency string `json:"base_currency"`
|
||||
TradedCurrency string `json:"traded_currency"`
|
||||
TradedCurrencyUnit string `json:"traded_currency_unit"`
|
||||
Description string `json:"description"`
|
||||
TickerID string `json:"ticker_id"`
|
||||
VolumePrecision int `json:"volume_precision"`
|
||||
PricePrecision float64 `json:"price_precision"`
|
||||
PriceRound int `json:"price_round"`
|
||||
Pricescale float64 `json:"pricescale"`
|
||||
TradeMinBaseCurrency float64 `json:"trade_min_base_currency"`
|
||||
TradeMinTradedCurrency float64 `json:"trade_min_traded_currency"`
|
||||
}
|
||||
|
||||
// IndodaxResponse represents the standard Indodax private API response
|
||||
type IndodaxResponse struct {
|
||||
Success int `json:"success"`
|
||||
Return json.RawMessage `json:"return,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
// IndodaxTicker represents ticker data
|
||||
type IndodaxTicker struct {
|
||||
High string `json:"high"`
|
||||
Low string `json:"low"`
|
||||
Last string `json:"last"`
|
||||
Buy string `json:"buy"`
|
||||
Sell string `json:"sell"`
|
||||
ServerTime int64 `json:"server_time"`
|
||||
}
|
||||
|
||||
// IndodaxTickerResponse wraps ticker response
|
||||
type IndodaxTickerResponse struct {
|
||||
Ticker IndodaxTicker `json:"ticker"`
|
||||
}
|
||||
|
||||
// NewIndodaxTrader creates a new Indodax trader instance
|
||||
func NewIndodaxTrader(apiKey, secretKey string) *IndodaxTrader {
|
||||
return &IndodaxTrader{
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
nonce: time.Now().UnixMilli(),
|
||||
pairCache: make(map[string]*IndodaxPair),
|
||||
cacheDuration: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// getNonce returns a unique incrementing nonce for each request
|
||||
func (t *IndodaxTrader) getNonce() int64 {
|
||||
t.nonceMutex.Lock()
|
||||
defer t.nonceMutex.Unlock()
|
||||
t.nonce++
|
||||
return t.nonce
|
||||
}
|
||||
|
||||
// sign generates HMAC-SHA512 signature for request body
|
||||
func (t *IndodaxTrader) sign(body string) string {
|
||||
mac := hmac.New(sha512.New, []byte(t.secretKey))
|
||||
mac.Write([]byte(body))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// doPublicRequest makes a public API GET request
|
||||
func (t *IndodaxTrader) doPublicRequest(path string) ([]byte, error) {
|
||||
reqURL := indodaxBaseURL + indodaxPublicAPI + path
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// doPrivateRequest makes a signed private API POST request
|
||||
func (t *IndodaxTrader) doPrivateRequest(params url.Values) ([]byte, error) {
|
||||
reqURL := indodaxBaseURL + indodaxPrivateAPI
|
||||
|
||||
// Add nonce
|
||||
params.Set("nonce", strconv.FormatInt(t.getNonce(), 10))
|
||||
|
||||
body := params.Encode()
|
||||
signature := t.sign(body)
|
||||
|
||||
req, err := http.NewRequest("POST", reqURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Key", t.apiKey)
|
||||
req.Header.Set("Sign", signature)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, fmt.Errorf("rate limit exceeded, please try again later")
|
||||
}
|
||||
|
||||
// Parse response to check success
|
||||
var apiResp IndodaxResponse
|
||||
if err := json.Unmarshal(data, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(data))
|
||||
}
|
||||
|
||||
if apiResp.Success != 1 {
|
||||
return nil, fmt.Errorf("API error: %s (code: %s)", apiResp.Error, apiResp.ErrorCode)
|
||||
}
|
||||
|
||||
return apiResp.Return, nil
|
||||
}
|
||||
|
||||
// convertSymbol converts standard symbol to Indodax format
|
||||
// e.g. BTCIDR -> btc_idr, ETHIDR -> eth_idr
|
||||
func (t *IndodaxTrader) convertSymbol(symbol string) string {
|
||||
s := strings.ToLower(symbol)
|
||||
|
||||
// Already in Indodax format (contains underscore)
|
||||
if strings.Contains(s, "_") {
|
||||
return s
|
||||
}
|
||||
|
||||
// Try to split by known base currencies
|
||||
for _, base := range []string{"idr", "btc", "usdt"} {
|
||||
if strings.HasSuffix(s, base) {
|
||||
traded := strings.TrimSuffix(s, base)
|
||||
if traded != "" {
|
||||
return traded + "_" + base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// convertSymbolBack converts Indodax format back to standard
|
||||
// e.g. btc_idr -> BTCIDR
|
||||
func (t *IndodaxTrader) convertSymbolBack(indodaxSymbol string) string {
|
||||
return strings.ToUpper(strings.ReplaceAll(indodaxSymbol, "_", ""))
|
||||
}
|
||||
|
||||
// getCoinFromSymbol extracts the traded currency from a symbol
|
||||
// e.g. btc_idr -> btc, eth_idr -> eth
|
||||
func (t *IndodaxTrader) getCoinFromSymbol(symbol string) string {
|
||||
pair := t.convertSymbol(symbol)
|
||||
parts := strings.Split(pair, "_")
|
||||
if len(parts) >= 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return strings.ToLower(symbol)
|
||||
}
|
||||
|
||||
// loadPairs loads trading pair information from the public API
|
||||
func (t *IndodaxTrader) loadPairs() error {
|
||||
t.pairCacheMutex.RLock()
|
||||
if len(t.pairCache) > 0 && time.Since(t.pairCacheTime) < 5*time.Minute {
|
||||
t.pairCacheMutex.RUnlock()
|
||||
return nil
|
||||
}
|
||||
t.pairCacheMutex.RUnlock()
|
||||
|
||||
data, err := t.doPublicRequest("/pairs")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load pairs: %w", err)
|
||||
}
|
||||
|
||||
var pairs []IndodaxPair
|
||||
if err := json.Unmarshal(data, &pairs); err != nil {
|
||||
return fmt.Errorf("failed to parse pairs: %w", err)
|
||||
}
|
||||
|
||||
t.pairCacheMutex.Lock()
|
||||
defer t.pairCacheMutex.Unlock()
|
||||
|
||||
t.pairCache = make(map[string]*IndodaxPair)
|
||||
for i := range pairs {
|
||||
p := pairs[i]
|
||||
t.pairCache[p.TickerID] = &p
|
||||
// Also index by ID (e.g. "btcidr")
|
||||
t.pairCache[p.ID] = &p
|
||||
}
|
||||
t.pairCacheTime = time.Now()
|
||||
|
||||
logger.Infof("[Indodax] Loaded %d trading pairs", len(pairs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPair gets pair info for a symbol
|
||||
func (t *IndodaxTrader) getPair(symbol string) (*IndodaxPair, error) {
|
||||
if err := t.loadPairs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pairID := t.convertSymbol(symbol)
|
||||
|
||||
t.pairCacheMutex.RLock()
|
||||
defer t.pairCacheMutex.RUnlock()
|
||||
|
||||
if pair, ok := t.pairCache[pairID]; ok {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// Try without underscore
|
||||
noUnderscore := strings.ReplaceAll(pairID, "_", "")
|
||||
if pair, ok := t.pairCache[noUnderscore]; ok {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("pair not found: %s", symbol)
|
||||
}
|
||||
|
||||
// clearCache clears cached data
|
||||
func (t *IndodaxTrader) clearCache() {
|
||||
t.cacheMutex.Lock()
|
||||
defer t.cacheMutex.Unlock()
|
||||
t.cachedBalance = nil
|
||||
t.cachedPositions = nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// types.Trader interface implementation
|
||||
// ============================================================
|
||||
|
||||
// GetBalance gets account balance from Indodax
|
||||
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedBalance
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ServerTime int64 `json:"server_time"`
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance: %w", err)
|
||||
}
|
||||
|
||||
// Calculate total balance in IDR
|
||||
idrBalance := parseFloat(result.Balance["idr"])
|
||||
idrHold := parseFloat(result.BalanceHold["idr"])
|
||||
totalIDR := idrBalance + idrHold
|
||||
|
||||
balance := map[string]interface{}{
|
||||
"totalWalletBalance": totalIDR,
|
||||
"availableBalance": idrBalance,
|
||||
"totalUnrealizedProfit": 0.0,
|
||||
"totalEquity": totalIDR,
|
||||
"balance": totalIDR,
|
||||
"idr_balance": idrBalance,
|
||||
"idr_hold": idrHold,
|
||||
"currency": "IDR",
|
||||
"user_id": result.UserID,
|
||||
"server_time": result.ServerTime,
|
||||
}
|
||||
|
||||
// Add individual crypto balances
|
||||
for currency, amount := range result.Balance {
|
||||
if currency != "idr" {
|
||||
balance["balance_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
for currency, amount := range result.BalanceHold {
|
||||
if currency != "idr" {
|
||||
balance["hold_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedBalance = balance
|
||||
t.balanceCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// GetPositions returns currently held crypto balances as "positions"
|
||||
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
|
||||
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedPositions
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions: %w", err)
|
||||
}
|
||||
|
||||
var positions []map[string]interface{}
|
||||
|
||||
for currency, amountRaw := range result.Balance {
|
||||
if currency == "idr" {
|
||||
continue
|
||||
}
|
||||
|
||||
amount := parseFloat(amountRaw)
|
||||
holdAmount := parseFloat(result.BalanceHold[currency])
|
||||
totalAmount := amount + holdAmount
|
||||
|
||||
if totalAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get market price for this coin
|
||||
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
|
||||
|
||||
// Calculate position value in IDR
|
||||
notionalValue := totalAmount * markPrice
|
||||
|
||||
position := map[string]interface{}{
|
||||
"symbol": strings.ToUpper(currency) + "IDR",
|
||||
"side": "LONG",
|
||||
"positionAmt": totalAmount,
|
||||
"entryPrice": markPrice, // Spot doesn't track entry price
|
||||
"markPrice": markPrice,
|
||||
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
|
||||
"leverage": 1.0,
|
||||
"mgnMode": "spot",
|
||||
"notionalValue": notionalValue,
|
||||
"currency": currency,
|
||||
"available": amount,
|
||||
"hold": holdAmount,
|
||||
}
|
||||
|
||||
positions = append(positions, position)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedPositions = positions
|
||||
t.positionCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// OpenLong opens a spot buy order
|
||||
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// Get market price to calculate IDR amount
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "buy")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place buy order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "BUY",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CloseLong closes a spot position by selling
|
||||
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// If quantity is 0, sell all available balance
|
||||
if quantity <= 0 {
|
||||
balance, err := t.GetBalance()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
|
||||
}
|
||||
available := parseFloat(balance["balance_"+coin])
|
||||
if available <= 0 {
|
||||
return nil, fmt.Errorf("no %s balance to sell", coin)
|
||||
}
|
||||
quantity = available
|
||||
}
|
||||
|
||||
// Get market price
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "sell")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place sell order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "SELL",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
|
||||
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
|
||||
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
|
||||
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets the current market price for a symbol
|
||||
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
|
||||
|
||||
data, err := t.doPublicRequest("/ticker/" + pairID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get ticker: %w", err)
|
||||
}
|
||||
|
||||
var tickerResp IndodaxTickerResponse
|
||||
if err := json.Unmarshal(data, &tickerResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ticker: %w", err)
|
||||
}
|
||||
|
||||
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// SetStopLoss is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetTakeProfit is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CancelStopLossOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders cancels all open orders for a given symbol
|
||||
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
// First get open orders
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
params.Set("pair", pair)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
// Cancel each order
|
||||
for _, order := range result.Orders {
|
||||
cancelParams := url.Values{}
|
||||
cancelParams.Set("method", "cancelOrder")
|
||||
cancelParams.Set("pair", pair)
|
||||
cancelParams.Set("order_id", order.OrderID.String())
|
||||
cancelParams.Set("type", order.Type)
|
||||
|
||||
if _, err := t.doPrivateRequest(cancelParams); err != nil {
|
||||
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
|
||||
} else {
|
||||
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders is a no-op for Indodax (no stop orders)
|
||||
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity formats quantity to correct precision for Indodax
|
||||
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
pair, err := t.getPair(symbol)
|
||||
if err != nil {
|
||||
// Default: 8 decimal places
|
||||
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
|
||||
}
|
||||
|
||||
precision := pair.PriceRound
|
||||
if precision <= 0 {
|
||||
precision = 8
|
||||
}
|
||||
|
||||
// Round down to avoid exceeding balance
|
||||
factor := math.Pow(10, float64(precision))
|
||||
rounded := math.Floor(quantity*factor) / factor
|
||||
|
||||
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
|
||||
}
|
||||
|
||||
// GetOrderStatus gets the status of a specific order
|
||||
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getOrder")
|
||||
params.Set("pair", pair)
|
||||
params.Set("order_id", orderID)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order status: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Order struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
FinishTime string `json:"finish_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"order"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order: %w", err)
|
||||
}
|
||||
|
||||
// Map Indodax status to standard status
|
||||
status := "NEW"
|
||||
switch result.Order.Status {
|
||||
case "filled":
|
||||
status = "FILLED"
|
||||
case "cancelled":
|
||||
status = "CANCELED"
|
||||
case "open":
|
||||
status = "NEW"
|
||||
}
|
||||
|
||||
price, _ := strconv.ParseFloat(result.Order.Price, 64)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": status,
|
||||
"avgPrice": price,
|
||||
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
|
||||
"commission": 0.0,
|
||||
"orderId": result.Order.OrderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records (trade history)
|
||||
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
// Indodax trade history is limited to 7 days range
|
||||
params := url.Values{}
|
||||
params.Set("method", "tradeHistory")
|
||||
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
|
||||
if limit > 0 {
|
||||
params.Set("count", strconv.Itoa(limit))
|
||||
}
|
||||
if !startTime.IsZero() {
|
||||
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Trades []struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
OrderID string `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
Fee string `json:"fee"`
|
||||
TradeTime string `json:"trade_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"trades"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
// Trade history might return empty, that's fine
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range result.Trades {
|
||||
price, _ := strconv.ParseFloat(trade.Price, 64)
|
||||
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
||||
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
|
||||
|
||||
side := "long"
|
||||
if trade.Type == "sell" {
|
||||
side = "long" // Selling from a spot position is closing long
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: "BTCIDR",
|
||||
Side: side,
|
||||
ExitPrice: price,
|
||||
Fee: fee,
|
||||
ExitTime: time.Unix(tradeTime, 0),
|
||||
OrderID: trade.OrderID,
|
||||
CloseType: "manual",
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets open/pending orders
|
||||
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
if pair != "" {
|
||||
params.Set("pair", pair)
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []types.OpenOrder
|
||||
for _, order := range result.Orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
|
||||
side := "BUY"
|
||||
if order.Type == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
orders = append(orders, types.OpenOrder{
|
||||
OrderID: order.OrderID.String(),
|
||||
Symbol: t.convertSymbolBack(pair),
|
||||
Side: side,
|
||||
PositionSide: "LONG",
|
||||
Type: "LIMIT",
|
||||
Price: price,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper functions
|
||||
// ============================================================
|
||||
|
||||
// parseFloat safely parses a float from interface{}
|
||||
func parseFloat(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(val, 64)
|
||||
return f
|
||||
case json.Number:
|
||||
f, _ := val.Float64()
|
||||
return f
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
package indodax
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// Test credentials - set via environment variables
|
||||
func getIndodaxTestCredentials(t *testing.T) (string, string) {
|
||||
apiKey := os.Getenv("INDODAX_TEST_API_KEY")
|
||||
secretKey := os.Getenv("INDODAX_TEST_SECRET_KEY")
|
||||
|
||||
if apiKey == "" || secretKey == "" {
|
||||
t.Skip("Indodax test credentials not set (INDODAX_TEST_API_KEY, INDODAX_TEST_SECRET_KEY)")
|
||||
}
|
||||
|
||||
return apiKey, secretKey
|
||||
}
|
||||
|
||||
func createIndodaxTestTrader(t *testing.T) *IndodaxTrader {
|
||||
apiKey, secretKey := getIndodaxTestCredentials(t)
|
||||
trader := NewIndodaxTrader(apiKey, secretKey)
|
||||
return trader
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_InterfaceCompliance tests that IndodaxTrader implements types.Trader
|
||||
func TestIndodaxTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*IndodaxTrader)(nil)
|
||||
}
|
||||
|
||||
// TestNewIndodaxTrader tests creating Indodax trader instance
|
||||
func TestNewIndodaxTrader(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test_api_key", "test_secret_key")
|
||||
|
||||
if trader == nil {
|
||||
t.Fatal("Expected non-nil trader")
|
||||
}
|
||||
if trader.apiKey != "test_api_key" {
|
||||
t.Errorf("Expected apiKey 'test_api_key', got '%s'", trader.apiKey)
|
||||
}
|
||||
if trader.secretKey != "test_secret_key" {
|
||||
t.Errorf("Expected secretKey 'test_secret_key', got '%s'", trader.secretKey)
|
||||
}
|
||||
if trader.httpClient == nil {
|
||||
t.Error("Expected non-nil httpClient")
|
||||
}
|
||||
if trader.cacheDuration != 15*time.Second {
|
||||
t.Errorf("Expected cacheDuration 15s, got %v", trader.cacheDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_SymbolConversion tests symbol format conversion
|
||||
func TestIndodaxTrader_SymbolConversion(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"BTCIDR to btc_idr", "BTCIDR", "btc_idr"},
|
||||
{"ETHIDR to eth_idr", "ETHIDR", "eth_idr"},
|
||||
{"SOLIDR to sol_idr", "SOLIDR", "sol_idr"},
|
||||
{"Already converted", "btc_idr", "btc_idr"},
|
||||
{"BTC pair", "ETHBTC", "eth_btc"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := trader.convertSymbol(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("convertSymbol(%s) = %s, want %s", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_SymbolConversionBack tests symbol reversion
|
||||
func TestIndodaxTrader_SymbolConversionBack(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"btc_idr to BTCIDR", "btc_idr", "BTCIDR"},
|
||||
{"eth_idr to ETHIDR", "eth_idr", "ETHIDR"},
|
||||
{"Already standard", "BTCIDR", "BTCIDR"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := trader.convertSymbolBack(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("convertSymbolBack(%s) = %s, want %s", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_GetCoinFromSymbol tests coin extraction
|
||||
func TestIndodaxTrader_GetCoinFromSymbol(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"BTCIDR", "btc"},
|
||||
{"ETHIDR", "eth"},
|
||||
{"btc_idr", "btc"},
|
||||
{"eth_idr", "eth"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := trader.getCoinFromSymbol(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getCoinFromSymbol(%s) = %s, want %s", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_Sign tests HMAC-SHA512 signature generation
|
||||
func TestIndodaxTrader_Sign(t *testing.T) {
|
||||
trader := NewIndodaxTrader("api_key", "secret_key")
|
||||
|
||||
body := "method=getInfo&nonce=1000"
|
||||
signature := trader.sign(body)
|
||||
|
||||
if signature == "" {
|
||||
t.Error("Expected non-empty signature")
|
||||
}
|
||||
if len(signature) != 128 { // SHA-512 hex = 128 chars
|
||||
t.Errorf("Expected signature length 128, got %d", len(signature))
|
||||
}
|
||||
|
||||
// Same input should produce same signature
|
||||
signature2 := trader.sign(body)
|
||||
if signature != signature2 {
|
||||
t.Error("Signature should be deterministic")
|
||||
}
|
||||
|
||||
// Different input should produce different signature
|
||||
signature3 := trader.sign("method=getInfo&nonce=1001")
|
||||
if signature == signature3 {
|
||||
t.Error("Different input should produce different signature")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_Nonce tests nonce incrementation
|
||||
func TestIndodaxTrader_Nonce(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test", "test")
|
||||
|
||||
nonce1 := trader.getNonce()
|
||||
nonce2 := trader.getNonce()
|
||||
nonce3 := trader.getNonce()
|
||||
|
||||
if nonce2 <= nonce1 {
|
||||
t.Errorf("Nonce should be increasing: %d <= %d", nonce2, nonce1)
|
||||
}
|
||||
if nonce3 <= nonce2 {
|
||||
t.Errorf("Nonce should be increasing: %d <= %d", nonce3, nonce2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_SpotOnlyRestrictions tests that futures-only methods return errors
|
||||
func TestIndodaxTrader_SpotOnlyRestrictions(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test", "test")
|
||||
|
||||
// OpenShort should fail
|
||||
_, err := trader.OpenShort("BTCIDR", 0.001, 1)
|
||||
if err == nil {
|
||||
t.Error("OpenShort should return error on spot exchange")
|
||||
}
|
||||
|
||||
// CloseShort should fail
|
||||
_, err = trader.CloseShort("BTCIDR", 0.001)
|
||||
if err == nil {
|
||||
t.Error("CloseShort should return error on spot exchange")
|
||||
}
|
||||
|
||||
// SetStopLoss should fail
|
||||
err = trader.SetStopLoss("BTCIDR", "LONG", 0.001, 500000000)
|
||||
if err == nil {
|
||||
t.Error("SetStopLoss should return error on spot exchange")
|
||||
}
|
||||
|
||||
// SetTakeProfit should fail
|
||||
err = trader.SetTakeProfit("BTCIDR", "LONG", 0.001, 600000000)
|
||||
if err == nil {
|
||||
t.Error("SetTakeProfit should return error on spot exchange")
|
||||
}
|
||||
|
||||
// SetLeverage should NOT fail (no-op)
|
||||
err = trader.SetLeverage("BTCIDR", 10)
|
||||
if err != nil {
|
||||
t.Errorf("SetLeverage should not fail (no-op): %v", err)
|
||||
}
|
||||
|
||||
// SetMarginMode should NOT fail (no-op)
|
||||
err = trader.SetMarginMode("BTCIDR", true)
|
||||
if err != nil {
|
||||
t.Errorf("SetMarginMode should not fail (no-op): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_ParseFloat tests parseFloat helper
|
||||
func TestIndodaxTrader_ParseFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected float64
|
||||
}{
|
||||
{"float64", 123.45, 123.45},
|
||||
{"string", "123.45", 123.45},
|
||||
{"int", 123, 123.0},
|
||||
{"int64", int64(123), 123.0},
|
||||
{"nil", nil, 0.0},
|
||||
{"zero string", "0", 0.0},
|
||||
{"empty string", "", 0.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseFloat(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("parseFloat(%v) = %f, want %f", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxTrader_ClearCache tests cache clearing
|
||||
func TestIndodaxTrader_ClearCache(t *testing.T) {
|
||||
trader := NewIndodaxTrader("test", "test")
|
||||
|
||||
// Set some cached data
|
||||
trader.cachedBalance = map[string]interface{}{"test": "data"}
|
||||
trader.cachedPositions = []map[string]interface{}{{"test": "data"}}
|
||||
|
||||
// Clear cache
|
||||
trader.clearCache()
|
||||
|
||||
if trader.cachedBalance != nil {
|
||||
t.Error("Cache should be cleared")
|
||||
}
|
||||
if trader.cachedPositions != nil {
|
||||
t.Error("Position cache should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Integration tests (require INDODAX_TEST_API_KEY env vars)
|
||||
// ============================================================
|
||||
|
||||
// TestIndodaxConnection tests basic API connectivity
|
||||
func TestIndodaxConnection(t *testing.T) {
|
||||
trader := createIndodaxTestTrader(t)
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get balance: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Connection OK")
|
||||
t.Logf(" totalWalletBalance: %v", balance["totalWalletBalance"])
|
||||
t.Logf(" availableBalance: %v", balance["availableBalance"])
|
||||
t.Logf(" totalEquity: %v", balance["totalEquity"])
|
||||
t.Logf(" currency: %v", balance["currency"])
|
||||
t.Logf(" user_id: %v", balance["user_id"])
|
||||
}
|
||||
|
||||
// TestIndodaxGetPositions tests position retrieval
|
||||
func TestIndodaxGetPositions(t *testing.T) {
|
||||
trader := createIndodaxTestTrader(t)
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📊 Found %d positions (crypto balances):", len(positions))
|
||||
for i, pos := range positions {
|
||||
t.Logf(" [%d] %s: qty=%.8f markPrice=%.0f value=%.0f IDR",
|
||||
i+1,
|
||||
pos["symbol"],
|
||||
pos["positionAmt"],
|
||||
pos["markPrice"],
|
||||
pos["notionalValue"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxGetMarketPrice tests market price retrieval
|
||||
func TestIndodaxGetMarketPrice(t *testing.T) {
|
||||
trader := createIndodaxTestTrader(t)
|
||||
|
||||
pairs := []string{"BTCIDR", "ETHIDR"}
|
||||
|
||||
for _, pair := range pairs {
|
||||
price, err := trader.GetMarketPrice(pair)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get price for %s: %v", pair, err)
|
||||
continue
|
||||
}
|
||||
t.Logf(" %s: %.0f IDR", pair, price)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxGetOpenOrders tests open orders retrieval
|
||||
func TestIndodaxGetOpenOrders(t *testing.T) {
|
||||
trader := createIndodaxTestTrader(t)
|
||||
|
||||
orders, err := trader.GetOpenOrders("BTCIDR")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get open orders: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Found %d open orders:", len(orders))
|
||||
for i, order := range orders {
|
||||
t.Logf(" [%d] %s %s: price=%.0f orderID=%s",
|
||||
i+1, order.Symbol, order.Side, order.Price, order.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxGetClosedPnL tests trade history retrieval
|
||||
func TestIndodaxGetClosedPnL(t *testing.T) {
|
||||
trader := createIndodaxTestTrader(t)
|
||||
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
records, err := trader.GetClosedPnL(startTime, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get closed PnL: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Found %d trade records:", len(records))
|
||||
for i, record := range records {
|
||||
t.Logf(" [%d] %s %s: price=%.0f fee=%.4f time=%s",
|
||||
i+1, record.Symbol, record.Side, record.ExitPrice, record.Fee,
|
||||
record.ExitTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndodaxLoadPairs tests loading trading pairs
|
||||
func TestIndodaxLoadPairs(t *testing.T) {
|
||||
trader := createIndodaxTestTrader(t)
|
||||
|
||||
err := trader.loadPairs()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load pairs: %v", err)
|
||||
}
|
||||
|
||||
trader.pairCacheMutex.RLock()
|
||||
defer trader.pairCacheMutex.RUnlock()
|
||||
|
||||
t.Logf("📊 Loaded %d pairs", len(trader.pairCache))
|
||||
|
||||
// Check some known pairs
|
||||
knownPairs := []string{"btc_idr", "eth_idr"}
|
||||
for _, pairID := range knownPairs {
|
||||
if pair, ok := trader.pairCache[pairID]; ok {
|
||||
t.Logf(" %s: min_base=%v, min_traded=%v, precision=%d",
|
||||
pair.Description, pair.TradeMinBaseCurrency, pair.TradeMinTradedCurrency, pair.PriceRound)
|
||||
} else {
|
||||
t.Errorf("Expected pair %s not found", pairID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,161 @@ package trader
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
// 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
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
package kucoin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader/types"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KuCoinTrade represents a trade record from KuCoin fill history
|
||||
type KuCoinTrade struct {
|
||||
Symbol string
|
||||
TradeID string
|
||||
OrderID string
|
||||
Side string // buy or sell
|
||||
FillPrice float64
|
||||
FillQty float64 // In base currency (e.g., ETH), not lots
|
||||
Fee float64
|
||||
FeeAsset string
|
||||
ExecTime time.Time
|
||||
ProfitLoss float64
|
||||
OrderAction string // open_long, open_short, close_long, close_short
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade/fill records from KuCoin
|
||||
func (t *KuCoinTrader) GetTrades(startTime time.Time, limit int) ([]KuCoinTrade, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100 // KuCoin max limit
|
||||
}
|
||||
|
||||
// Build query path
|
||||
path := fmt.Sprintf("%s?pageSize=%d", kucoinFillsPath, limit)
|
||||
if !startTime.IsZero() {
|
||||
path += fmt.Sprintf("&startAt=%d", startTime.UnixMilli())
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
CurrentPage int `json:"currentPage"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalNum int `json:"totalNum"`
|
||||
TotalPage int `json:"totalPage"`
|
||||
Items []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TradeId string `json:"tradeId"`
|
||||
OrderId string `json:"orderId"`
|
||||
Side string `json:"side"`
|
||||
Price string `json:"price"`
|
||||
Size int64 `json:"size"`
|
||||
Value string `json:"value"` // Trade value in quote currency
|
||||
Fee string `json:"fee"` // Total fee
|
||||
FeeRate string `json:"feeRate"` // Fee rate
|
||||
FeeCurrency string `json:"feeCurrency"` // Fee currency (USDT)
|
||||
OpenFeePay string `json:"openFeePay"` // Fee for opening (>0 means opening trade)
|
||||
CloseFeePay string `json:"closeFeePay"` // Fee for closing (>0 means closing trade)
|
||||
TradeTime int64 `json:"tradeTime"` // Nanoseconds
|
||||
MarginMode string `json:"marginMode"` // CROSS or ISOLATED
|
||||
OrderType string `json:"orderType"` // market, limit
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade history: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from KuCoin", len(response.Items))
|
||||
|
||||
result := make([]KuCoinTrade, 0, len(response.Items))
|
||||
|
||||
for _, trade := range response.Items {
|
||||
// Parse numeric values from strings
|
||||
var fillPrice, fee, openFeePay, closeFeePay float64
|
||||
fmt.Sscanf(trade.Price, "%f", &fillPrice)
|
||||
fmt.Sscanf(trade.Fee, "%f", &fee)
|
||||
fmt.Sscanf(trade.OpenFeePay, "%f", &openFeePay)
|
||||
fmt.Sscanf(trade.CloseFeePay, "%f", &closeFeePay)
|
||||
|
||||
// Get multiplier from contract info
|
||||
symbol := t.convertSymbolBack(trade.Symbol)
|
||||
var multiplier float64
|
||||
contract, err := t.getContract(symbol)
|
||||
if err == nil && contract != nil {
|
||||
multiplier = contract.Multiplier
|
||||
} else {
|
||||
// Default multipliers based on symbol
|
||||
if strings.Contains(symbol, "BTC") {
|
||||
multiplier = 0.001
|
||||
} else {
|
||||
multiplier = 0.01 // Default for altcoins
|
||||
}
|
||||
}
|
||||
|
||||
// Convert lots to actual quantity
|
||||
absSize := trade.Size
|
||||
if absSize < 0 {
|
||||
absSize = -absSize
|
||||
}
|
||||
fillQty := float64(absSize) * multiplier
|
||||
|
||||
// Determine side and order action
|
||||
// KuCoin uses openFeePay/closeFeePay to indicate if trade is opening or closing
|
||||
side := strings.ToUpper(trade.Side) // BUY or SELL
|
||||
isClosing := closeFeePay > 0
|
||||
|
||||
var orderAction string
|
||||
if trade.Side == "buy" {
|
||||
if isClosing {
|
||||
// Buying to close short
|
||||
orderAction = "close_short"
|
||||
} else {
|
||||
// Buying to open long
|
||||
orderAction = "open_long"
|
||||
}
|
||||
} else { // sell
|
||||
if isClosing {
|
||||
// Selling to close long
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
// Selling to open short
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
// Trade time is in nanoseconds
|
||||
execTime := time.Unix(0, trade.TradeTime)
|
||||
|
||||
result = append(result, KuCoinTrade{
|
||||
Symbol: symbol,
|
||||
TradeID: trade.TradeId,
|
||||
OrderID: trade.OrderId,
|
||||
Side: side,
|
||||
FillPrice: fillPrice,
|
||||
FillQty: fillQty,
|
||||
Fee: fee,
|
||||
FeeAsset: trade.FeeCurrency,
|
||||
ExecTime: execTime,
|
||||
ProfitLoss: 0, // KuCoin fills API doesn't return PnL per trade
|
||||
OrderAction: orderAction,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by execution time (oldest first)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ExecTime.Before(result[j].ExecTime)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetRecentTrades retrieves recent trades (faster, no pagination)
|
||||
func (t *KuCoinTrader) GetRecentTrades() ([]KuCoinTrade, error) {
|
||||
data, err := t.doRequest("GET", kucoinRecentFillsPath, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recent trades: %w", err)
|
||||
}
|
||||
|
||||
var trades []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TradeId string `json:"tradeId"`
|
||||
OrderId string `json:"orderId"`
|
||||
Side string `json:"side"`
|
||||
Price string `json:"price"`
|
||||
Size int64 `json:"size"`
|
||||
Fee string `json:"fee"`
|
||||
FeeCurrency string `json:"feeCurrency"`
|
||||
OpenFeePay string `json:"openFeePay"`
|
||||
CloseFeePay string `json:"closeFeePay"`
|
||||
TradeTime int64 `json:"tradeTime"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &trades); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse recent trades: %w", err)
|
||||
}
|
||||
|
||||
result := make([]KuCoinTrade, 0, len(trades))
|
||||
|
||||
for _, trade := range trades {
|
||||
var fillPrice, fee, openFeePay, closeFeePay float64
|
||||
fmt.Sscanf(trade.Price, "%f", &fillPrice)
|
||||
fmt.Sscanf(trade.Fee, "%f", &fee)
|
||||
fmt.Sscanf(trade.OpenFeePay, "%f", &openFeePay)
|
||||
fmt.Sscanf(trade.CloseFeePay, "%f", &closeFeePay)
|
||||
|
||||
// Get multiplier from contract info
|
||||
symbol := t.convertSymbolBack(trade.Symbol)
|
||||
var multiplier float64
|
||||
contract, err := t.getContract(symbol)
|
||||
if err == nil && contract != nil {
|
||||
multiplier = contract.Multiplier
|
||||
} else {
|
||||
if strings.Contains(symbol, "BTC") {
|
||||
multiplier = 0.001
|
||||
} else {
|
||||
multiplier = 0.01
|
||||
}
|
||||
}
|
||||
|
||||
absSize := trade.Size
|
||||
if absSize < 0 {
|
||||
absSize = -absSize
|
||||
}
|
||||
fillQty := float64(absSize) * multiplier
|
||||
|
||||
side := strings.ToUpper(trade.Side)
|
||||
isClosing := closeFeePay > 0
|
||||
|
||||
var orderAction string
|
||||
if trade.Side == "buy" {
|
||||
if isClosing {
|
||||
orderAction = "close_short"
|
||||
} else {
|
||||
orderAction = "open_long"
|
||||
}
|
||||
} else {
|
||||
if isClosing {
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
execTime := time.Unix(0, trade.TradeTime)
|
||||
|
||||
result = append(result, KuCoinTrade{
|
||||
Symbol: symbol,
|
||||
TradeID: trade.TradeId,
|
||||
OrderID: trade.OrderId,
|
||||
Side: side,
|
||||
FillPrice: fillPrice,
|
||||
FillQty: fillQty,
|
||||
Fee: fee,
|
||||
FeeAsset: trade.FeeCurrency,
|
||||
ExecTime: execTime,
|
||||
ProfitLoss: 0,
|
||||
OrderAction: orderAction,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ToTradeRecord converts KuCoinTrade to types.TradeRecord
|
||||
func (t *KuCoinTrade) ToTradeRecord() types.TradeRecord {
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(t.OrderAction, "short") {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
return types.TradeRecord{
|
||||
TradeID: t.TradeID,
|
||||
Symbol: t.Symbol,
|
||||
Side: t.Side,
|
||||
PositionSide: positionSide,
|
||||
OrderAction: t.OrderAction,
|
||||
Price: t.FillPrice,
|
||||
Quantity: t.FillQty,
|
||||
RealizedPnL: t.ProfitLoss,
|
||||
Fee: t.Fee,
|
||||
Time: t.ExecTime,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncOrdersFromKuCoin syncs KuCoin 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 ("kucoin")
|
||||
func (t *KuCoinTrader) SyncOrdersFromKuCoin(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 KuCoin 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 KuCoin", 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
|
||||
}
|
||||
|
||||
// Symbol is already normalized in GetTrades
|
||||
symbol := 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", // KuCoin uses one-way position mode
|
||||
Type: "MARKET",
|
||||
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("✅ KuCoin order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task for KuCoin
|
||||
func (t *KuCoinTrader) 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.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ KuCoin order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 KuCoin order sync started (interval: %v)", interval)
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
package kucoin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test credentials - set via environment variables
|
||||
func getKuCoinTestCredentials(t *testing.T) (string, string, string) {
|
||||
apiKey := os.Getenv("KUCOIN_TEST_API_KEY")
|
||||
secretKey := os.Getenv("KUCOIN_TEST_SECRET_KEY")
|
||||
passphrase := os.Getenv("KUCOIN_TEST_PASSPHRASE")
|
||||
|
||||
if apiKey == "" || secretKey == "" || passphrase == "" {
|
||||
t.Skip("KuCoin test credentials not set (KUCOIN_TEST_API_KEY, KUCOIN_TEST_SECRET_KEY, KUCOIN_TEST_PASSPHRASE)")
|
||||
}
|
||||
|
||||
return apiKey, secretKey, passphrase
|
||||
}
|
||||
|
||||
func createKuCoinTestTrader(t *testing.T) *KuCoinTrader {
|
||||
apiKey, secretKey, passphrase := getKuCoinTestCredentials(t)
|
||||
trader := NewKuCoinTrader(apiKey, secretKey, passphrase)
|
||||
return trader
|
||||
}
|
||||
|
||||
// TestKuCoinConnection tests basic API connectivity
|
||||
func TestKuCoinConnection(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get balance: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Connection OK")
|
||||
t.Logf(" totalWalletBalance: %v", balance["totalWalletBalance"])
|
||||
t.Logf(" availableBalance: %v", balance["availableBalance"])
|
||||
t.Logf(" totalUnrealizedProfit: %v", balance["totalUnrealizedProfit"])
|
||||
t.Logf(" totalEquity: %v", balance["totalEquity"])
|
||||
}
|
||||
|
||||
// TestKuCoinGetPositions tests position retrieval
|
||||
func TestKuCoinGetPositions(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📊 Found %d positions:", len(positions))
|
||||
for i, pos := range positions {
|
||||
symbol := pos["symbol"].(string)
|
||||
side := pos["side"].(string)
|
||||
posAmt := pos["positionAmt"].(float64)
|
||||
entryPrice := pos["entryPrice"].(float64)
|
||||
markPrice := pos["markPrice"].(float64)
|
||||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||||
leverage := pos["leverage"].(float64)
|
||||
mgnMode := pos["mgnMode"].(string)
|
||||
|
||||
t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f mark=%.4f pnl=%.4f lev=%.0f mode=%s",
|
||||
i+1, symbol, side, posAmt, entryPrice, markPrice, unrealizedPnl, leverage, mgnMode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinGetTrades tests trade history retrieval with proper JSON parsing
|
||||
func TestKuCoinGetTrades(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
// Get trades from last 24 hours (KuCoin API quirk: >24h startAt returns 0)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
trades, err := trader.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get trades: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Retrieved %d trades from KuCoin:", len(trades))
|
||||
for i, trade := range trades {
|
||||
t.Logf(" [%d] %s | TradeID: %s | OrderID: %s", i+1, trade.ExecTime.Format("2006-01-02 15:04:05"), trade.TradeID, trade.OrderID)
|
||||
t.Logf(" Symbol: %s | Side: %s | Action: %s", trade.Symbol, trade.Side, trade.OrderAction)
|
||||
t.Logf(" Price: %.4f | Qty: %.6f | Fee: %.6f %s", trade.FillPrice, trade.FillQty, trade.Fee, trade.FeeAsset)
|
||||
t.Logf(" PnL: %.4f", trade.ProfitLoss)
|
||||
}
|
||||
|
||||
// Verify trade data integrity
|
||||
for i, trade := range trades {
|
||||
if trade.TradeID == "" {
|
||||
t.Errorf("Trade %d has empty TradeID", i)
|
||||
}
|
||||
if trade.Symbol == "" {
|
||||
t.Errorf("Trade %d has empty Symbol", i)
|
||||
}
|
||||
if trade.Side != "BUY" && trade.Side != "SELL" {
|
||||
t.Errorf("Trade %d has invalid Side: %s (expected BUY or SELL)", i, trade.Side)
|
||||
}
|
||||
if trade.OrderAction != "open_long" && trade.OrderAction != "open_short" &&
|
||||
trade.OrderAction != "close_long" && trade.OrderAction != "close_short" {
|
||||
t.Errorf("Trade %d has invalid OrderAction: %s", i, trade.OrderAction)
|
||||
}
|
||||
if trade.FillPrice <= 0 {
|
||||
t.Errorf("Trade %d has invalid FillPrice: %.6f", i, trade.FillPrice)
|
||||
}
|
||||
if trade.FillQty <= 0 {
|
||||
t.Errorf("Trade %d has invalid FillQty: %.6f", i, trade.FillQty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinGetRecentTrades tests recent trades endpoint
|
||||
func TestKuCoinGetRecentTrades(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
trades, err := trader.GetRecentTrades()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get recent trades: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Retrieved %d recent trades from KuCoin:", len(trades))
|
||||
for i, trade := range trades {
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f action=%s",
|
||||
i+1, trade.ExecTime.Format("01-02 15:04:05"), trade.Symbol, trade.Side,
|
||||
trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.OrderAction)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinTradeToRecord tests conversion to TradeRecord
|
||||
func TestKuCoinTradeToRecord(t *testing.T) {
|
||||
// Test open_long
|
||||
trade1 := KuCoinTrade{
|
||||
TradeID: "test-trade-1",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "BUY",
|
||||
OrderAction: "open_long",
|
||||
FillPrice: 50000.0,
|
||||
FillQty: 0.01,
|
||||
Fee: 0.5,
|
||||
ProfitLoss: 0,
|
||||
}
|
||||
record1 := trade1.ToTradeRecord()
|
||||
if record1.PositionSide != "LONG" {
|
||||
t.Errorf("open_long should have PositionSide=LONG, got %s", record1.PositionSide)
|
||||
}
|
||||
|
||||
// Test close_long
|
||||
trade2 := KuCoinTrade{
|
||||
TradeID: "test-trade-2",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "SELL",
|
||||
OrderAction: "close_long",
|
||||
FillPrice: 51000.0,
|
||||
FillQty: 0.01,
|
||||
Fee: 0.5,
|
||||
ProfitLoss: 10.0,
|
||||
}
|
||||
record2 := trade2.ToTradeRecord()
|
||||
if record2.PositionSide != "LONG" {
|
||||
t.Errorf("close_long should have PositionSide=LONG, got %s", record2.PositionSide)
|
||||
}
|
||||
|
||||
// Test open_short
|
||||
trade3 := KuCoinTrade{
|
||||
TradeID: "test-trade-3",
|
||||
Symbol: "ETHUSDT",
|
||||
Side: "SELL",
|
||||
OrderAction: "open_short",
|
||||
FillPrice: 3000.0,
|
||||
FillQty: 0.1,
|
||||
Fee: 0.3,
|
||||
ProfitLoss: 0,
|
||||
}
|
||||
record3 := trade3.ToTradeRecord()
|
||||
if record3.PositionSide != "SHORT" {
|
||||
t.Errorf("open_short should have PositionSide=SHORT, got %s", record3.PositionSide)
|
||||
}
|
||||
|
||||
// Test close_short
|
||||
trade4 := KuCoinTrade{
|
||||
TradeID: "test-trade-4",
|
||||
Symbol: "ETHUSDT",
|
||||
Side: "BUY",
|
||||
OrderAction: "close_short",
|
||||
FillPrice: 2900.0,
|
||||
FillQty: 0.1,
|
||||
Fee: 0.3,
|
||||
ProfitLoss: 10.0,
|
||||
}
|
||||
record4 := trade4.ToTradeRecord()
|
||||
if record4.PositionSide != "SHORT" {
|
||||
t.Errorf("close_short should have PositionSide=SHORT, got %s", record4.PositionSide)
|
||||
}
|
||||
|
||||
t.Logf("✅ TradeRecord conversion tests passed")
|
||||
}
|
||||
|
||||
// TestKuCoinOrderActionDetermination tests that order action is correctly determined
|
||||
func TestKuCoinOrderActionDetermination(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
trades, err := trader.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get trades: %v", err)
|
||||
}
|
||||
|
||||
// Analyze trade patterns
|
||||
actionCounts := make(map[string]int)
|
||||
for _, trade := range trades {
|
||||
actionCounts[trade.OrderAction]++
|
||||
}
|
||||
|
||||
t.Logf("📊 Order action distribution:")
|
||||
for action, count := range actionCounts {
|
||||
t.Logf(" %s: %d", action, count)
|
||||
}
|
||||
|
||||
// Verify logical consistency:
|
||||
// - BUY + open_long: opening a long position
|
||||
// - BUY + close_short: closing a short position
|
||||
// - SELL + open_short: opening a short position
|
||||
// - SELL + close_long: closing a long position
|
||||
for i, trade := range trades {
|
||||
switch trade.OrderAction {
|
||||
case "open_long":
|
||||
if trade.Side != "BUY" {
|
||||
t.Errorf("Trade %d: open_long should have Side=BUY, got %s", i, trade.Side)
|
||||
}
|
||||
case "close_short":
|
||||
if trade.Side != "BUY" {
|
||||
t.Errorf("Trade %d: close_short should have Side=BUY, got %s", i, trade.Side)
|
||||
}
|
||||
case "open_short":
|
||||
if trade.Side != "SELL" {
|
||||
t.Errorf("Trade %d: open_short should have Side=SELL, got %s", i, trade.Side)
|
||||
}
|
||||
case "close_long":
|
||||
if trade.Side != "SELL" {
|
||||
t.Errorf("Trade %d: close_long should have Side=SELL, got %s", i, trade.Side)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinPositionBuilding tests that trades can be used to build position state
|
||||
func TestKuCoinPositionBuilding(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
trades, err := trader.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get trades: %v", err)
|
||||
}
|
||||
|
||||
// Group trades by symbol and build position state
|
||||
type PositionState struct {
|
||||
LongQty float64
|
||||
ShortQty float64
|
||||
LongPnL float64
|
||||
ShortPnL float64
|
||||
TradeCount int
|
||||
}
|
||||
positions := make(map[string]*PositionState)
|
||||
|
||||
for _, trade := range trades {
|
||||
if positions[trade.Symbol] == nil {
|
||||
positions[trade.Symbol] = &PositionState{}
|
||||
}
|
||||
pos := positions[trade.Symbol]
|
||||
pos.TradeCount++
|
||||
|
||||
switch trade.OrderAction {
|
||||
case "open_long":
|
||||
pos.LongQty += trade.FillQty
|
||||
case "close_long":
|
||||
pos.LongQty -= trade.FillQty
|
||||
pos.LongPnL += trade.ProfitLoss
|
||||
case "open_short":
|
||||
pos.ShortQty += trade.FillQty
|
||||
case "close_short":
|
||||
pos.ShortQty -= trade.FillQty
|
||||
pos.ShortPnL += trade.ProfitLoss
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("📊 Calculated position states from %d trades:", len(trades))
|
||||
for symbol, pos := range positions {
|
||||
t.Logf(" %s: trades=%d longQty=%.6f shortQty=%.6f longPnL=%.4f shortPnL=%.4f",
|
||||
symbol, pos.TradeCount, pos.LongQty, pos.ShortQty, pos.LongPnL, pos.ShortPnL)
|
||||
}
|
||||
|
||||
// Now compare with actual positions from exchange
|
||||
actualPositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get actual positions: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("\n📊 Actual positions from exchange:")
|
||||
for _, pos := range actualPositions {
|
||||
symbol := pos["symbol"].(string)
|
||||
side := pos["side"].(string)
|
||||
qty := pos["positionAmt"].(float64)
|
||||
t.Logf(" %s %s: qty=%.6f", symbol, side, qty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinRawAPIResponse tests raw API response to verify field types
|
||||
func TestKuCoinRawAPIResponse(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
// Make raw request to fills endpoint
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
path := fmt.Sprintf("%s?pageSize=10&startAt=%d", kucoinFillsPath, startTime.UnixMilli())
|
||||
|
||||
data, err := trader.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get raw fills data: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Raw API response (first 2000 chars):")
|
||||
response := string(data)
|
||||
if len(response) > 2000 {
|
||||
response = response[:2000] + "..."
|
||||
}
|
||||
t.Logf("%s", response)
|
||||
}
|
||||
|
||||
// TestKuCoinValueCalculation tests that calculated value (price * qty) matches API value
|
||||
// This is the key test to verify multiplier and qty calculation is correct
|
||||
func TestKuCoinValueCalculation(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
// Get raw API response to compare
|
||||
path := fmt.Sprintf("%s?pageSize=20", kucoinFillsPath)
|
||||
data, err := trader.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get raw fills: %v", err)
|
||||
}
|
||||
|
||||
var rawResponse struct {
|
||||
Items []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TradeId string `json:"tradeId"`
|
||||
Price string `json:"price"`
|
||||
Size int64 `json:"size"`
|
||||
Value string `json:"value"` // This is the actual USDT value from API
|
||||
Side string `json:"side"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &rawResponse); err != nil {
|
||||
t.Fatalf("Failed to parse raw response: %v", err)
|
||||
}
|
||||
|
||||
// Get trades via GetTrades
|
||||
trades, err := trader.GetTrades(time.Time{}, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get trades: %v", err)
|
||||
}
|
||||
|
||||
// Build a map of tradeID -> calculated value
|
||||
calculatedValues := make(map[string]float64)
|
||||
for _, trade := range trades {
|
||||
calculatedValues[trade.TradeID] = trade.FillPrice * trade.FillQty
|
||||
}
|
||||
|
||||
t.Logf("Comparing API value vs calculated value (price * qty):")
|
||||
t.Logf("==========================================")
|
||||
|
||||
errorCount := 0
|
||||
for i, raw := range rawResponse.Items {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
|
||||
var apiValue float64
|
||||
fmt.Sscanf(raw.Value, "%f", &apiValue)
|
||||
|
||||
calculatedValue, exists := calculatedValues[raw.TradeId]
|
||||
if !exists {
|
||||
t.Errorf("Trade %s not found in GetTrades result", raw.TradeId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Allow 1% tolerance for rounding
|
||||
tolerance := apiValue * 0.01
|
||||
diff := calculatedValue - apiValue
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
status := "✅"
|
||||
if diff > tolerance {
|
||||
status = "❌"
|
||||
errorCount++
|
||||
}
|
||||
|
||||
t.Logf(" %s [%d] %s: API value=%.4f, Calculated=%.4f, Diff=%.4f",
|
||||
status, i+1, raw.Symbol, apiValue, calculatedValue, diff)
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("Found %d trades with incorrect value calculation", errorCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinEntryExitPrice tests that entry/exit prices are correctly captured
|
||||
func TestKuCoinEntryExitPrice(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
trades, err := trader.GetTrades(time.Time{}, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get trades: %v", err)
|
||||
}
|
||||
|
||||
// Group trades by symbol to track entry/exit
|
||||
type PositionTracker struct {
|
||||
OpenTrades []KuCoinTrade
|
||||
CloseTrades []KuCoinTrade
|
||||
}
|
||||
positions := make(map[string]*PositionTracker)
|
||||
|
||||
for _, trade := range trades {
|
||||
if positions[trade.Symbol] == nil {
|
||||
positions[trade.Symbol] = &PositionTracker{}
|
||||
}
|
||||
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
|
||||
positions[trade.Symbol].OpenTrades = append(positions[trade.Symbol].OpenTrades, trade)
|
||||
} else {
|
||||
positions[trade.Symbol].CloseTrades = append(positions[trade.Symbol].CloseTrades, trade)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Entry/Exit price analysis:")
|
||||
t.Logf("==========================")
|
||||
|
||||
for symbol, pos := range positions {
|
||||
if len(pos.OpenTrades) == 0 && len(pos.CloseTrades) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate weighted average entry price
|
||||
var totalEntryValue, totalEntryQty float64
|
||||
for _, trade := range pos.OpenTrades {
|
||||
totalEntryValue += trade.FillPrice * trade.FillQty
|
||||
totalEntryQty += trade.FillQty
|
||||
}
|
||||
avgEntryPrice := 0.0
|
||||
if totalEntryQty > 0 {
|
||||
avgEntryPrice = totalEntryValue / totalEntryQty
|
||||
}
|
||||
|
||||
// Calculate weighted average exit price
|
||||
var totalExitValue, totalExitQty float64
|
||||
for _, trade := range pos.CloseTrades {
|
||||
totalExitValue += trade.FillPrice * trade.FillQty
|
||||
totalExitQty += trade.FillQty
|
||||
}
|
||||
avgExitPrice := 0.0
|
||||
if totalExitQty > 0 {
|
||||
avgExitPrice = totalExitValue / totalExitQty
|
||||
}
|
||||
|
||||
// Calculate P&L (simplified: (exit - entry) * qty for long)
|
||||
pnl := 0.0
|
||||
if totalEntryQty > 0 && totalExitQty > 0 {
|
||||
// Use the smaller qty for P&L calculation
|
||||
closedQty := totalExitQty
|
||||
if totalEntryQty < closedQty {
|
||||
closedQty = totalEntryQty
|
||||
}
|
||||
pnl = (avgExitPrice - avgEntryPrice) * closedQty
|
||||
}
|
||||
|
||||
t.Logf(" %s:", symbol)
|
||||
t.Logf(" Entry: %d trades, total qty=%.6f, avg price=%.6f, value=%.2f USDT",
|
||||
len(pos.OpenTrades), totalEntryQty, avgEntryPrice, totalEntryValue)
|
||||
t.Logf(" Exit: %d trades, total qty=%.6f, avg price=%.6f, value=%.2f USDT",
|
||||
len(pos.CloseTrades), totalExitQty, avgExitPrice, totalExitValue)
|
||||
t.Logf(" Calculated P&L: %.4f USDT", pnl)
|
||||
|
||||
// Verify entry qty matches exit qty for closed positions
|
||||
if len(pos.OpenTrades) > 0 && len(pos.CloseTrades) > 0 {
|
||||
qtyDiff := totalEntryQty - totalExitQty
|
||||
if qtyDiff < 0 {
|
||||
qtyDiff = -qtyDiff
|
||||
}
|
||||
tolerance := totalEntryQty * 0.001 // 0.1% tolerance
|
||||
if qtyDiff > tolerance {
|
||||
t.Logf(" ⚠️ Entry/Exit qty mismatch: %.6f", qtyDiff)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKuCoinPnLCalculation tests P&L calculation against actual exchange data
|
||||
func TestKuCoinPnLCalculation(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
// Get current balance for reference
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not get balance: %v", err)
|
||||
} else {
|
||||
t.Logf("Current account balance:")
|
||||
t.Logf(" Total equity: %v", balance["totalEquity"])
|
||||
t.Logf(" Available: %v", balance["availableBalance"])
|
||||
}
|
||||
|
||||
trades, err := trader.GetTrades(time.Time{}, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get trades: %v", err)
|
||||
}
|
||||
|
||||
// Group by symbol and calculate P&L
|
||||
type SymbolPnL struct {
|
||||
Symbol string
|
||||
TotalFees float64
|
||||
GrossPnL float64 // From price difference
|
||||
NetPnL float64 // Gross - fees
|
||||
OpenQty float64
|
||||
CloseQty float64
|
||||
AvgOpenPrice float64
|
||||
AvgClosePrice float64
|
||||
}
|
||||
pnlBySymbol := make(map[string]*SymbolPnL)
|
||||
|
||||
for _, trade := range trades {
|
||||
if pnlBySymbol[trade.Symbol] == nil {
|
||||
pnlBySymbol[trade.Symbol] = &SymbolPnL{Symbol: trade.Symbol}
|
||||
}
|
||||
p := pnlBySymbol[trade.Symbol]
|
||||
p.TotalFees += trade.Fee
|
||||
|
||||
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
|
||||
p.OpenQty += trade.FillQty
|
||||
p.AvgOpenPrice = (p.AvgOpenPrice*(p.OpenQty-trade.FillQty) + trade.FillPrice*trade.FillQty) / p.OpenQty
|
||||
} else {
|
||||
p.CloseQty += trade.FillQty
|
||||
p.AvgClosePrice = (p.AvgClosePrice*(p.CloseQty-trade.FillQty) + trade.FillPrice*trade.FillQty) / p.CloseQty
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\nP&L Summary by Symbol:")
|
||||
t.Logf("======================")
|
||||
|
||||
var totalGrossPnL, totalFees, totalNetPnL float64
|
||||
|
||||
for symbol, p := range pnlBySymbol {
|
||||
closedQty := p.CloseQty
|
||||
if p.OpenQty < closedQty {
|
||||
closedQty = p.OpenQty
|
||||
}
|
||||
|
||||
// For LONG: P&L = (exitPrice - entryPrice) * qty
|
||||
if closedQty > 0 && p.AvgOpenPrice > 0 && p.AvgClosePrice > 0 {
|
||||
p.GrossPnL = (p.AvgClosePrice - p.AvgOpenPrice) * closedQty
|
||||
p.NetPnL = p.GrossPnL - p.TotalFees
|
||||
}
|
||||
|
||||
totalGrossPnL += p.GrossPnL
|
||||
totalFees += p.TotalFees
|
||||
totalNetPnL += p.NetPnL
|
||||
|
||||
t.Logf(" %s:", symbol)
|
||||
t.Logf(" Open: qty=%.6f @ avg price=%.6f", p.OpenQty, p.AvgOpenPrice)
|
||||
t.Logf(" Close: qty=%.6f @ avg price=%.6f", p.CloseQty, p.AvgClosePrice)
|
||||
t.Logf(" Fees: %.4f USDT", p.TotalFees)
|
||||
t.Logf(" Gross P&L: %.4f USDT", p.GrossPnL)
|
||||
t.Logf(" Net P&L: %.4f USDT", p.NetPnL)
|
||||
}
|
||||
|
||||
t.Logf("\nTotal Summary:")
|
||||
t.Logf(" Total Gross P&L: %.4f USDT", totalGrossPnL)
|
||||
t.Logf(" Total Fees: %.4f USDT", totalFees)
|
||||
t.Logf(" Total Net P&L: %.4f USDT", totalNetPnL)
|
||||
}
|
||||
|
||||
// TestKuCoinGetTradesDebug tests GetTrades with detailed debugging
|
||||
func TestKuCoinGetTradesDebug(t *testing.T) {
|
||||
trader := createKuCoinTestTrader(t)
|
||||
|
||||
// Test with different time windows
|
||||
timeWindows := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
}{
|
||||
{"1 hour", 1 * time.Hour},
|
||||
{"24 hours", 24 * time.Hour},
|
||||
{"7 days", 7 * 24 * time.Hour},
|
||||
{"no filter", 0},
|
||||
}
|
||||
|
||||
for _, tw := range timeWindows {
|
||||
var startTime time.Time
|
||||
var path string
|
||||
if tw.duration > 0 {
|
||||
startTime = time.Now().Add(-tw.duration)
|
||||
path = fmt.Sprintf("%s?pageSize=100&startAt=%d", kucoinFillsPath, startTime.UnixMilli())
|
||||
} else {
|
||||
path = fmt.Sprintf("%s?pageSize=100", kucoinFillsPath)
|
||||
}
|
||||
|
||||
data, err := trader.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get fills for %s: %v", tw.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse to count items
|
||||
var resp struct {
|
||||
TotalNum int `json:"totalNum"`
|
||||
Items []struct {
|
||||
TradeTime int64 `json:"tradeTime"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
t.Logf("📋 %s: totalNum=%d, items=%d", tw.name, resp.TotalNum, len(resp.Items))
|
||||
if len(resp.Items) > 0 {
|
||||
firstTime := time.Unix(0, resp.Items[0].TradeTime)
|
||||
t.Logf(" First trade time: %s", firstTime.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tradertypes "nofx/trader/types"
|
||||
)
|
||||
|
||||
// Test configuration - uses environment variables for security
|
||||
@@ -686,7 +684,7 @@ func TestLighterPlaceLimitOrder(t *testing.T) {
|
||||
limitPrice := marketPrice * 0.75
|
||||
quantity := 0.01
|
||||
|
||||
req := &tradertypes.LimitOrderRequest{
|
||||
req := &LimitOrderRequest{
|
||||
Symbol: "ETH",
|
||||
Side: "BUY",
|
||||
PositionSide: "LONG",
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -399,14 +398,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) ([]tradertypes.ClosedPnLRecord, error) {
|
||||
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []tradertypes.ClosedPnLRecord
|
||||
var records []ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
@@ -428,7 +427,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]trader
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, tradertypes.ClosedPnLRecord{
|
||||
records = append(records, ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -448,7 +447,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]trader
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Lighter
|
||||
func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertypes.TradeRecord, error) {
|
||||
func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
// Ensure we have account index
|
||||
if t.accountIndex == 0 {
|
||||
if err := t.initializeAccount(); err != nil {
|
||||
@@ -491,7 +490,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
|
||||
return []tradertypes.TradeRecord{}, nil
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Debug: log raw response
|
||||
@@ -503,14 +502,14 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
|
||||
var trades []LighterTrade
|
||||
if err := json.Unmarshal(body, &trades); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse trades response as array: %v", err)
|
||||
return []tradertypes.TradeRecord{}, nil
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
response.Trades = trades
|
||||
}
|
||||
|
||||
if response.Code != 200 && response.Code != 0 {
|
||||
logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code)
|
||||
return []tradertypes.TradeRecord{}, nil
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Build market_id -> symbol map
|
||||
@@ -529,7 +528,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
|
||||
}
|
||||
|
||||
// Convert to unified TradeRecord format
|
||||
var result []tradertypes.TradeRecord
|
||||
var result []TradeRecord
|
||||
for _, lt := range response.Trades {
|
||||
price, _ := parseFloat(lt.Price)
|
||||
qty, _ := parseFloat(lt.Size)
|
||||
@@ -616,7 +615,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
|
||||
openSide, openAction = "LONG", "open_long"
|
||||
}
|
||||
|
||||
closeTrade := tradertypes.TradeRecord{
|
||||
closeTrade := TradeRecord{
|
||||
TradeID: fmt.Sprintf("%d_close", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -630,7 +629,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
|
||||
}
|
||||
result = append(result, closeTrade)
|
||||
|
||||
openTrade := tradertypes.TradeRecord{
|
||||
openTrade := TradeRecord{
|
||||
TradeID: fmt.Sprintf("%d_open", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -672,7 +671,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
|
||||
}
|
||||
}
|
||||
|
||||
trade := tradertypes.TradeRecord{
|
||||
trade := TradeRecord{
|
||||
TradeID: fmt.Sprintf("%d", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
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_types.go
|
||||
// Return in standard format compatible with auto_trader.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_types.go
|
||||
// Return in standard format compatible with auto_trader.go
|
||||
result = append(result, map[string]interface{}{
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
tradertypes "nofx/trader/types"
|
||||
)
|
||||
|
||||
// OpenLong Open long position (implements Trader interface)
|
||||
@@ -857,14 +856,14 @@ func pow10(n int) int64 {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder, error) {
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]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 []tradertypes.OpenOrder
|
||||
var result []OpenOrder
|
||||
for _, order := range activeOrders {
|
||||
// Convert side: Lighter uses is_ask (true=sell, false=buy)
|
||||
side := "BUY"
|
||||
@@ -906,7 +905,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder,
|
||||
}
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
|
||||
openOrder := tradertypes.OpenOrder{
|
||||
openOrder := OpenOrder{
|
||||
OrderID: order.OrderID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -926,7 +925,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder,
|
||||
|
||||
// PlaceLimitOrder implements GridTrader interface for grid trading
|
||||
// Places a limit order at the specified price
|
||||
func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*tradertypes.LimitOrderResult, error) {
|
||||
func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
@@ -961,7 +960,7 @@ func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*
|
||||
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
|
||||
req.Symbol, req.Side, req.Price, orderID)
|
||||
|
||||
return &tradertypes.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,14 +7,6 @@ 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 okx
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package okx
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// OKX API endpoints
|
||||
@@ -1282,7 +1281,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) ([]types.ClosedPnLRecord, error) {
|
||||
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1329,10 +1328,10 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Closed
|
||||
return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
records := make([]types.ClosedPnLRecord, 0, len(resp.Data))
|
||||
records := make([]ClosedPnLRecord, 0, len(resp.Data))
|
||||
|
||||
for _, pos := range resp.Data {
|
||||
record := types.ClosedPnLRecord{}
|
||||
record := ClosedPnLRecord{}
|
||||
|
||||
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
|
||||
parts := strings.Split(pos.InstID, "-")
|
||||
@@ -1390,9 +1389,9 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Closed
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
instId := t.convertSymbol(symbol)
|
||||
var result []types.OpenOrder
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
|
||||
@@ -1423,7 +1422,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrdId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -1439,8 +1438,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
}
|
||||
|
||||
// 2. Get pending algo orders (stop-loss/take-profit)
|
||||
// OKX requires ordType parameter for algo orders API
|
||||
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP&ordType=conditional", okxAlgoPendingPath, instId)
|
||||
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxAlgoPendingPath, instId)
|
||||
algoData, err := t.doRequest("GET", algoPath, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
|
||||
@@ -1453,13 +1451,12 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
PosSide string `json:"posSide"`
|
||||
OrdType string `json:"ordType"` // conditional/oco/trigger
|
||||
TriggerPx string `json:"triggerPx"`
|
||||
SlTriggerPx string `json:"slTriggerPx"` // Stop loss trigger price
|
||||
TpTriggerPx string `json:"tpTriggerPx"` // Take profit trigger price
|
||||
Sz string `json:"sz"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(algoData, &algoOrders); err == nil {
|
||||
for _, order := range algoOrders {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
@@ -1468,59 +1465,23 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
// Check for stop loss order (slTriggerPx is set)
|
||||
if order.SlTriggerPx != "" {
|
||||
slPrice, _ := strconv.ParseFloat(order.SlTriggerPx, 64)
|
||||
if slPrice > 0 {
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.AlgoId + "_sl",
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: "STOP_MARKET",
|
||||
Price: 0,
|
||||
StopPrice: slPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
// Map OKX algo order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.OrdType == "oco" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
// Check for take profit order (tpTriggerPx is set)
|
||||
if order.TpTriggerPx != "" {
|
||||
tpPrice, _ := strconv.ParseFloat(order.TpTriggerPx, 64)
|
||||
if tpPrice > 0 {
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.AlgoId + "_tp",
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: "TAKE_PROFIT_MARKET",
|
||||
Price: 0,
|
||||
StopPrice: tpPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for trigger orders (triggerPx is set)
|
||||
if order.TriggerPx != "" && order.SlTriggerPx == "" && order.TpTriggerPx == "" {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
|
||||
if triggerPrice > 0 {
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.AlgoId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: "STOP_MARKET",
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.AlgoId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,7 +1492,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
instId := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Get instrument info
|
||||
@@ -1605,7 +1566,7 @@ func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitO
|
||||
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
instId, side, req.Price, orders[0].OrdId)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
return &LimitOrderResult{
|
||||
OrderID: orders[0].OrdId,
|
||||
ClientID: orders[0].ClOrdId,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,11 +1,10 @@
|
||||
package testutil
|
||||
package trader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/agiledragon/gomonkey/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// TraderTestSuite Generic Trader interface test suite (base suite)
|
||||
@@ -17,12 +16,12 @@ import (
|
||||
// 3. Call RunAllTests() to run all generic tests
|
||||
type TraderTestSuite struct {
|
||||
T *testing.T
|
||||
Trader types.Trader
|
||||
Trader Trader
|
||||
Patches *gomonkey.Patches
|
||||
}
|
||||
|
||||
// NewTraderTestSuite Create new base test suite
|
||||
func NewTraderTestSuite(t *testing.T, trader types.Trader) *TraderTestSuite {
|
||||
func NewTraderTestSuite(t *testing.T, trader Trader) *TraderTestSuite {
|
||||
return &TraderTestSuite{
|
||||
T: t,
|
||||
Trader: trader,
|
||||
@@ -1,230 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 952 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.8095 0.263396C16.8864 1.28889 10.9524 4.62174 6.15385 10.2253C3.99267 12.7524 1.50183 17.8066 0.659341 21.3592C0.03663 24.1061 0 29.3434 0 150.022C0 270.7 0.03663 275.938 0.659341 278.684C1.50183 282.274 3.99267 287.291 6.26374 289.928C10.1832 294.58 15.7143 298.022 21.3187 299.341C24.0659 299.963 29.304 300 150 300C270.696 300 275.934 299.963 278.681 299.341C284.286 298.022 289.817 294.58 293.736 289.928C296.007 287.291 298.498 282.274 299.341 278.684C299.963 275.938 300 270.7 300 150.022C300 29.3434 299.963 24.1061 299.341 21.3592C297.033 11.6171 289.341 3.66949 279.414 0.849391L276.557 0.0436474L151.282 0.00702266C81.3919 -0.0296021 25.0549 0.0802721 23.8095 0.263396ZM92.381 77.2119C96.1905 81.0941 104.469 89.5911 110.769 96.037L122.234 107.83L124.249 108.05C126.777 108.343 128.645 109.295 130.293 111.126C131.905 112.921 132.564 114.789 132.527 117.426C132.454 122.517 128.645 126.216 123.443 126.216C118.681 126.216 115.421 123.432 114.322 118.525C114.139 117.682 108.901 112.189 89.1209 91.9717L85.1648 87.9429L77.3993 95.7074L69.5971 103.508L89.7436 123.652L109.89 143.796H116.374C119.963 143.796 122.894 143.905 122.894 144.089C122.894 144.235 110.952 156.065 96.337 170.349C81.7216 184.669 69.7802 196.425 69.7802 196.535C69.7802 196.645 73.2967 200.234 77.5824 204.519L85.4212 212.32L110.842 186.537L136.264 160.716L150.403 160.68L164.506 160.643L168.901 165.148C174.249 170.642 177.729 174.231 198.169 195.546L214.249 212.32L222.051 204.519C226.337 200.234 229.817 196.608 229.744 196.425C229.707 196.279 224.286 190.492 217.692 183.57C206.557 171.887 205.568 170.971 203.956 170.495C201.685 169.872 198.718 166.979 198.059 164.818C196.337 158.922 199.927 153.318 205.824 152.695C211.136 152.146 215.348 155.772 215.897 161.339L216.117 163.829L231.319 180.054C239.67 188.954 246.557 196.462 246.63 196.682C246.667 196.901 239.414 204.483 230.476 213.529L214.286 229.974L189.194 203.824C175.421 189.43 162.821 176.318 161.209 174.634L158.278 171.63H150.733H143.187L133.333 181.592C127.912 187.086 114.908 200.198 104.432 210.746L85.3846 229.9L68.6813 213.199L52.0147 196.535L75.2747 173.279L98.5348 150.022L75.2747 126.765L52.0147 103.508L68.6813 86.8442C77.8388 77.688 85.348 70.18 85.3846 70.18C85.4212 70.18 88.5348 73.3663 92.381 77.2119ZM231.355 86.5512C240.403 95.5243 247.802 103.032 247.802 103.215C247.802 103.435 242.381 109.039 235.788 115.704C209.121 142.697 191.209 161.009 191.209 161.375C191.209 161.559 196.374 167.382 202.674 174.304L214.139 186.903L216.154 187.342C220.366 188.295 223.26 191.957 223.26 196.352C223.26 201.553 219.341 205.435 214.103 205.398C209.414 205.398 205.751 202.102 205.018 197.304C204.762 195.473 204.652 195.363 188.205 178.443C179.084 169.067 171.612 161.229 171.612 161.046C171.612 160.863 172.711 159.691 174.103 158.409C177.802 154.93 229.67 104.168 229.78 103.875C229.927 103.508 214.506 88.0528 214.139 88.1993C213.846 88.3092 194.029 107.684 169.78 131.563L161.172 140.06L136.813 139.877L112.454 139.694L99.0843 126.399C86.4103 113.8 85.641 113.104 84.1392 112.884C81.0256 112.408 78.0586 110.138 76.8865 107.317C76.1538 105.559 76.2637 101.787 77.0696 100.029C77.9487 98.1246 80.696 95.5975 82.4542 95.1214C87.9487 93.5831 93.2234 96.623 94.359 101.934L94.7985 104.058L107.106 116.217L119.414 128.377H137.912L156.41 128.413L185.531 99.2966C201.538 83.2916 214.689 70.18 214.762 70.18C214.835 70.18 222.271 77.5415 231.355 86.5512ZM216.813 95.4876C218.718 96.0004 221.978 99.26 222.491 101.164C224.103 106.915 220.733 112.481 215.128 113.251C213.626 113.47 212.344 114.642 191.392 135.042L169.231 156.578L151.832 156.614H134.432L114.615 176.465C96.63 194.484 94.7253 196.499 94.5055 197.817C93.2967 204.812 85.2747 207.742 79.4506 203.311C75.3846 200.198 75.4212 192.69 79.5238 189.284C80.9158 188.112 83.7729 187.013 85.3846 187.013C85.9341 187.013 92.3443 180.86 107.802 165.404L129.414 143.796H146.923H164.469L184.432 124.311C204.249 104.937 204.396 104.79 204.652 103.032C205.568 97.209 210.952 93.8761 216.813 95.4876Z" fill="#0184B5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Anthropic</title>
|
||||
<path fill="#CC785C" d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/>
|
||||
<title>Claude</title>
|
||||
<path fill="#D97757" d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 301 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<rect width="24" height="24" rx="4" fill="#E45735"/>
|
||||
<text x="12" y="16" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="10" fill="white">M</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 266 B |
@@ -77,7 +77,7 @@ const AI_PROVIDER_CONFIG: Record<string, {
|
||||
apiName: 'OpenAI',
|
||||
},
|
||||
claude: {
|
||||
defaultModel: 'claude-opus-4-6',
|
||||
defaultModel: 'claude-opus-4-5-20251101',
|
||||
apiUrl: 'https://console.anthropic.com/settings/keys',
|
||||
apiName: 'Anthropic',
|
||||
},
|
||||
@@ -96,11 +96,6 @@ const AI_PROVIDER_CONFIG: Record<string, {
|
||||
apiUrl: 'https://platform.moonshot.ai/console/api-keys',
|
||||
apiName: 'Moonshot',
|
||||
},
|
||||
minimax: {
|
||||
defaultModel: 'MiniMax-M2.5',
|
||||
apiUrl: 'https://platform.minimax.io',
|
||||
apiName: 'MiniMax',
|
||||
},
|
||||
}
|
||||
|
||||
interface AITradersPageProps {
|
||||
@@ -1389,99 +1384,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Step indicator component for Model Config
|
||||
function ModelStepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{labels.map((label, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
|
||||
style={{
|
||||
background: index < currentStep ? '#0ECB81' : index === currentStep ? '#8B5CF6' : '#2B3139',
|
||||
color: index <= currentStep ? '#000' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{index < currentStep ? <Check className="w-4 h-4" /> : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium hidden sm:block"
|
||||
style={{ color: index === currentStep ? '#EAECEF' : '#848E9C' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{index < labels.length - 1 && (
|
||||
<div
|
||||
className="w-8 h-0.5 mx-1"
|
||||
style={{ background: index < currentStep ? '#0ECB81' : '#2B3139' }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Model card component
|
||||
function ModelCard({
|
||||
model,
|
||||
selected,
|
||||
onClick,
|
||||
configured,
|
||||
}: {
|
||||
model: AIModel
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
configured?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-xl transition-all hover:scale-105"
|
||||
style={{
|
||||
background: selected ? 'rgba(139, 92, 246, 0.15)' : '#0B0E11',
|
||||
border: selected ? '2px solid #8B5CF6' : '2px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-black border border-white/10">
|
||||
{getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || (
|
||||
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{model.name[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<div
|
||||
className="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{ background: '#0ECB81' }}
|
||||
>
|
||||
<Check className="w-3 h-3 text-black" />
|
||||
</div>
|
||||
)}
|
||||
{configured && !selected && (
|
||||
<div
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center"
|
||||
style={{ background: '#F0B90B' }}
|
||||
>
|
||||
<Check className="w-2.5 h-2.5 text-black" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(model.name)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full uppercase tracking-wide"
|
||||
style={{ background: 'rgba(139, 92, 246, 0.2)', color: '#A78BFA' }}
|
||||
>
|
||||
{model.provider}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Model Configuration Modal Component
|
||||
function ModelConfigModal({
|
||||
allModels,
|
||||
@@ -1505,16 +1407,17 @@ function ModelConfigModal({
|
||||
onClose: () => void
|
||||
language: Language
|
||||
}) {
|
||||
const [currentStep, setCurrentStep] = useState(editingModelId ? 1 : 0)
|
||||
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [modelName, setModelName] = useState('')
|
||||
|
||||
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找
|
||||
const selectedModel = editingModelId
|
||||
? configuredModels?.find((m) => m.id === selectedModelId)
|
||||
: allModels?.find((m) => m.id === selectedModelId)
|
||||
|
||||
// 如果是编辑现有模型,初始化API Key、Base URL和Model Name
|
||||
useEffect(() => {
|
||||
if (editingModelId && selectedModel) {
|
||||
setApiKey(selectedModel.apiKey || '')
|
||||
@@ -1523,239 +1426,266 @@ function ModelConfigModal({
|
||||
}
|
||||
}, [editingModelId, selectedModel])
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
setSelectedModelId(modelId)
|
||||
setCurrentStep(1)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (editingModelId) {
|
||||
onClose()
|
||||
} else {
|
||||
setCurrentStep(0)
|
||||
setSelectedModelId('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedModelId || !apiKey.trim()) return
|
||||
onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined)
|
||||
|
||||
onSave(
|
||||
selectedModelId,
|
||||
apiKey.trim(),
|
||||
baseUrl.trim() || undefined,
|
||||
modelName.trim() || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// 可选择的模型列表(所有支持的模型)
|
||||
const availableModels = allModels || []
|
||||
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
|
||||
const stepLabels = language === 'zh' ? ['选择模型', '配置 API'] : ['Select Model', 'Configure API']
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
className="rounded-2xl w-full max-w-2xl relative my-8 shadow-2xl"
|
||||
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)', maxHeight: 'calc(100vh - 4rem)' }}
|
||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{currentStep > 0 && !editingModelId && (
|
||||
<button type="button" onClick={handleBack} className="p-2 rounded-lg hover:bg-white/10 transition-colors">
|
||||
<svg className="w-5 h-5" style={{ color: '#848E9C' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingModelId ? t('editAIModel', language) : t('addAIModel', language)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingModelId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(editingModelId)}
|
||||
className="p-2 rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
style={{ color: '#F6465D' }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors" style={{ color: '#848E9C' }}>
|
||||
✕
|
||||
<div
|
||||
className="flex items-center justify-between p-6 pb-4 sticky top-0 z-10"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingModelId
|
||||
? t('editAIModel', language)
|
||||
: t('addAIModel', language)}
|
||||
</h3>
|
||||
{editingModelId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(editingModelId)}
|
||||
className="p-2 rounded hover:bg-red-100 transition-colors"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
||||
title={t('delete', language)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
{!editingModelId && (
|
||||
<div className="px-6">
|
||||
<ModelStepIndicator currentStep={currentStep} labels={stepLabels} />
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
{!editingModelId && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('selectModel', language)}
|
||||
</label>
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">{t('pleaseSelectModel', language)}</option>
|
||||
{availableModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{getShortName(model.name)} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 16rem)' }}>
|
||||
{/* Step 0: Select Model */}
|
||||
{currentStep === 0 && !editingModelId && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{language === 'zh' ? '选择 AI 模型提供商' : 'Choose Your AI Provider'}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{availableModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => handleSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '带金色标记的模型已配置' : 'Models with gold badge are already configured'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Configure */}
|
||||
{(currentStep === 1 || editingModelId) && selectedModel && (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Selected Model Header */}
|
||||
<div className="p-4 rounded-xl flex items-center gap-4" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-black border border-white/10">
|
||||
{getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || (
|
||||
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{selectedModel.name[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-lg" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(selectedModel.name)}
|
||||
{selectedModel && (
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{getModelIcon(selectedModel.provider || selectedModel.id, {
|
||||
width: 32,
|
||||
height: 32,
|
||||
}) || (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background:
|
||||
selectedModel.id === 'deepseek'
|
||||
? '#60a5fa'
|
||||
: '#c084fc',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{selectedModel.name[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedModel.provider} • {AI_PROVIDER_CONFIG[selectedModel.provider]?.defaultModel || selectedModel.id}
|
||||
</div>
|
||||
</div>
|
||||
{AI_PROVIDER_CONFIG[selectedModel.provider] && (
|
||||
<a
|
||||
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all hover:scale-105"
|
||||
style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.3)' }}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#A78BFA' }}>
|
||||
{language === 'zh' ? '获取 API Key' : 'Get API Key'}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kimi Warning */}
|
||||
{selectedModel.provider === 'kimi' && (
|
||||
<div className="p-4 rounded-xl" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span style={{ fontSize: '16px' }}>⚠️</span>
|
||||
<div className="text-sm" style={{ color: '#F6465D' }}>
|
||||
{t('kimiApiNote', language)}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(selectedModel.name)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedModel.provider} • {selectedModel.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
API Key *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('enterAPIKey', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
{/* Default model info and API link */}
|
||||
{AI_PROVIDER_CONFIG[selectedModel.provider] && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid #2B3139' }}>
|
||||
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('defaultModel', language)}: <span style={{ color: '#F0B90B' }}>{AI_PROVIDER_CONFIG[selectedModel.provider].defaultModel}</span>
|
||||
</div>
|
||||
<a
|
||||
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs hover:underline"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t('applyApiKey', language)} → {AI_PROVIDER_CONFIG[selectedModel.provider].apiName}
|
||||
</a>
|
||||
{selectedModel.provider === 'kimi' && (
|
||||
<div className="mt-2 text-xs p-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
⚠️ {t('kimiApiNote', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Base URL */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{t('customBaseURL', language)}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={t('customBaseURLPlaceholder', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefault', language)}
|
||||
{selectedModel && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('enterAPIKey', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Model Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{t('customModelName', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder={t('customModelNamePlaceholder', language)}
|
||||
className="w-full px-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefaultModel', language)}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('customBaseURL', language)}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={t('customBaseURLPlaceholder', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefault', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-4 rounded-xl" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
|
||||
<div className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: '#A78BFA' }}>
|
||||
<Brain className="w-4 h-4" />
|
||||
{t('information', language)}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('customModelName', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder={t('customModelNamePlaceholder', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefaultModel', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>• {t('modelConfigInfo1', language)}</div>
|
||||
<div>• {t('modelConfigInfo2', language)}</div>
|
||||
<div>• {t('modelConfigInfo3', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={handleBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{editingModelId ? t('cancel', language) : (language === 'zh' ? '返回' : 'Back')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedModel || !apiKey.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: '#8B5CF6', color: '#fff' }}
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
{t('saveConfig', language)}
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
ℹ️ {t('information', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs space-y-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<div>{t('modelConfigInfo1', language)}</div>
|
||||
<div>{t('modelConfigInfo2', language)}</div>
|
||||
<div>{t('modelConfigInfo3', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedModel || !apiKey.trim()}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('saveConfig', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
1000 // 默认值(与创建交易员时的默认配置一致)
|
||||
|
||||
// 转换数据格式
|
||||
const chartData = displayHistory.map((point, index) => {
|
||||
const chartData = displayHistory.map((point) => {
|
||||
const pnl = point.total_equity - initialBalance
|
||||
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
|
||||
return {
|
||||
@@ -151,7 +151,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
minute: '2-digit',
|
||||
}),
|
||||
value: displayMode === 'dollar' ? point.total_equity : parseFloat(pnlPct),
|
||||
cycle: point.cycle_number ?? index + 1,
|
||||
cycle: point.cycle_number,
|
||||
raw_equity: point.total_equity,
|
||||
raw_pnl: pnl,
|
||||
raw_pnl_pct: parseFloat(pnlPct),
|
||||
@@ -192,7 +192,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
Cycle #{data.cycle != null ? data.cycle : '—'}
|
||||
Cycle #{data.cycle}
|
||||
</div>
|
||||
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
{data.raw_equity.toFixed(2)} USDT
|
||||
|
||||
@@ -12,12 +12,9 @@ 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',
|
||||
lighter: '/exchange-icons/lighter.png',
|
||||
indodax: '/exchange-icons/indodax.png',
|
||||
}
|
||||
|
||||
// 通用图标组件
|
||||
@@ -92,19 +89,13 @@ export const getExchangeIcon = (
|
||||
? 'okx'
|
||||
: lowerType.includes('bitget')
|
||||
? 'bitget'
|
||||
: lowerType.includes('gate')
|
||||
? 'gate'
|
||||
: lowerType.includes('kucoin')
|
||||
? 'kucoin'
|
||||
: lowerType.includes('hyperliquid')
|
||||
? 'hyperliquid'
|
||||
: lowerType.includes('aster')
|
||||
? 'aster'
|
||||
: lowerType.includes('lighter')
|
||||
? 'lighter'
|
||||
: lowerType.includes('indodax')
|
||||
? 'indodax'
|
||||
: lowerType
|
||||
: lowerType.includes('hyperliquid')
|
||||
? 'hyperliquid'
|
||||
: lowerType.includes('aster')
|
||||
? 'aster'
|
||||
: lowerType.includes('lighter')
|
||||
? 'lighter'
|
||||
: lowerType
|
||||
|
||||
const iconProps = {
|
||||
width: props.width || 24,
|
||||
|
||||
@@ -57,17 +57,6 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('id')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={
|
||||
language === 'id'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
ID
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -99,8 +99,8 @@ export default function HeaderBar({
|
||||
{(() => {
|
||||
// Define all navigation tabs
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
@@ -259,7 +259,7 @@ export default function HeaderBar({
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors text-nofx-text-muted hover:bg-white/5"
|
||||
>
|
||||
<span className="text-lg">
|
||||
{language === 'zh' ? '🇨🇳' : language === 'id' ? '🇮🇩' : '🇺🇸'}
|
||||
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -288,17 +288,6 @@ export default function HeaderBar({
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('id')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
|
||||
${language === 'id' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-base">🇮🇩</span>
|
||||
<span className="text-sm">Bahasa</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -340,8 +329,8 @@ export default function HeaderBar({
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
{(() => {
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
@@ -440,7 +429,7 @@ export default function HeaderBar({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Lang Switcher */}
|
||||
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
||||
{['zh', 'en', 'id'].map((lang) => (
|
||||
{['zh', 'en'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
@@ -452,7 +441,7 @@ export default function HeaderBar({
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
|
||||
{lang === 'zh' ? 'CN' : 'EN'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,15 @@ import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
const { login, loginAdmin } = useAuth()
|
||||
const { login, loginAdmin, verifyOTP, completeRegistration } = useAuth()
|
||||
const [step, setStep] = useState<'login' | 'otp' | 'setup-otp'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [userID, setUserID] = useState('')
|
||||
const [qrCodeURL, setQrCodeURL] = useState('') // New state for recovery
|
||||
const [otpSecret, setOtpSecret] = useState('') // New state for recovery
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
@@ -59,19 +64,83 @@ export function LoginPage() {
|
||||
const result = await login(email, password)
|
||||
|
||||
if (result.success) {
|
||||
// Dismiss the "login expired" toast on successful login.
|
||||
if (expiredToastId) {
|
||||
toast.dismiss(expiredToastId)
|
||||
// Check for incomplete OTP setup (user registered but didn't complete 2FA)
|
||||
if (result.requiresOTPSetup && result.userID) {
|
||||
setUserID(result.userID)
|
||||
setQrCodeURL(result.qrCodeURL || '')
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setStep('setup-otp')
|
||||
toast.info("Pending 2FA setup detected. Please complete configuration.")
|
||||
} else if (result.requiresOTP && result.userID) {
|
||||
setUserID(result.userID)
|
||||
|
||||
// Check if backend provided recovery data (meaning 2FA is pending setup)
|
||||
if (result.qrCodeURL) {
|
||||
setQrCodeURL(result.qrCodeURL)
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setStep('setup-otp')
|
||||
toast.info("Pending 2FA setup detected. Please complete configuration.")
|
||||
} else {
|
||||
setStep('otp')
|
||||
}
|
||||
} else {
|
||||
// Dismiss the "login expired" toast on successful login (no OTP required)
|
||||
if (expiredToastId) {
|
||||
toast.dismiss(expiredToastId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
// Check if we have recovery data despite the error (e.g. "Account has not completed OTP setup")
|
||||
if (result.qrCodeURL) {
|
||||
setUserID(result.userID || '') // We might need to ensure userID is returned in error case too, or derived
|
||||
setQrCodeURL(result.qrCodeURL)
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setStep('setup-otp')
|
||||
toast.warning(t('completeGapSetup', language) || "Incomplete setup detected. Please configure 2FA.")
|
||||
} else {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleOTPVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
// If we have qrCodeURL, it means user needs to complete registration (first time OTP setup)
|
||||
// Otherwise, it's a normal login OTP verification
|
||||
const result = qrCodeURL
|
||||
? await completeRegistration(userID, otpCode)
|
||||
: await verifyOTP(userID, otpCode)
|
||||
|
||||
if (!result.success) {
|
||||
const msg = result.message || t('verificationFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} else {
|
||||
// Dismiss the "login expired" toast on successful OTP verification
|
||||
if (expiredToastId) {
|
||||
toast.dismiss(expiredToastId)
|
||||
}
|
||||
// Clear qrCodeURL after successful completion
|
||||
setQrCodeURL('')
|
||||
setOtpSecret('')
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
|
||||
@@ -103,7 +172,7 @@ export function LoginPage() {
|
||||
<span className="text-nofx-gold">SYSTEM</span> ACCESS
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
|
||||
Authentication Protocol v3.0
|
||||
{step === 'login' ? 'Authentication Protocol v3.0' : 'Multi-Factor Verification'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +241,67 @@ export function LoginPage() {
|
||||
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
) : step === 'setup-otp' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center bg-zinc-900/50 p-4 rounded border border-zinc-800">
|
||||
<div className="text-xs font-mono text-zinc-400 mb-2">COMPLETE 2FA CONFIGURATION</div>
|
||||
{qrCodeURL ? (
|
||||
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
|
||||
alt="QR Code"
|
||||
className="w-32 h-32"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-32 bg-zinc-800 animate-pulse rounded inline-block"></div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Backup Secret Key</p>
|
||||
<div className="flex items-center gap-2 justify-center bg-black/50 p-2 rounded border border-zinc-700/50 max-w-[200px] mx-auto">
|
||||
<code className="text-xs font-mono text-nofx-gold">{otpSecret}</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(otpSecret)}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-[10px] uppercase border border-zinc-700 px-1 rounded">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">01</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Install Authenticator App</p>
|
||||
<p className="mb-2">Recommended: <span className="text-nofx-gold">Google Authenticator</span>.</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
|
||||
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">02</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Scan & Verify</p>
|
||||
<p>Scan code above, then enter the 6-digit token below to activate your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStep('otp')}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-colors font-mono shadow-lg"
|
||||
>
|
||||
I HAVE SCANNED THE CODE →
|
||||
</button>
|
||||
</div>
|
||||
) : step === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -205,6 +334,12 @@ export function LoginPage() {
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
aria-label={
|
||||
showPassword
|
||||
? t('hidePassword', language)
|
||||
: t('showPassword', language)
|
||||
}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
@@ -242,6 +377,59 @@ export function LoginPage() {
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-6">
|
||||
<div className="text-center py-2">
|
||||
<div className="w-12 h-12 bg-zinc-900 rounded-full flex items-center justify-center mx-auto mb-4 border border-zinc-700 text-2xl">
|
||||
🔐
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 font-mono leading-relaxed">
|
||||
{t('scanQRCodeInstructions', language)}<br />
|
||||
{t('enterOTPCode', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-2 text-center font-bold">
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) =>
|
||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||
}
|
||||
className="w-full bg-black border border-zinc-700 rounded px-4 py-4 text-center text-2xl tracking-[0.5em] font-mono text-white focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono text-center">
|
||||
[ACCESS DENIED]: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('login')}
|
||||
className="flex-1 bg-zinc-900 border border-zinc-700 text-zinc-400 py-3 rounded text-xs font-mono uppercase hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
< ABORT
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 bg-nofx-gold text-black font-bold py-3 rounded text-xs font-mono uppercase hover:bg-yellow-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'VERIFYING...' : 'CONFIRM IDENTITY'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,20 +40,6 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
|
||||
login: 'EXECUTE LOGIN',
|
||||
register: 'REGISTER NEW ID',
|
||||
later: 'ABORT'
|
||||
},
|
||||
id: {
|
||||
title: 'AKSES SISTEM DITOLAK',
|
||||
subtitle: featureName ? `Modul "${featureName}" memerlukan hak akses lebih tinggi` : 'Otorisasi diperlukan untuk modul ini',
|
||||
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI, aliran data Pasar Strategi, dan inti Simulasi Backtest.',
|
||||
benefits: [
|
||||
'Kontrol Trader AI',
|
||||
'Pasar Strategi HFT',
|
||||
'Mesin Backtest Historis',
|
||||
'Visualisasi Sistem Penuh'
|
||||
],
|
||||
login: 'JALANKAN LOGIN',
|
||||
register: 'DAFTAR ID BARU',
|
||||
later: 'BATALKAN'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const MODEL_COLORS: Record<string, string> = {
|
||||
gemini: '#4285F4',
|
||||
grok: '#000000',
|
||||
openai: '#10A37F',
|
||||
minimax: '#E45735',
|
||||
}
|
||||
|
||||
// 获取AI模型图标的函数
|
||||
@@ -45,9 +44,6 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
case 'openai':
|
||||
iconPath = '/icons/openai.svg'
|
||||
break
|
||||
case 'minimax':
|
||||
iconPath = '/icons/minimax.svg'
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||