mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-19 02:14:35 +08:00
Compare commits
1 Commits
palette/lo
...
fix/pr-tem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a579bc39d |
@@ -1,3 +0,0 @@
|
||||
## 2025-05-22 - [Accessibility: Password Toggles]
|
||||
**Learning:** Icon-only buttons for password visibility toggles are a common accessibility gap. Adding `aria-label` and `aria-pressed` ensures screen reader users understand the button's purpose and state.
|
||||
**Action:** Always check password fields and add these attributes along with corresponding translations in `translations.ts`.
|
||||
37
README.ja.md
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バトル
|
||||
|
||||
90
README.md
90
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
|
||||
@@ -50,12 +42,6 @@
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
### Official Links
|
||||
|
||||
- **Official Website**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
|
||||
|
||||
## Developer Community
|
||||
@@ -78,35 +64,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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -498,26 +482,6 @@ All contributions are tracked on GitHub. When NOFX generates revenue, contributo
|
||||
|
||||
---
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to all our sponsors!
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="60" height="60" style="border-radius:50%" alt="pjl914335852-ux" /></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="60" height="60" style="border-radius:50%" alt="cat9999aaa" /></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="60" height="60" style="border-radius:50%" alt="1733055465" /></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="60" height="60" style="border-radius:50%" alt="kolal2020" /></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="60" height="60" style="border-radius:50%" alt="CyberFFarm" /></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="60" height="60" style="border-radius:50%" alt="vip3001003" /></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="60" height="60" style="border-radius:50%" alt="mrtluh" /></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="60" height="60" style="border-radius:50%" alt="cpcp1117-source" /></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="60" height="60" style="border-radius:50%" alt="match-007" /></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="60" height="60" style="border-radius:50%" alt="leiwuhen1715" /></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="60" height="60" style="border-radius:50%" alt="SHAOXIA1991" /></a>
|
||||
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
158
api/server.go
158
api/server.go
@@ -20,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"
|
||||
@@ -166,7 +157,6 @@ func (s *Server) setupRoutes() {
|
||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
||||
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
||||
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
|
||||
|
||||
// AI model configuration
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
@@ -265,14 +255,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{
|
||||
@@ -294,9 +283,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
|
||||
}
|
||||
}
|
||||
@@ -594,43 +582,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,
|
||||
)
|
||||
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),
|
||||
@@ -638,7 +615,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,
|
||||
@@ -1119,20 +1096,6 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetGridRiskInfo returns current risk information for a grid trader
|
||||
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
|
||||
autoTrader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
|
||||
return
|
||||
}
|
||||
|
||||
riskInfo := autoTrader.GetGridRiskInfo()
|
||||
c.JSON(http.StatusOK, riskInfo)
|
||||
}
|
||||
|
||||
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -1163,43 +1126,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,
|
||||
)
|
||||
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),
|
||||
@@ -1207,7 +1159,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,
|
||||
@@ -1326,43 +1278,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,
|
||||
)
|
||||
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),
|
||||
@@ -1370,7 +1311,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,
|
||||
@@ -1428,7 +1369,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
if closeErr != nil {
|
||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
||||
SafeInternalError(c, "Close position", closeErr)
|
||||
SafeInternalError(c, "Failed to close position", closeErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1449,7 +1390,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
|
||||
}
|
||||
@@ -1764,15 +1705,8 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each model's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
// Update each model's configuration
|
||||
for modelID, modelData := range req.Models {
|
||||
// Find traders using this AI model BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
||||
@@ -1780,12 +1714,6 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
@@ -1897,15 +1825,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
// Update each exchange's configuration
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1913,12 +1834,6 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
@@ -2003,7 +1918,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,
|
||||
"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)})
|
||||
@@ -2535,16 +2450,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)
|
||||
@@ -3389,8 +3299,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"},
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// Lighter API Authentication Test Tool
|
||||
// Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=... [-testnet]
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
walletAddr := flag.String("wallet", "", "Ethereum wallet address")
|
||||
apiKeyPrivateKey := flag.String("apikey", "", "API key private key (40 bytes hex)")
|
||||
apiKeyIndex := flag.Int("apikeyindex", 0, "API key index (0-255)")
|
||||
testnet := flag.Bool("testnet", false, "Use testnet instead of mainnet")
|
||||
flag.Parse()
|
||||
|
||||
if *walletAddr == "" || *apiKeyPrivateKey == "" {
|
||||
fmt.Println("Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=...")
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" -wallet Ethereum wallet address (required)")
|
||||
fmt.Println(" -apikey API key private key, 40 bytes hex (required)")
|
||||
fmt.Println(" -apikeyindex API key index, 0-255 (default: 0)")
|
||||
fmt.Println(" -testnet Use testnet instead of mainnet")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Lighter API Authentication Test ===")
|
||||
fmt.Printf("Wallet: %s\n", *walletAddr)
|
||||
fmt.Printf("API Key Index: %d\n", *apiKeyIndex)
|
||||
fmt.Printf("Testnet: %v\n", *testnet)
|
||||
fmt.Println()
|
||||
|
||||
// Determine base URL
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(304)
|
||||
if *testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||
chainID = uint32(300)
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Step 1: Get account info
|
||||
fmt.Println("Step 1: Getting account info...")
|
||||
accountInfo, err := getAccountByL1Address(client, baseURL, *walletAddr)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to get account info: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("SUCCESS: Account index = %d\n\n", accountInfo.AccountIndex)
|
||||
|
||||
// Step 2: Create TxClient
|
||||
fmt.Println("Step 2: Creating TxClient...")
|
||||
txClient, err := lighterClient.NewTxClient(
|
||||
httpClient,
|
||||
*apiKeyPrivateKey,
|
||||
accountInfo.AccountIndex,
|
||||
uint8(*apiKeyIndex),
|
||||
chainID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("SUCCESS: TxClient created")
|
||||
|
||||
// Step 3: Generate auth token
|
||||
fmt.Println("Step 3: Generating auth token...")
|
||||
deadline := time.Now().Add(1 * time.Hour)
|
||||
authToken, err := txClient.GetAuthToken(deadline)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to generate auth token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("SUCCESS: Auth token generated\n")
|
||||
fmt.Printf("Token: %s...\n", authToken[:min(50, len(authToken))])
|
||||
fmt.Printf("Valid until: %s\n\n", deadline.Format(time.RFC3339))
|
||||
|
||||
// Step 4: Test GetActiveOrders API with auth query parameter
|
||||
fmt.Println("Step 4: Testing GetActiveOrders API...")
|
||||
encodedAuth := url.QueryEscape(authToken)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||
|
||||
fmt.Printf("Endpoint: %s...\n", endpoint[:min(120, len(endpoint))])
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("Response: %s\n\n", string(body))
|
||||
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("ERROR: Failed to parse response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
fmt.Printf("API ERROR: code=%d, message=%s\n", apiResp.Code, apiResp.Message)
|
||||
fmt.Println("\n=== DIAGNOSTIC INFO ===")
|
||||
fmt.Println("If you see 'invalid signature', possible causes:")
|
||||
fmt.Println("1. API key is not registered on-chain")
|
||||
fmt.Println("2. API key private key is incorrect")
|
||||
fmt.Println("3. API key index is wrong")
|
||||
fmt.Println("4. Account index mismatch")
|
||||
fmt.Println("\nTo fix:")
|
||||
fmt.Println("- Go to app.lighter.xyz and register/verify your API key")
|
||||
fmt.Println("- Make sure you're using the correct API key private key")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("SUCCESS: Retrieved %d orders\n", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
if i >= 5 {
|
||||
fmt.Printf("... and %d more orders\n", len(apiResp.Orders)-5)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" Order %s: %s %s @ %s\n", order.OrderID, order.Side, order.Type, order.Price)
|
||||
}
|
||||
|
||||
// Step 5: Test GetTrades API (also needs auth)
|
||||
fmt.Println("\nStep 5: Testing GetTrades API...")
|
||||
tradesEndpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=5&auth=%s",
|
||||
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||
|
||||
tradesReq, _ := http.NewRequest("GET", tradesEndpoint, nil)
|
||||
tradesResp, err := client.Do(tradesReq)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Trades request failed: %v\n", err)
|
||||
} else {
|
||||
defer tradesResp.Body.Close()
|
||||
tradesBody, _ := io.ReadAll(tradesResp.Body)
|
||||
fmt.Printf("Status: %d\n", tradesResp.StatusCode)
|
||||
if tradesResp.StatusCode == 200 {
|
||||
fmt.Println("SUCCESS: GetTrades API working")
|
||||
} else {
|
||||
fmt.Printf("Response: %s\n", string(tradesBody))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== ALL TESTS PASSED ===")
|
||||
}
|
||||
|
||||
// AccountInfo represents Lighter account information
|
||||
type AccountInfo struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
L1Address string `json:"l1_address"`
|
||||
}
|
||||
|
||||
// getAccountByL1Address gets account info by L1 wallet address
|
||||
func getAccountByL1Address(client *http.Client, baseURL, walletAddr string) (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response - can be in "accounts" or "sub_accounts" field
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Accounts []AccountInfo `json:"accounts"`
|
||||
SubAccounts []AccountInfo `json:"sub_accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// Check main accounts first
|
||||
if len(apiResp.Accounts) > 0 {
|
||||
return &apiResp.Accounts[0], nil
|
||||
}
|
||||
|
||||
// Check sub-accounts
|
||||
if len(apiResp.SubAccounts) > 0 {
|
||||
return &apiResp.SubAccounts[0], nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no account found for address: %s", walletAddr)
|
||||
}
|
||||
@@ -1,41 +1,27 @@
|
||||
<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 インターフェースですべて設定
|
||||
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
|
||||
|
||||
### 公式リンク
|
||||
|
||||
- **公式サイト**: [https://nofxai.com](https://nofxai.com)
|
||||
- **データダッシュボード**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API ドキュメント**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
|
||||
|
||||
## 開発者コミュニティ
|
||||
@@ -63,8 +49,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,41 +1,27 @@
|
||||
<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 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
|
||||
|
||||
### 공식 링크
|
||||
|
||||
- **공식 웹사이트**: [https://nofxai.com](https://nofxai.com)
|
||||
- **데이터 대시보드**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 문서**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
|
||||
|
||||
## 개발자 커뮤니티
|
||||
@@ -63,8 +49,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,41 +1,27 @@
|
||||
<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 — настройка всего через веб-интерфейс
|
||||
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
|
||||
|
||||
### Официальные ссылки
|
||||
|
||||
- **Официальный сайт**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Панель данных**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Документация API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
|
||||
|
||||
## Сообщество разработчиков
|
||||
@@ -63,8 +49,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,41 +1,27 @@
|
||||
<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 — налаштування всього через веб-інтерфейс
|
||||
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
|
||||
|
||||
### Офіційні посилання
|
||||
|
||||
- **Офіційний сайт**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Панель даних**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Документація API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
|
||||
|
||||
## Спільнота розробників
|
||||
@@ -63,8 +49,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,41 +1,27 @@
|
||||
<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
|
||||
- **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận
|
||||
|
||||
### Liên Kết Chính Thức
|
||||
|
||||
- **Website Chính Thức**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Bảng Điều Khiển Dữ Liệu**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Tài Liệu API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ!
|
||||
|
||||
## Cộng Đồng Nhà Phát Triển
|
||||
@@ -63,8 +49,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 界面完成所有配置
|
||||
@@ -42,12 +34,6 @@
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
### 官方链接
|
||||
|
||||
- **官网**: [https://nofxai.com](https://nofxai.com)
|
||||
- **数据站点**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 文档**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
|
||||
|
||||
## 开发者社区
|
||||
@@ -75,8 +61,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,281 +0,0 @@
|
||||
# Market Regime Classification Framework
|
||||
|
||||
> A comprehensive market state identification system for quantitative trading strategy matching
|
||||
|
||||
---
|
||||
|
||||
## 1. Classification Dimensions Overview
|
||||
|
||||
Market state identification requires analysis across multiple dimensions:
|
||||
|
||||
| Dimension | Sub-dimensions | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| **Trend** | Direction, Strength | Determine market movement direction and momentum |
|
||||
| **Volatility** | Amplitude, Frequency | Measure price fluctuation characteristics |
|
||||
| **Structure** | Pattern, Phase | Identify market structure and cycle position |
|
||||
|
||||
---
|
||||
|
||||
## 2. Primary Classification (5 Categories)
|
||||
|
||||
### 2.1 Classification Overview
|
||||
|
||||
| Code | Name | Key Characteristics | Suitable Strategies |
|
||||
|------|------|---------------------|---------------------|
|
||||
| `TREND_UP` | Uptrend | Higher highs & higher lows | Trend following, Breakout |
|
||||
| `TREND_DOWN` | Downtrend | Lower highs & lower lows | Trend following, Short selling |
|
||||
| `RANGE` | Range-bound | Price oscillates within bounds | Grid trading, Mean reversion |
|
||||
| `TRANSITION` | Transition | Uncertain directional period | Wait & watch, Small positions |
|
||||
| `BREAKOUT` | Breakout | Price breaks key levels | Breakout trading |
|
||||
|
||||
### 2.2 Identification Indicators
|
||||
|
||||
- **ADX (Average Directional Index)**: Measures trend strength
|
||||
- ADX > 25: Clear trend exists
|
||||
- ADX < 20: Range-bound market
|
||||
- **EMA Alignment**: Determines trend direction
|
||||
- EMA20 > EMA50 > EMA200: Bullish alignment
|
||||
- EMA20 < EMA50 < EMA200: Bearish alignment
|
||||
|
||||
---
|
||||
|
||||
## 3. Secondary Classification (18 Sub-categories)
|
||||
|
||||
### 3.1 Uptrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TU_STRONG_LOW_VOL` | Strong Uptrend · Low Vol | Steady rise, shallow pullbacks | ADX>40, ATR%<2%, Pullback<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | Strong Uptrend · High Vol | Rapid surge, high volatility | ADX>40, ATR%>4%, MACD histogram expanding |
|
||||
| `TU_WEAK_CHOPPY` | Weak Uptrend · Choppy | Two steps forward, one back | ADX 20-30, RSI oscillating 50-70 |
|
||||
| `TU_PARABOLIC` | Parabolic Acceleration | Exponential price increase | Price far from MA, RSI>80, Volume surge |
|
||||
| `TU_EXHAUSTION` | Uptrend Exhaustion | New highs but weakening momentum | Price new high + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Heavy trend following, pyramid adding
|
||||
- Strong High Vol: Medium position, trailing stops
|
||||
- Weak Choppy: Light swing trading
|
||||
- Parabolic: Cautious, prepare to exit
|
||||
- Exhaustion: Reduce positions, prepare for reversal
|
||||
|
||||
### 3.2 Downtrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TD_STRONG_LOW_VOL` | Strong Downtrend · Low Vol | Steady decline, weak bounces | ADX>40, ATR%<2%, Bounce<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | Strong Downtrend · High Vol | Panic selling, wild swings | ADX>40, ATR%>5%, VIX spike |
|
||||
| `TD_WEAK_CHOPPY` | Weak Downtrend · Choppy | Grinding lower with bounces | ADX 20-30, RSI oscillating 30-50 |
|
||||
| `TD_CAPITULATION` | Capitulation | High volume crash, extreme fear | RSI<20, Volume>3x average |
|
||||
| `TD_EXHAUSTION` | Downtrend Exhaustion | New lows but selling pressure fading | Price new low + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Short trend following
|
||||
- Strong High Vol: Stay flat or light hedge
|
||||
- Weak Choppy: Wait for stabilization
|
||||
- Capitulation: Light bottom fishing possible
|
||||
- Exhaustion: Gradually build long positions
|
||||
|
||||
### 3.3 Range Sub-categories (4 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `RG_TIGHT_LOW_VOL` | Tight Range · Low Vol | Extreme contraction, coiling | BB Width<2%, ATR at new lows |
|
||||
| `RG_TIGHT_HIGH_VOL` | Tight Range · High Vol | Violent swings within range | BB Width<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | Wide Range · Low Vol | Large range, slow movement | BB Width>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | Wide Range · High Vol | Large range, fast movement | BB Width>5%, ATR%>3% |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Tight Low Vol: Dense grid, wait for breakout
|
||||
- Tight High Vol: Fast grid, small frequent profits
|
||||
- Wide Low Vol: Sparse grid, patient holding
|
||||
- Wide High Vol: Swing trading, high profit targets
|
||||
|
||||
### 3.4 Transition (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TR_BOTTOM_FORMING` | Bottom Forming | Decline slowing, testing support | Price stabilizing + Volume drying up + RSI divergence |
|
||||
| `TR_TOP_FORMING` | Top Forming | Rally slowing, testing resistance | Price stalling + Volume drying up + RSI divergence |
|
||||
|
||||
### 3.5 Breakout (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `BK_UPWARD` | Upward Breakout | Breaking resistance with volume | Price>Previous high, Volume>2x, BB breakout |
|
||||
| `BK_DOWNWARD` | Downward Breakout | Breaking support with volume | Price<Previous low, Volume>2x, BB breakdown |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tertiary Classification (36 Ultra-fine Categories)
|
||||
|
||||
### 4.1 Trend Phase Classification
|
||||
|
||||
Uptrend lifecycle consists of 5 phases:
|
||||
|
||||
| Phase Code | Name | Description | Quantitative Criteria |
|
||||
|------------|------|-------------|----------------------|
|
||||
| `TU_S1_INITIATION` | Uptrend Initiation | First break above MA or previous high | MACD bullish cross, Price>EMA20 |
|
||||
| `TU_S2_ACCELERATION` | Uptrend Acceleration | Momentum increasing, slope steepening | MACD histogram expanding, ADX rising |
|
||||
| `TU_S3_MAIN_WAVE` | Main Wave | Sustained rise, shallow pullbacks | RSI 60-80, Pullbacks hold EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | Uptrend Exhaustion | Slowing momentum, divergences appearing | RSI divergence, MACD divergence |
|
||||
| `TU_S5_REVERSAL` | Trend Reversal | Breakdown, trend ending | Break below EMA50, MACD bearish cross |
|
||||
|
||||
Downtrend phases follow same pattern: `TD_S1` through `TD_S5`
|
||||
|
||||
### 4.2 Range Position Classification
|
||||
|
||||
| Position Code | Name | Description | Strategy Suggestion |
|
||||
|---------------|------|-------------|---------------------|
|
||||
| `RG_UPPER` | Upper Range | Price near resistance | Bias toward short |
|
||||
| `RG_MIDDLE` | Mid Range | Price near middle band | Neutral grid trading |
|
||||
| `RG_LOWER` | Lower Range | Price near support | Bias toward long |
|
||||
| `RG_SQUEEZE` | Squeeze Pattern | Highs and lows converging | Wait for direction |
|
||||
| `RG_EXPAND` | Expanding Pattern | Highs and lows diverging | Boundary reversal |
|
||||
|
||||
### 4.3 Volatility Grades
|
||||
|
||||
| Code | Name | ATR% | BB Width | Strategy Suggestion |
|
||||
|------|------|------|----------|---------------------|
|
||||
| `VOL_EXTREME_LOW` | Extreme Low Vol | <1% | <1.5% | Option selling |
|
||||
| `VOL_LOW` | Low Volatility | 1-2% | 1.5-2.5% | Grid / Mean reversion |
|
||||
| `VOL_NORMAL` | Normal Volatility | 2-3% | 2.5-4% | Trend following |
|
||||
| `VOL_HIGH` | High Volatility | 3-5% | 4-6% | Momentum / Breakout |
|
||||
| `VOL_EXTREME_HIGH` | Extreme High Vol | >5% | >6% | Reduce exposure / Hedge |
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete State Encoding Rules
|
||||
|
||||
### 5.1 Encoding Format
|
||||
|
||||
```
|
||||
{Primary}_{Volatility}_{Phase}_{Position}
|
||||
```
|
||||
|
||||
### 5.2 Encoding Examples
|
||||
|
||||
| Full Code | Interpretation |
|
||||
|-----------|----------------|
|
||||
| `TU_LV_S3_M` | Uptrend_LowVol_MainWave_Middle |
|
||||
| `TD_HV_S2_L` | Downtrend_HighVol_Acceleration_Lower |
|
||||
| `RG_NV_SQ_U` | Range_NormalVol_Squeeze_Upper |
|
||||
| `BK_HV_UP_M` | Breakout_HighVol_Upward_Middle |
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Identification Indicators
|
||||
|
||||
### 6.1 Trend Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| ADX | 14-period Average Directional Index | >40 Strong, 25-40 Medium, <25 Weak/Range |
|
||||
| Trend Score | Composite EMA/MACD/Price structure | -100 to +100, Positive=Bullish, Negative=Bearish |
|
||||
| EMA Alignment | Relative position of EMA20/50/200 | Bullish/Bearish/Mixed alignment |
|
||||
|
||||
### 6.2 Volatility Indicators
|
||||
|
||||
| Indicator | Calculation | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| ATR Percent | ATR(14) / Current Price × 100% | Measure relative volatility |
|
||||
| BB Width | (Upper - Lower) / Middle × 100% | Measure price range |
|
||||
| Volatility Rank | Current vol percentile in history | Determine vol level |
|
||||
|
||||
### 6.3 Momentum Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| RSI | 14-period Relative Strength Index | >70 Overbought, <30 Oversold, 50 Neutral |
|
||||
| MACD Histogram | MACD - Signal | Positive=Bullish momentum, Negative=Bearish |
|
||||
| Momentum Score | Composite RSI/MACD/Volume | Measure current momentum |
|
||||
|
||||
### 6.4 Structure Indicators
|
||||
|
||||
| Indicator | Description | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| Swing Structure | HH/HL/LH/LL sequence | Determine trend structure |
|
||||
| Support/Resistance | Key price levels | Define trading range |
|
||||
| Volume Profile | Volume-price relationship | Validate price action |
|
||||
|
||||
---
|
||||
|
||||
## 7. Strategy Matching Matrix
|
||||
|
||||
### 7.1 Regime-Strategy Mapping
|
||||
|
||||
| Regime Type | Recommended Strategy | Position Size | Stop Loss |
|
||||
|-------------|---------------------|---------------|-----------|
|
||||
| Strong Uptrend · Low Vol | Trend following + Pyramid | 60-80% | ATR×2 |
|
||||
| Strong Uptrend · High Vol | Momentum + Quick profit | 40-60% | ATR×1.5 |
|
||||
| Uptrend Exhaustion | Reduce + Reversal short | 20-30% | Previous high |
|
||||
| Panic Decline | Wait or light bottom fish | 10-20% | Wide stop |
|
||||
| Low Vol Range | Grid trading | 50-70% | Range boundary |
|
||||
| High Vol Range | Swing trading | 30-50% | ATR×2 |
|
||||
| Squeeze Pattern | Wait for breakout | 10-20% | - |
|
||||
| Upward Breakout | Chase + Add on pullback | 50-70% | Breakout level |
|
||||
| Bottom Formation | Scale in gradually | 20-40% | New low |
|
||||
|
||||
### 7.2 Grid Strategy Parameter Matching
|
||||
|
||||
| Range Type | Grid Levels | Grid Spacing | Other Parameters |
|
||||
|------------|-------------|--------------|------------------|
|
||||
| Tight Low Vol | 30-50 levels | Small spacing | Enable Maker Only |
|
||||
| Tight High Vol | 15-25 levels | Small spacing | Fast execution mode |
|
||||
| Wide Low Vol | 10-20 levels | Large spacing | Patient execution |
|
||||
| Wide High Vol | 15-25 levels | Large spacing | High profit targets |
|
||||
| Squeeze Pattern | Pause grid | - | Wait for breakout signal |
|
||||
| Upper Range | Short bias | Medium | Increase sell weight |
|
||||
| Lower Range | Long bias | Medium | Increase buy weight |
|
||||
|
||||
---
|
||||
|
||||
## 8. Real-time Monitoring Guidelines
|
||||
|
||||
### 8.1 State Transition Triggers
|
||||
|
||||
| Current State | Trigger Condition | Transitions To |
|
||||
|---------------|-------------------|----------------|
|
||||
| Range | Price breakout + Volume + ADX rising | Breakout |
|
||||
| Uptrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Downtrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Breakout | Failed breakout, price returns | Range |
|
||||
| Exhaustion | Confirmed reversal breakout | Opposite trend |
|
||||
|
||||
### 8.2 Risk Control Rules
|
||||
|
||||
| Regime State | Max Position | Risk Per Trade | Special Rules |
|
||||
|--------------|--------------|----------------|---------------|
|
||||
| Strong Trend | 80% | 2% | Adding allowed |
|
||||
| Weak Trend | 50% | 1.5% | No adding |
|
||||
| Range | 60% | 1% | Diversified holding |
|
||||
| Transition | 30% | 1% | Reduce activity |
|
||||
| High Volatility | 40% | 0.5% | Wide stops |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix
|
||||
|
||||
### 9.1 Abbreviation Reference
|
||||
|
||||
| Abbrev | Full Form | Description |
|
||||
|--------|-----------|-------------|
|
||||
| TU | Trend Up | Upward trend |
|
||||
| TD | Trend Down | Downward trend |
|
||||
| RG | Range | Range-bound market |
|
||||
| TR | Transition | Trend transition |
|
||||
| BK | Breakout | Breakout pattern |
|
||||
| LV | Low Volatility | Low volatility regime |
|
||||
| HV | High Volatility | High volatility regime |
|
||||
| NV | Normal Volatility | Normal volatility regime |
|
||||
| XLV | Extreme Low Vol | Extremely low volatility |
|
||||
| XHV | Extreme High Vol | Extremely high volatility |
|
||||
|
||||
### 9.2 Document Information
|
||||
|
||||
- Version: v1.0
|
||||
- Created: January 2026
|
||||
- Applicable: Cryptocurrency, Forex, Stocks, and other financial markets
|
||||
|
||||
---
|
||||
|
||||
*This document is designed for market state identification and strategy matching in quantitative trading systems*
|
||||
@@ -1,281 +0,0 @@
|
||||
# 市场行情精细分类体系
|
||||
|
||||
> 用于量化交易策略匹配的市场状态识别框架
|
||||
|
||||
---
|
||||
|
||||
## 一、分类维度概览
|
||||
|
||||
市场状态识别需要从多个维度进行分析:
|
||||
|
||||
| 维度 | 子维度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **趋势维度** | 方向、强度 | 判断市场运动方向和力度 |
|
||||
| **波动维度** | 幅度、频率 | 衡量价格波动特征 |
|
||||
| **结构维度** | 形态、阶段 | 识别市场结构和所处周期 |
|
||||
|
||||
---
|
||||
|
||||
## 二、一级分类(5大类)
|
||||
|
||||
### 2.1 分类总览
|
||||
|
||||
| 代码 | 名称 | 核心特征 | 适合策略 |
|
||||
|------|------|----------|----------|
|
||||
| `TREND_UP` | 上涨趋势 | 高点/低点持续抬升 | 趋势跟踪、突破追涨 |
|
||||
| `TREND_DOWN` | 下跌趋势 | 高点/低点持续降低 | 趋势跟踪、做空策略 |
|
||||
| `RANGE` | 震荡区间 | 价格在区间内波动 | 网格交易、均值回归 |
|
||||
| `TRANSITION` | 趋势转换 | 方向不明确的过渡期 | 观望、小仓位试探 |
|
||||
| `BREAKOUT` | 突破行情 | 价格突破关键位置 | 突破追踪策略 |
|
||||
|
||||
### 2.2 识别指标
|
||||
|
||||
- **ADX(平均方向指数)**:衡量趋势强度
|
||||
- ADX > 25:存在明确趋势
|
||||
- ADX < 20:震荡市场
|
||||
- **EMA排列**:判断趋势方向
|
||||
- EMA20 > EMA50 > EMA200:多头排列
|
||||
- EMA20 < EMA50 < EMA200:空头排列
|
||||
|
||||
---
|
||||
|
||||
## 三、二级分类(18细分类)
|
||||
|
||||
### 3.1 上涨趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TU_STRONG_LOW_VOL` | 强势上涨·低波动 | 稳步上涨,回调幅度小 | ADX>40, ATR%<2%, 回调<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | 强势上涨·高波动 | 快速拉升,波动剧烈 | ADX>40, ATR%>4%, MACD柱放大 |
|
||||
| `TU_WEAK_CHOPPY` | 弱势上涨·震荡 | 涨三退二,反复磨蹭 | ADX 20-30, RSI在50-70震荡 |
|
||||
| `TU_PARABOLIC` | 抛物线加速 | 指数级加速上涨 | 价格远离均线, RSI>80, 成交量放大 |
|
||||
| `TU_EXHAUSTION` | 上涨衰竭 | 创新高但动能减弱 | 价格新高 + MACD/RSI顶背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:重仓趋势跟踪,金字塔加仓
|
||||
- 强势高波动:中等仓位,设置移动止盈
|
||||
- 弱势震荡:轻仓波段,高抛低吸
|
||||
- 抛物线加速:谨慎追涨,准备离场
|
||||
- 上涨衰竭:减仓观望,准备反转做空
|
||||
|
||||
### 3.2 下跌趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TD_STRONG_LOW_VOL` | 强势下跌·低波动 | 稳步下跌,反弹无力 | ADX>40, ATR%<2%, 反弹<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | 强势下跌·高波动 | 恐慌抛售,波动剧烈 | ADX>40, ATR%>5%, 恐慌指数飙升 |
|
||||
| `TD_WEAK_CHOPPY` | 弱势下跌·震荡 | 跌跌涨涨,磨底过程 | ADX 20-30, RSI在30-50震荡 |
|
||||
| `TD_CAPITULATION` | 恐慌投降 | 放量暴跌,情绪极端 | RSI<20, 成交量>3倍均量 |
|
||||
| `TD_EXHAUSTION` | 下跌衰竭 | 创新低但卖压减弱 | 价格新低 + MACD/RSI底背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:空头趋势跟踪
|
||||
- 强势高波动:观望或轻仓对冲
|
||||
- 弱势震荡:等待企稳信号
|
||||
- 恐慌投降:极端情况可轻仓抄底
|
||||
- 下跌衰竭:逐步建立多头仓位
|
||||
|
||||
### 3.3 震荡区间细分(4种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `RG_TIGHT_LOW_VOL` | 窄幅震荡·低波动 | 极度收敛,蓄势待发 | 布林带宽度<2%, ATR创新低 |
|
||||
| `RG_TIGHT_HIGH_VOL` | 窄幅震荡·高波动 | 区间内剧烈波动 | 布林带宽度<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | 宽幅震荡·低波动 | 大区间慢速波动 | 布林带宽度>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | 宽幅震荡·高波动 | 大区间快速波动 | 布林带宽度>5%, ATR%>3% |
|
||||
|
||||
**策略匹配:**
|
||||
- 窄幅低波动:密集网格,等待突破
|
||||
- 窄幅高波动:快速网格,小利润多次
|
||||
- 宽幅低波动:稀疏网格,耐心持有
|
||||
- 宽幅高波动:波段交易,高利润目标
|
||||
|
||||
### 3.4 转换过渡(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TR_BOTTOM_FORMING` | 底部形成中 | 下跌放缓,试探支撑 | 价格止跌 + 成交量萎缩 + RSI底背离 |
|
||||
| `TR_TOP_FORMING` | 顶部形成中 | 上涨放缓,试探压力 | 价格滞涨 + 成交量萎缩 + RSI顶背离 |
|
||||
|
||||
### 3.5 突破行情(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `BK_UPWARD` | 向上突破 | 突破阻力位并放量 | 价格>前高, 成交量>2倍, 布林带突破 |
|
||||
| `BK_DOWNWARD` | 向下突破 | 跌破支撑位并放量 | 价格<前低, 成交量>2倍, 布林带跌破 |
|
||||
|
||||
---
|
||||
|
||||
## 四、三级分类(36超细分类)
|
||||
|
||||
### 4.1 趋势阶段细分
|
||||
|
||||
上涨趋势生命周期分为5个阶段:
|
||||
|
||||
| 阶段代码 | 名称 | 特征描述 | 量化判断标准 |
|
||||
|----------|------|----------|--------------|
|
||||
| `TU_S1_INITIATION` | 上涨启动期 | 首次突破均线或前高 | MACD金叉, 价格突破EMA20 |
|
||||
| `TU_S2_ACCELERATION` | 上涨加速期 | 动能增强,斜率加大 | MACD柱持续增大, ADX上升 |
|
||||
| `TU_S3_MAIN_WAVE` | 主升浪阶段 | 持续上涨,回调幅度浅 | RSI维持60-80, 回调不破EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | 上涨衰竭期 | 涨速放缓,出现背离 | RSI顶背离, MACD顶背离 |
|
||||
| `TU_S5_REVERSAL` | 趋势反转期 | 破位下跌,趋势结束 | 跌破EMA50, MACD死叉 |
|
||||
|
||||
下跌趋势同理,代码为 `TD_S1` 至 `TD_S5`
|
||||
|
||||
### 4.2 震荡位置细分
|
||||
|
||||
| 位置代码 | 名称 | 特征描述 | 策略建议 |
|
||||
|----------|------|----------|----------|
|
||||
| `RG_UPPER` | 区间上沿震荡 | 价格接近阻力位 | 偏空操作为主 |
|
||||
| `RG_MIDDLE` | 区间中部震荡 | 价格在中轨附近 | 双向网格交易 |
|
||||
| `RG_LOWER` | 区间下沿震荡 | 价格接近支撑位 | 偏多操作为主 |
|
||||
| `RG_SQUEEZE` | 收敛三角震荡 | 高低点逐渐收窄 | 等待方向选择 |
|
||||
| `RG_EXPAND` | 扩散三角震荡 | 高低点逐渐扩张 | 边界反转操作 |
|
||||
|
||||
### 4.3 波动率等级
|
||||
|
||||
| 代码 | 名称 | ATR百分比 | 布林带宽度 | 策略建议 |
|
||||
|------|------|-----------|------------|----------|
|
||||
| `VOL_EXTREME_LOW` | 极低波动 | <1% | <1.5% | 期权卖方策略 |
|
||||
| `VOL_LOW` | 低波动 | 1-2% | 1.5-2.5% | 网格/均值回归 |
|
||||
| `VOL_NORMAL` | 正常波动 | 2-3% | 2.5-4% | 趋势跟踪 |
|
||||
| `VOL_HIGH` | 高波动 | 3-5% | 4-6% | 动量/突破 |
|
||||
| `VOL_EXTREME_HIGH` | 极高波动 | >5% | >6% | 减仓/对冲 |
|
||||
|
||||
---
|
||||
|
||||
## 五、完整状态编码规则
|
||||
|
||||
### 5.1 编码格式
|
||||
|
||||
```
|
||||
{一级分类}_{波动等级}_{阶段}_{位置}
|
||||
```
|
||||
|
||||
### 5.2 编码示例
|
||||
|
||||
| 完整代码 | 含义解释 |
|
||||
|----------|----------|
|
||||
| `TU_LV_S3_M` | 上涨趋势_低波动_主升浪_中部位置 |
|
||||
| `TD_HV_S2_L` | 下跌趋势_高波动_加速期_下部位置 |
|
||||
| `RG_NV_SQ_U` | 震荡区间_正常波动_收敛形态_上沿位置 |
|
||||
| `BK_HV_UP_M` | 突破行情_高波动_向上突破_中部位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、核心识别指标
|
||||
|
||||
### 6.1 趋势指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| ADX | 14周期平均方向指数 | >40强趋势, 25-40中等, <25弱/震荡 |
|
||||
| 趋势评分 | 综合EMA/MACD/价格结构 | -100到+100, 正数多头,负数空头 |
|
||||
| EMA排列 | EMA20/50/200相对位置 | 多头排列/空头排列/混乱 |
|
||||
|
||||
### 6.2 波动指标
|
||||
|
||||
| 指标 | 计算方法 | 用途 |
|
||||
|------|----------|------|
|
||||
| ATR百分比 | ATR(14) / 当前价格 × 100% | 衡量相对波动幅度 |
|
||||
| 布林带宽度 | (上轨-下轨) / 中轨 × 100% | 衡量价格波动区间 |
|
||||
| 波动率排名 | 当前波动在历史中的分位 | 判断波动率高低 |
|
||||
|
||||
### 6.3 动量指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| RSI | 14周期相对强弱指数 | >70超买, <30超卖, 50中性 |
|
||||
| MACD柱 | MACD - Signal | 正数多头动能,负数空头动能 |
|
||||
| 动量评分 | 综合RSI/MACD/成交量 | 衡量当前动能强弱 |
|
||||
|
||||
### 6.4 结构指标
|
||||
|
||||
| 指标 | 说明 | 用途 |
|
||||
|------|------|------|
|
||||
| 高低点结构 | HH/HL/LH/LL序列 | 判断趋势结构 |
|
||||
| 支撑阻力位 | 关键价格水平 | 确定交易区间 |
|
||||
| 成交量形态 | 量价配合关系 | 验证价格走势 |
|
||||
|
||||
---
|
||||
|
||||
## 七、策略匹配矩阵
|
||||
|
||||
### 7.1 行情类型与策略对应
|
||||
|
||||
| 行情类型 | 推荐策略 | 建议仓位 | 止损设置 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强势上涨·低波动 | 趋势跟踪+金字塔加仓 | 60-80% | ATR×2 |
|
||||
| 强势上涨·高波动 | 动量突破+快速止盈 | 40-60% | ATR×1.5 |
|
||||
| 上涨衰竭期 | 减仓+反转信号做空 | 20-30% | 前高 |
|
||||
| 恐慌下跌 | 观望或轻仓抄底 | 10-20% | 宽止损 |
|
||||
| 低波动震荡 | 网格交易 | 50-70% | 区间边界 |
|
||||
| 高波动震荡 | 波段高抛低吸 | 30-50% | ATR×2 |
|
||||
| 收敛等待 | 蓄势等突破 | 10-20% | - |
|
||||
| 向上突破 | 追涨+回踩加仓 | 50-70% | 突破位 |
|
||||
| 底部形成 | 分批建仓 | 20-40% | 新低 |
|
||||
|
||||
### 7.2 网格策略参数匹配
|
||||
|
||||
| 震荡类型 | 网格层数 | 网格间距 | 其他参数 |
|
||||
|----------|----------|----------|----------|
|
||||
| 窄幅低波动 | 30-50层 | 小间距 | 启用Maker Only |
|
||||
| 窄幅高波动 | 15-25层 | 小间距 | 快速成交模式 |
|
||||
| 宽幅低波动 | 10-20层 | 大间距 | 耐心等待成交 |
|
||||
| 宽幅高波动 | 15-25层 | 大间距 | 高利润目标 |
|
||||
| 收敛形态 | 暂停网格 | - | 等待突破信号 |
|
||||
| 区间上沿 | 偏空配置 | 中等 | 卖单权重增加 |
|
||||
| 区间下沿 | 偏多配置 | 中等 | 买单权重增加 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实时监控建议
|
||||
|
||||
### 8.1 状态转换触发条件
|
||||
|
||||
| 当前状态 | 触发条件 | 转换到 |
|
||||
|----------|----------|--------|
|
||||
| 震荡区间 | 价格突破+放量+ADX上升 | 突破行情 |
|
||||
| 上涨趋势 | RSI顶背离+成交量萎缩 | 上涨衰竭 |
|
||||
| 下跌趋势 | RSI底背离+成交量萎缩 | 下跌衰竭 |
|
||||
| 突破行情 | 突破失败回落 | 震荡区间 |
|
||||
| 趋势衰竭 | 反向突破确认 | 反向趋势 |
|
||||
|
||||
### 8.2 风险控制规则
|
||||
|
||||
| 行情状态 | 最大仓位 | 单笔风险 | 特殊规则 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强趋势 | 80% | 2% | 可加仓 |
|
||||
| 弱趋势 | 50% | 1.5% | 不加仓 |
|
||||
| 震荡 | 60% | 1% | 分散持仓 |
|
||||
| 转换期 | 30% | 1% | 减少操作 |
|
||||
| 高波动 | 40% | 0.5% | 宽止损 |
|
||||
|
||||
---
|
||||
|
||||
## 九、附录
|
||||
|
||||
### 9.1 缩写对照表
|
||||
|
||||
| 缩写 | 英文全称 | 中文含义 |
|
||||
|------|----------|----------|
|
||||
| TU | Trend Up | 上涨趋势 |
|
||||
| TD | Trend Down | 下跌趋势 |
|
||||
| RG | Range | 震荡区间 |
|
||||
| TR | Transition | 趋势转换 |
|
||||
| BK | Breakout | 突破行情 |
|
||||
| LV | Low Volatility | 低波动 |
|
||||
| HV | High Volatility | 高波动 |
|
||||
| NV | Normal Volatility | 正常波动 |
|
||||
| XLV | Extreme Low Vol | 极低波动 |
|
||||
| XHV | Extreme High Vol | 极高波动 |
|
||||
|
||||
### 9.2 版本信息
|
||||
|
||||
- 文档版本:v1.0
|
||||
- 创建日期:2026年1月
|
||||
- 适用范围:加密货币、外汇、股票等金融市场
|
||||
|
||||
---
|
||||
|
||||
*本文档用于量化交易系统的市场状态识别和策略匹配*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,151 +0,0 @@
|
||||
# 网格策略市场状态识别与风控设计
|
||||
|
||||
## 概述
|
||||
|
||||
增强网格策略的市场状态识别能力,实现震荡/趋势的精准判断,并根据不同震荡级别自动调整网格参数和风控策略。
|
||||
|
||||
---
|
||||
|
||||
## 一、市场状态识别
|
||||
|
||||
### 1.1 识别维度(3个)
|
||||
|
||||
| 维度 | 指标 | 作用 |
|
||||
|------|------|------|
|
||||
| 价格波动 | ATR14 + Bollinger带宽 | 判断震荡幅度 |
|
||||
| 趋势强度 | EMA20/50距离 + MACD | 判断是否有趋势 |
|
||||
| 动量 | RSI14 + 1h/4h涨跌幅 | 判断超买超卖 |
|
||||
|
||||
### 1.2 箱体指标(新增)
|
||||
|
||||
基于1小时K线的多周期Donchian通道:
|
||||
|
||||
| 箱体级别 | 周期 | 覆盖时间 | 用途 |
|
||||
|----------|------|----------|------|
|
||||
| 短期箱体 | 72根1小时 | 3天 | 日内波动边界 |
|
||||
| 中期箱体 | 240根1小时 | 10天 | 周级别震荡区间 |
|
||||
| 长期箱体 | 500根1小时 | ~21天 | 大级别趋势边界 |
|
||||
|
||||
### 1.3 判断方式
|
||||
|
||||
由AI综合分析以上指标 + 原始K线序列 + 箱体位置,输出市场状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 二、震荡分级与网格策略
|
||||
|
||||
### 2.1 四级震荡分类
|
||||
|
||||
| 级别 | 特征 | 判断依据 |
|
||||
|------|------|----------|
|
||||
| 窄幅震荡 | 价格在短期箱体内小幅波动 | Bollinger带宽 < 2%,ATR低 |
|
||||
| 标准震荡 | 价格在中期箱体内正常波动 | Bollinger带宽 2-3%,ATR正常 |
|
||||
| 宽幅震荡 | 价格接近中期箱体边缘 | Bollinger带宽 3-4%,ATR较高 |
|
||||
| 剧烈震荡 | 价格接近长期箱体边缘 | Bollinger带宽 > 4%,ATR高 |
|
||||
|
||||
### 2.2 各级别对应的网格策略
|
||||
|
||||
| 级别 | 网格密度 | 网格范围 | 单格仓位 | 总仓位上限 | 有效杠杆上限 |
|
||||
|------|----------|----------|----------|------------|--------------|
|
||||
| 窄幅震荡 | 密集 | 窄 | 小 | 30-40% | 2x |
|
||||
| 标准震荡 | 正常 | 中等 | 正常 | 60-70% | 3-4x |
|
||||
| 宽幅震荡 | 稀疏 | 宽 | 正常 | 50-60% | 3x |
|
||||
| 剧烈震荡 | 最稀疏 | 最宽 | 小 | 30-40% | 2x |
|
||||
|
||||
**核心原则:**
|
||||
- 窄幅震荡:单格仓位小 + 总仓位上限低(防击穿风险)
|
||||
- 剧烈震荡:同样保守(随时可能变趋势)
|
||||
- 标准震荡:才是放量的最佳时机
|
||||
|
||||
---
|
||||
|
||||
## 三、突破处理与恢复机制
|
||||
|
||||
### 3.1 突破判断与处理
|
||||
|
||||
**确认方式:** 收盘价突破箱体后,持续3根1小时K线不回箱体
|
||||
|
||||
| 箱体级别 | 突破处理 |
|
||||
|----------|----------|
|
||||
| 短期箱体突破 | 降低仓位到 50% |
|
||||
| 中期箱体突破 | 暂停网格 + 取消挂单 |
|
||||
| 长期箱体突破 | 暂停网格 + 取消挂单 + 平掉所有持仓 |
|
||||
|
||||
### 3.2 假突破恢复
|
||||
|
||||
**价格回到箱体内 → 以50%仓位恢复网格**
|
||||
|
||||
---
|
||||
|
||||
## 四、前端风控面板
|
||||
|
||||
### 4.1 需要展示的信息
|
||||
|
||||
| 类别 | 显示内容 |
|
||||
|------|----------|
|
||||
| 杠杆信息 | 当前杠杆、有效杠杆、系统推荐杠杆 |
|
||||
| 仓位信息 | 当前仓位、最大仓位、仓位占比 |
|
||||
| 爆仓信息 | 爆仓价格、爆仓距离(%) |
|
||||
| 市场状态 | 当前震荡级别(窄幅/标准/宽幅/剧烈) |
|
||||
| 箱体状态 | 短期/中期/长期箱体上下沿、当前价格位置 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实现要点
|
||||
|
||||
### 5.1 后端新增
|
||||
|
||||
1. **箱体指标计算** (`market/data.go`)
|
||||
- 新增 `calculateDonchian(klines, period)` 函数
|
||||
- 返回 upper(最高价), lower(最低价)
|
||||
- 支持72/240/500三个周期
|
||||
|
||||
2. **市场状态评估** (`kernel/grid_engine.go`)
|
||||
- 更新AI prompt,加入箱体指标和K线序列
|
||||
- AI输出震荡级别判断
|
||||
|
||||
3. **网格参数动态调整** (`trader/auto_trader_grid.go`)
|
||||
- 根据震荡级别自动调整:网格密度、范围、仓位、杠杆
|
||||
- 实现有效杠杆上限控制
|
||||
|
||||
4. **突破处理逻辑** (`trader/auto_trader_grid.go`)
|
||||
- 实现三级箱体突破检测
|
||||
- 实现3根K线确认逻辑
|
||||
- 实现降级恢复机制
|
||||
|
||||
### 5.2 前端新增
|
||||
|
||||
1. **风控面板组件**
|
||||
- 杠杆信息展示
|
||||
- 仓位信息展示
|
||||
- 爆仓信息展示
|
||||
- 市场状态展示
|
||||
- 箱体状态可视化
|
||||
|
||||
### 5.3 数据模型更新
|
||||
|
||||
1. **GridConfigModel** 新增字段:
|
||||
- `EffectiveLeverageLimit` - 有效杠杆上限
|
||||
- `ShortBoxPeriod` - 短期箱体周期 (默认72)
|
||||
- `MidBoxPeriod` - 中期箱体周期 (默认240)
|
||||
- `LongBoxPeriod` - 长期箱体周期 (默认500)
|
||||
|
||||
2. **GridInstanceModel** 新增字段:
|
||||
- `CurrentRegimeLevel` - 当前震荡级别 (narrow/standard/wide/volatile)
|
||||
- `ShortBoxUpper/Lower` - 短期箱体上下沿
|
||||
- `MidBoxUpper/Lower` - 中期箱体上下沿
|
||||
- `LongBoxUpper/Lower` - 长期箱体上下沿
|
||||
- `BreakoutStatus` - 突破状态 (none/short/mid/long)
|
||||
- `BreakoutConfirmCount` - 突破确认K线计数
|
||||
|
||||
---
|
||||
|
||||
## 六、风险控制总结
|
||||
|
||||
| 控制点 | 机制 |
|
||||
|--------|------|
|
||||
| 仓位控制 | 根据震荡级别限制总仓位上限 (30-70%) |
|
||||
| 杠杆控制 | 根据震荡级别限制有效杠杆 (2-4x) |
|
||||
| 突破保护 | 三级箱体突破分级处理 |
|
||||
| 假突破恢复 | 50%仓位降级恢复 |
|
||||
| 爆仓预防 | 前端展示爆仓距离,系统自动限制杠杆 |
|
||||
File diff suppressed because it is too large
Load Diff
2
go.mod
2
go.mod
@@ -23,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
|
||||
@@ -45,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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@@ -130,8 +130,7 @@ type Context struct {
|
||||
// Decision AI trading decision
|
||||
type Decision struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // Standard: "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
// Grid actions: "place_buy_limit", "place_sell_limit", "cancel_order", "cancel_all_orders", "pause_grid", "resume_grid", "adjust_grid"
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
|
||||
// Opening position parameters
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
@@ -139,12 +138,6 @@ type Decision struct {
|
||||
StopLoss float64 `json:"stop_loss,omitempty"`
|
||||
TakeProfit float64 `json:"take_profit,omitempty"`
|
||||
|
||||
// Grid trading parameters
|
||||
Price float64 `json:"price,omitempty"` // Limit order price (for grid)
|
||||
Quantity float64 `json:"quantity,omitempty"` // Order quantity (for grid)
|
||||
LevelIndex int `json:"level_index,omitempty"` // Grid level index
|
||||
OrderID string `json:"order_id,omitempty"` // Order ID (for cancel)
|
||||
|
||||
// Common parameters
|
||||
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
|
||||
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
|
||||
@@ -447,7 +440,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_top":
|
||||
@@ -467,27 +459,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_low":
|
||||
// 持仓减少榜,适合做空
|
||||
if !coinSource.UseOILow {
|
||||
logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low 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.getOILowCoins(coinSource.OILowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
@@ -513,17 +484,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseOILow {
|
||||
oiLowCoins, err := e.getOILowCoins(coinSource.OILowLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get OI Low: %v", err)
|
||||
} else {
|
||||
for _, coin := range oiLowCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_low")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
if _, exists := symbolSources[symbol]; !exists {
|
||||
@@ -594,7 +554,7 @@ func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
||||
|
||||
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
limit = 20
|
||||
}
|
||||
|
||||
positions, err := e.nofxosClient.GetOITopPositions()
|
||||
@@ -616,30 +576,6 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
positions, err := e.nofxosClient.GetOILowPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for i, pos := range positions {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
symbol := market.Normalize(pos.Symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"oi_low"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -1346,38 +1282,13 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
||||
|
||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if len(sources) > 1 {
|
||||
// 多信号源组合
|
||||
hasAI500 := false
|
||||
hasOITop := false
|
||||
hasOILow := false
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
hasAI500 = true
|
||||
case "oi_top":
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = true
|
||||
}
|
||||
}
|
||||
if hasAI500 && hasOITop {
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
}
|
||||
if hasAI500 && hasOILow {
|
||||
return " (AI500+OI_Low dual signal)"
|
||||
}
|
||||
if hasOITop && hasOILow {
|
||||
return " (OI_Top+OI_Low)"
|
||||
}
|
||||
return " (Multiple sources)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
case "ai500":
|
||||
return " (AI500)"
|
||||
case "oi_top":
|
||||
return " (OI_Top 持仓增加)"
|
||||
case "oi_low":
|
||||
return " (OI_Low 持仓减少)"
|
||||
return " (OI_Top position growth)"
|
||||
case "static":
|
||||
return " (Manual selection)"
|
||||
}
|
||||
@@ -1849,8 +1760,8 @@ func compactArrayOpen(s string) string {
|
||||
// ============================================================================
|
||||
|
||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||
for i := range decisions {
|
||||
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
for i, decision := range decisions {
|
||||
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Grid Trading Context and Types
|
||||
// ============================================================================
|
||||
|
||||
// GridLevelInfo represents a single grid level's current state
|
||||
type GridLevelInfo struct {
|
||||
Index int `json:"index"` // Level index (0 = lowest)
|
||||
Price float64 `json:"price"` // Target price for this level
|
||||
State string `json:"state"` // "empty", "pending", "filled"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderID string `json:"order_id"` // Current order ID (if pending)
|
||||
OrderQuantity float64 `json:"order_quantity"` // Order quantity
|
||||
PositionSize float64 `json:"position_size"` // Position size (if filled)
|
||||
PositionEntry float64 `json:"position_entry"` // Entry price (if filled)
|
||||
AllocatedUSD float64 `json:"allocated_usd"` // USD allocated to this level
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized P&L (if filled)
|
||||
}
|
||||
|
||||
// GridContext contains all information needed for AI grid decision making
|
||||
type GridContext struct {
|
||||
// Basic info
|
||||
Symbol string `json:"symbol"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
|
||||
// Grid configuration
|
||||
GridCount int `json:"grid_count"`
|
||||
TotalInvestment float64 `json:"total_investment"`
|
||||
Leverage int `json:"leverage"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
GridSpacing float64 `json:"grid_spacing"`
|
||||
Distribution string `json:"distribution"`
|
||||
|
||||
// Grid state
|
||||
Levels []GridLevelInfo `json:"levels"`
|
||||
ActiveOrderCount int `json:"active_order_count"`
|
||||
FilledLevelCount int `json:"filled_level_count"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
|
||||
// Market data
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerUpper float64 `json:"bollinger_upper"`
|
||||
BollingerMiddle float64 `json:"bollinger_middle"`
|
||||
BollingerLower float64 `json:"bollinger_lower"`
|
||||
BollingerWidth float64 `json:"bollinger_width"` // Percentage
|
||||
EMA20 float64 `json:"ema20"`
|
||||
EMA50 float64 `json:"ema50"`
|
||||
EMADistance float64 `json:"ema_distance"` // Percentage
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
MACD float64 `json:"macd"`
|
||||
MACDSignal float64 `json:"macd_signal"`
|
||||
MACDHistogram float64 `json:"macd_histogram"`
|
||||
FundingRate float64 `json:"funding_rate"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
PriceChange1h float64 `json:"price_change_1h"`
|
||||
PriceChange4h float64 `json:"price_change_4h"`
|
||||
|
||||
// Account info
|
||||
TotalEquity float64 `json:"total_equity"`
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
CurrentPosition float64 `json:"current_position"` // Net position size
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
|
||||
// Performance
|
||||
TotalProfit float64 `json:"total_profit"`
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
DailyPnL float64 `json:"daily_pnl"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Prompt Building
|
||||
// ============================================================================
|
||||
|
||||
// BuildGridSystemPrompt builds the system prompt for grid trading AI
|
||||
func BuildGridSystemPrompt(config *store.GridStrategyConfig, lang string) string {
|
||||
if lang == "zh" {
|
||||
return buildGridSystemPromptZh(config)
|
||||
}
|
||||
return buildGridSystemPromptEn(config)
|
||||
}
|
||||
|
||||
func buildGridSystemPromptZh(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# 你是一个专业的网格交易AI
|
||||
|
||||
## 角色定义
|
||||
你是一个经验丰富的网格交易专家,负责管理 %s 的网格交易策略。你的任务是:
|
||||
1. 判断当前市场状态(震荡/趋势/高波动)
|
||||
2. 决定是否需要调整网格或暂停交易
|
||||
3. 管理每个网格层级的订单
|
||||
|
||||
## 网格配置
|
||||
- 交易对: %s
|
||||
- 网格层数: %d
|
||||
- 总投资: %.2f USDT
|
||||
- 杠杆: %dx
|
||||
- 价格分布: %s
|
||||
|
||||
## 决策规则
|
||||
|
||||
### 市场状态判断
|
||||
- **震荡市场** (适合网格): 布林带宽度 < 3%%, EMA20/50 距离 < 1%%, 价格在布林带中轨附近
|
||||
- **趋势市场** (暂停网格): 布林带宽度 > 4%%, EMA20/50 距离 > 2%%, 价格持续突破布林带
|
||||
- **高波动市场** (谨慎): ATR异常放大, 价格剧烈波动
|
||||
|
||||
### 可执行的操作
|
||||
- place_buy_limit: 在指定价格下买入限价单
|
||||
- place_sell_limit: 在指定价格下卖出限价单
|
||||
- cancel_order: 取消指定订单
|
||||
- cancel_all_orders: 取消所有订单
|
||||
- pause_grid: 暂停网格交易(趋势市场时)
|
||||
- resume_grid: 恢复网格交易(震荡市场时)
|
||||
- adjust_grid: 调整网格边界
|
||||
- hold: 保持当前状态不操作
|
||||
|
||||
## 输出格式
|
||||
输出JSON数组,每个决策包含:
|
||||
- symbol: 交易对
|
||||
- action: 操作类型
|
||||
- price: 价格(限价单用)
|
||||
- quantity: 数量
|
||||
- level_index: 网格层级索引
|
||||
- order_id: 订单ID(取消订单用)
|
||||
- confidence: 置信度 0-100
|
||||
- reasoning: 决策理由
|
||||
|
||||
示例:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "第2层价格接近,下买单"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "市场震荡,保持当前网格"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
|
||||
func buildGridSystemPromptEn(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# You are a Professional Grid Trading AI
|
||||
|
||||
## Role Definition
|
||||
You are an experienced grid trading expert managing a grid strategy for %s. Your tasks are:
|
||||
1. Assess current market regime (ranging/trending/volatile)
|
||||
2. Decide whether to adjust grid or pause trading
|
||||
3. Manage orders at each grid level
|
||||
|
||||
## Grid Configuration
|
||||
- Symbol: %s
|
||||
- Grid Levels: %d
|
||||
- Total Investment: %.2f USDT
|
||||
- Leverage: %dx
|
||||
- Distribution: %s
|
||||
|
||||
## Decision Rules
|
||||
|
||||
### Market Regime Assessment
|
||||
- **Ranging Market** (ideal for grid): Bollinger width < 3%%, EMA20/50 distance < 1%%, price near middle band
|
||||
- **Trending Market** (pause grid): Bollinger width > 4%%, EMA20/50 distance > 2%%, price breaking bands
|
||||
- **High Volatility** (caution): ATR spike, erratic price movement
|
||||
|
||||
### Available Actions
|
||||
- place_buy_limit: Place buy limit order at specified price
|
||||
- place_sell_limit: Place sell limit order at specified price
|
||||
- cancel_order: Cancel specific order
|
||||
- cancel_all_orders: Cancel all orders
|
||||
- pause_grid: Pause grid trading (in trending market)
|
||||
- resume_grid: Resume grid trading (in ranging market)
|
||||
- adjust_grid: Adjust grid boundaries
|
||||
- hold: Maintain current state
|
||||
|
||||
## Output Format
|
||||
Output JSON array, each decision contains:
|
||||
- symbol: Trading pair
|
||||
- action: Action type
|
||||
- price: Price (for limit orders)
|
||||
- quantity: Quantity
|
||||
- level_index: Grid level index
|
||||
- order_id: Order ID (for cancel)
|
||||
- confidence: Confidence 0-100
|
||||
- reasoning: Decision reason
|
||||
|
||||
Example:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "Level 2 price approaching, place buy order"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "Market ranging, maintain current grid"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
|
||||
// BuildGridUserPrompt builds the user prompt with current grid context
|
||||
func BuildGridUserPrompt(ctx *GridContext, lang string) string {
|
||||
if lang == "zh" {
|
||||
return buildGridUserPromptZh(ctx)
|
||||
}
|
||||
return buildGridUserPromptEn(ctx)
|
||||
}
|
||||
|
||||
func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 当前时间: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## 市场数据\n")
|
||||
sb.WriteString(fmt.Sprintf("- 当前价格: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1小时涨跌: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4小时涨跌: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带: 上轨 $%.2f, 中轨 $%.2f, 下轨 $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带宽度: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, 距离: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- 资金费率: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## 箱体指标 (唐奇安通道)\n\n")
|
||||
sb.WriteString("| 箱体级别 | 上轨 | 下轨 | 宽度 |\n")
|
||||
sb.WriteString("|----------|------|------|------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
midWidth := 0.0
|
||||
longWidth := 0.0
|
||||
|
||||
if ctx.BoxData.CurrentPrice > 0 {
|
||||
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
|
||||
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| 短期 (3天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 中期 (10天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 长期 (21天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n当前价格: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ 突破: 价格突破长期箱体!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ 警告: 价格接近长期箱体边界\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## 账户状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总权益: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- 当前持仓: %.4f (净头寸)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- 未实现盈亏: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## 网格状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 网格范围: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 网格间距: $%.2f\n", ctx.GridSpacing))
|
||||
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
|
||||
sb.WriteString("## 网格层级详情\n")
|
||||
sb.WriteString("| 层级 | 价格 | 状态 | 方向 | 订单数量 | 持仓数量 | 未实现盈亏 |\n")
|
||||
sb.WriteString("|------|------|------|------|----------|----------|------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
level.Index, level.Price, level.State, level.Side,
|
||||
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## 绩效统计\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总利润: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- 总交易次数: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 胜率: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- 今日盈亏: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## 请分析以上数据,做出网格交易决策\n")
|
||||
sb.WriteString("输出JSON数组格式的决策列表。\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildGridUserPromptEn(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Current Time: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## Market Data\n")
|
||||
sb.WriteString(fmt.Sprintf("- Current Price: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4h Change: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Bands: Upper $%.2f, Middle $%.2f, Lower $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Width: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, Distance: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- Funding Rate: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## Box Indicators (Donchian Channels)\n\n")
|
||||
sb.WriteString("| Box Level | Upper | Lower | Width |\n")
|
||||
sb.WriteString("|-----------|-------|-------|-------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
midWidth := 0.0
|
||||
longWidth := 0.0
|
||||
|
||||
if ctx.BoxData.CurrentPrice > 0 {
|
||||
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
|
||||
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ BREAKOUT: Price outside long-term box!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ WARNING: Price approaching long-term box boundary\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## Account Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Equity: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- Available Balance: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- Current Position: %.4f (net)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- Unrealized PnL: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## Grid Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Grid Range: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Spacing: $%.2f\n", ctx.GridSpacing))
|
||||
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
|
||||
sb.WriteString("## Grid Levels Detail\n")
|
||||
sb.WriteString("| Level | Price | State | Side | Order Qty | Position | Unrealized PnL |\n")
|
||||
sb.WriteString("|-------|-------|-------|------|-----------|----------|----------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
level.Index, level.Price, level.State, level.Side,
|
||||
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## Performance Stats\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Profit: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Win Rate: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- Daily PnL: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## Please analyze the data above and make grid trading decisions\n")
|
||||
sb.WriteString("Output a JSON array of decisions.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Decision Functions
|
||||
// ============================================================================
|
||||
|
||||
// GetGridDecisions gets AI decisions for grid trading
|
||||
func GetGridDecisions(ctx *GridContext, mcpClient mcp.AIClient, config *store.GridStrategyConfig, lang string) (*FullDecision, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Build prompts
|
||||
systemPrompt := BuildGridSystemPrompt(config, lang)
|
||||
userPrompt := BuildGridUserPrompt(ctx, lang)
|
||||
|
||||
logger.Infof("🤖 [Grid] Calling AI for grid decisions...")
|
||||
|
||||
// Call AI
|
||||
response, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse decisions from response
|
||||
decisions, err := parseGridDecisions(response, ctx.Symbol)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to parse grid decisions: %v", err)
|
||||
// Return hold decision as fallback
|
||||
decisions = []Decision{{
|
||||
Symbol: ctx.Symbol,
|
||||
Action: "hold",
|
||||
Confidence: 50,
|
||||
Reasoning: "Failed to parse AI response, holding current state",
|
||||
}}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Milliseconds()
|
||||
logger.Infof("⏱️ [Grid] AI call duration: %d ms, decisions: %d", duration, len(decisions))
|
||||
|
||||
// Extract chain of thought from response
|
||||
cotTrace := extractCoTTrace(response)
|
||||
|
||||
return &FullDecision{
|
||||
SystemPrompt: systemPrompt,
|
||||
UserPrompt: userPrompt,
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
RawResponse: response,
|
||||
AIRequestDurationMs: duration,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseGridDecisions parses AI response into grid decisions
|
||||
func parseGridDecisions(response string, symbol string) ([]Decision, error) {
|
||||
// Try to find JSON array in response
|
||||
jsonStr := extractJSONArray(response)
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no JSON array found in response")
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonStr), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Validate and set default symbol
|
||||
for i := range decisions {
|
||||
if decisions[i].Symbol == "" {
|
||||
decisions[i].Symbol = symbol
|
||||
}
|
||||
// Validate action
|
||||
if !isValidGridAction(decisions[i].Action) {
|
||||
logger.Warnf("Invalid grid action: %s", decisions[i].Action)
|
||||
}
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// extractJSONArray extracts JSON array from AI response
|
||||
func extractJSONArray(response string) string {
|
||||
// Try to find ```json code block first
|
||||
matches := reJSONFence.FindStringSubmatch(response)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
// Try to find raw JSON array
|
||||
matches = reJSONArray.FindStringSubmatch(response)
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidGridAction checks if action is a valid grid action
|
||||
func isValidGridAction(action string) bool {
|
||||
validActions := map[string]bool{
|
||||
"place_buy_limit": true,
|
||||
"place_sell_limit": true,
|
||||
"cancel_order": true,
|
||||
"cancel_all_orders": true,
|
||||
"pause_grid": true,
|
||||
"resume_grid": true,
|
||||
"adjust_grid": true,
|
||||
"hold": true,
|
||||
// Also support standard actions for compatibility
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
}
|
||||
return validActions[action]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Context Builder Helpers
|
||||
// ============================================================================
|
||||
|
||||
// BuildGridContextFromMarketData builds grid context from market data
|
||||
func BuildGridContextFromMarketData(mktData *market.Data, config *store.GridStrategyConfig) *GridContext {
|
||||
ctx := &GridContext{
|
||||
Symbol: config.Symbol,
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
CurrentPrice: mktData.CurrentPrice,
|
||||
|
||||
// Grid config
|
||||
GridCount: config.GridCount,
|
||||
TotalInvestment: config.TotalInvestment,
|
||||
Leverage: config.Leverage,
|
||||
Distribution: config.Distribution,
|
||||
|
||||
// Market data
|
||||
PriceChange1h: mktData.PriceChange1h,
|
||||
PriceChange4h: mktData.PriceChange4h,
|
||||
FundingRate: mktData.FundingRate,
|
||||
}
|
||||
|
||||
// Extract indicators from timeframe data
|
||||
if mktData.TimeframeData != nil {
|
||||
if tf5m, ok := mktData.TimeframeData["5m"]; ok {
|
||||
if len(tf5m.BOLLUpper) > 0 {
|
||||
ctx.BollingerUpper = tf5m.BOLLUpper[len(tf5m.BOLLUpper)-1]
|
||||
ctx.BollingerMiddle = tf5m.BOLLMiddle[len(tf5m.BOLLMiddle)-1]
|
||||
ctx.BollingerLower = tf5m.BOLLLower[len(tf5m.BOLLLower)-1]
|
||||
if ctx.BollingerMiddle > 0 {
|
||||
ctx.BollingerWidth = (ctx.BollingerUpper - ctx.BollingerLower) / ctx.BollingerMiddle * 100
|
||||
}
|
||||
}
|
||||
ctx.ATR14 = tf5m.ATR14
|
||||
if len(tf5m.RSI14Values) > 0 {
|
||||
ctx.RSI14 = tf5m.RSI14Values[len(tf5m.RSI14Values)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract longer term context
|
||||
if mktData.LongerTermContext != nil {
|
||||
if ctx.ATR14 == 0 {
|
||||
ctx.ATR14 = mktData.LongerTermContext.ATR14
|
||||
}
|
||||
ctx.EMA50 = mktData.LongerTermContext.EMA50
|
||||
}
|
||||
|
||||
ctx.EMA20 = mktData.CurrentEMA20
|
||||
ctx.MACD = mktData.CurrentMACD
|
||||
|
||||
// Calculate EMA distance
|
||||
if ctx.EMA50 > 0 {
|
||||
ctx.EMADistance = (ctx.EMA20 - ctx.EMA50) / ctx.EMA50 * 100
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Helper function for max
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
// Concurrently fetch data for each trader
|
||||
for i, t := range traders {
|
||||
go func(index int, trader *trader.AutoTrader) {
|
||||
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
// Set timeout to 3 seconds for single trader
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use channel for timeout control
|
||||
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case err := <-errorChan:
|
||||
// Failed to get account info
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s: %v", trader.GetID(), err)
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout
|
||||
logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
|
||||
logger.Infof("⏰ Timeout getting account info for trader %s", trader.GetID())
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -690,13 +690,6 @@ 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
|
||||
|
||||
156
market/data.go
156
market/data.go
@@ -31,7 +31,7 @@ var (
|
||||
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
|
||||
|
||||
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
||||
func getKlinesFromCoinAnk(symbol, interval, 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,45 +67,14 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert coinank kline format to market.Kline format
|
||||
klines := make([]Kline, len(coinankKlines))
|
||||
@@ -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
|
||||
@@ -1254,91 +1210,3 @@ func ExportCalculateATR(klines []Kline, period int) float64 {
|
||||
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||||
return calculateBOLL(klines, period, multiplier)
|
||||
}
|
||||
|
||||
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
|
||||
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
|
||||
if len(klines) == 0 || period <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Use all available klines if period > len(klines)
|
||||
start := len(klines) - period
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
upper = klines[start].High
|
||||
lower = klines[start].Low
|
||||
|
||||
for i := start + 1; i < len(klines); i++ {
|
||||
if klines[i].High > upper {
|
||||
upper = klines[i].High
|
||||
}
|
||||
if klines[i].Low < lower {
|
||||
lower = klines[i].Low
|
||||
}
|
||||
}
|
||||
|
||||
return upper, lower
|
||||
}
|
||||
|
||||
// ExportCalculateDonchian exports calculateDonchian for testing
|
||||
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
|
||||
return calculateDonchian(klines, period)
|
||||
}
|
||||
|
||||
// Box period constants (in 1h candles)
|
||||
const (
|
||||
ShortBoxPeriod = 72 // 3 days of 1h candles
|
||||
MidBoxPeriod = 240 // 10 days of 1h candles
|
||||
LongBoxPeriod = 500 // ~21 days of 1h candles
|
||||
)
|
||||
|
||||
// calculateBoxData calculates multi-period box data from klines
|
||||
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||||
box := &BoxData{
|
||||
CurrentPrice: currentPrice,
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
return box
|
||||
}
|
||||
|
||||
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
|
||||
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
|
||||
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
|
||||
|
||||
return box
|
||||
}
|
||||
|
||||
// ExportCalculateBoxData exports calculateBoxData for testing
|
||||
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||||
return calculateBoxData(klines, currentPrice)
|
||||
}
|
||||
|
||||
// GetBoxData fetches 1h klines and calculates box data for a symbol
|
||||
func GetBoxData(symbol string) (*BoxData, error) {
|
||||
symbol = Normalize(symbol)
|
||||
|
||||
// Fetch 500 1h klines
|
||||
var klines []Kline
|
||||
var err error
|
||||
|
||||
if IsXyzDexAsset(symbol) {
|
||||
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
||||
} else {
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
return nil, fmt.Errorf("no kline data available")
|
||||
}
|
||||
|
||||
currentPrice := klines[len(klines)-1].Close
|
||||
|
||||
return calculateBoxData(klines, currentPrice), nil
|
||||
}
|
||||
|
||||
@@ -500,86 +500,3 @@ func TestIsStaleData_EmptyKlines(t *testing.T) {
|
||||
t.Error("Expected false for empty klines, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian(t *testing.T) {
|
||||
// Create test klines with known high/low values
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
{High: 105, Low: 88},
|
||||
{High: 102, Low: 92},
|
||||
{High: 108, Low: 85},
|
||||
{High: 103, Low: 91},
|
||||
}
|
||||
|
||||
upper, lower := ExportCalculateDonchian(klines, 5)
|
||||
|
||||
if upper != 108 {
|
||||
t.Errorf("Expected upper = 108, got %v", upper)
|
||||
}
|
||||
if lower != 85 {
|
||||
t.Errorf("Expected lower = 85, got %v", lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian_PartialPeriod(t *testing.T) {
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
{High: 105, Low: 88},
|
||||
}
|
||||
|
||||
upper, lower := ExportCalculateDonchian(klines, 10)
|
||||
|
||||
// Should use all available klines when period > len(klines)
|
||||
if upper != 105 {
|
||||
t.Errorf("Expected upper = 105, got %v", upper)
|
||||
}
|
||||
if lower != 88 {
|
||||
t.Errorf("Expected lower = 88, got %v", lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian_InvalidPeriod(t *testing.T) {
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
}
|
||||
|
||||
// Zero period should return (0, 0)
|
||||
upper, lower := ExportCalculateDonchian(klines, 0)
|
||||
if upper != 0 || lower != 0 {
|
||||
t.Errorf("Expected (0, 0) for zero period, got (%v, %v)", upper, lower)
|
||||
}
|
||||
|
||||
// Negative period should return (0, 0)
|
||||
upper, lower = ExportCalculateDonchian(klines, -1)
|
||||
if upper != 0 || lower != 0 {
|
||||
t.Errorf("Expected (0, 0) for negative period, got (%v, %v)", upper, lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBoxData(t *testing.T) {
|
||||
// Create synthetic kline data
|
||||
klines := make([]Kline, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
basePrice := 100.0
|
||||
klines[i] = Kline{
|
||||
High: basePrice + float64(i%10),
|
||||
Low: basePrice - float64(i%10),
|
||||
Close: basePrice,
|
||||
}
|
||||
}
|
||||
|
||||
box := ExportCalculateBoxData(klines, 100.0)
|
||||
|
||||
if box.ShortUpper == 0 || box.ShortLower == 0 {
|
||||
t.Error("Short box should not be zero")
|
||||
}
|
||||
if box.MidUpper == 0 || box.MidLower == 0 {
|
||||
t.Error("Mid box should not be zero")
|
||||
}
|
||||
if box.LongUpper == 0 || box.LongLower == 0 {
|
||||
t.Error("Long box should not be zero")
|
||||
}
|
||||
if box.CurrentPrice != 100.0 {
|
||||
t.Errorf("Expected CurrentPrice = 100.0, got %v", box.CurrentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,76 +187,3 @@ var config = Config{
|
||||
},
|
||||
UpdateInterval: 60, // 1 minute
|
||||
}
|
||||
|
||||
// BoxData represents multi-period Donchian channel (box) data
|
||||
type BoxData struct {
|
||||
// Short-term box (72 1h candles = 3 days)
|
||||
ShortUpper float64 `json:"short_upper"`
|
||||
ShortLower float64 `json:"short_lower"`
|
||||
|
||||
// Mid-term box (240 1h candles = 10 days)
|
||||
MidUpper float64 `json:"mid_upper"`
|
||||
MidLower float64 `json:"mid_lower"`
|
||||
|
||||
// Long-term box (500 1h candles = ~21 days)
|
||||
LongUpper float64 `json:"long_upper"`
|
||||
LongLower float64 `json:"long_lower"`
|
||||
|
||||
// Current price position relative to boxes
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
}
|
||||
|
||||
// RegimeLevel represents the ranging classification level
|
||||
type RegimeLevel string
|
||||
|
||||
const (
|
||||
RegimeLevelNarrow RegimeLevel = "narrow" // 窄幅震荡
|
||||
RegimeLevelStandard RegimeLevel = "standard" // 标准震荡
|
||||
RegimeLevelWide RegimeLevel = "wide" // 宽幅震荡
|
||||
RegimeLevelVolatile RegimeLevel = "volatile" // 剧烈震荡
|
||||
RegimeLevelTrending RegimeLevel = "trending" // 趋势
|
||||
)
|
||||
|
||||
// BreakoutLevel represents which box level has been broken
|
||||
type BreakoutLevel string
|
||||
|
||||
const (
|
||||
BreakoutNone BreakoutLevel = "none"
|
||||
BreakoutShort BreakoutLevel = "short"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package coinank_api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const MainDepthWsUrl = "wss://ws.coinank.com/wsDepth/wsKline"
|
||||
|
||||
type DepthWs struct {
|
||||
conn *websocket.Conn
|
||||
DepthV3Ch <-chan *WsResult[DepthV3]
|
||||
}
|
||||
|
||||
// DepthWsConn connect ws , read data from DepthV3Ch
|
||||
func DepthWsConn(ctx context.Context) (*DepthWs, error) {
|
||||
conn, ch, err := depth_ws(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws := &DepthWs{
|
||||
conn: conn,
|
||||
DepthV3Ch: ch,
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// Subscribe subscribe depth
|
||||
func (ws *DepthWs) Subscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
|
||||
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
|
||||
info := SubscribeInfo{
|
||||
Op: "subscribe",
|
||||
Args: args,
|
||||
}
|
||||
json, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = websocket.Message.Send(ws.conn, json)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnSubscribe unsubscribe depth
|
||||
func (ws *DepthWs) UnSubscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
|
||||
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
|
||||
info := SubscribeInfo{
|
||||
Op: "unsubscribe",
|
||||
Args: args,
|
||||
}
|
||||
json, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = websocket.Message.Send(ws.conn, json)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close websocket
|
||||
func (ws *DepthWs) Close() error {
|
||||
return ws.conn.Close()
|
||||
}
|
||||
|
||||
func depth_ws(ctx context.Context) (*websocket.Conn, <-chan *WsResult[DepthV3], error) {
|
||||
config, err := websocket.NewConfig(MainDepthWsUrl, "http://localhost")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
conn, err := config.DialContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ch := make(chan *WsResult[DepthV3], 1024)
|
||||
go depth_read(conn, ch)
|
||||
return conn, ch, nil
|
||||
}
|
||||
|
||||
func depth_read(conn *websocket.Conn, ch chan *WsResult[DepthV3]) {
|
||||
defer conn.Close()
|
||||
defer close(ch)
|
||||
var msg string
|
||||
for {
|
||||
err := websocket.Message.Receive(conn, &msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var depth WsResult[DepthV3]
|
||||
err = json.Unmarshal([]byte(msg), &depth)
|
||||
if err == nil {
|
||||
ch <- &depth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DepthV3 struct {
|
||||
Type string `json:"type"`
|
||||
Ts uint64 `json:"ts"`
|
||||
Asks [][]string `json:"asks"`
|
||||
Bids [][]string `json:"bids"`
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package coinank_api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDepthWs(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
ws, err := DepthWsConn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
for tickers := range ws.DepthV3Ch {
|
||||
msg, err := json.Marshal(tickers)
|
||||
if err != nil {
|
||||
fmt.Println("json err:", err)
|
||||
}
|
||||
fmt.Println(string(msg))
|
||||
}
|
||||
fmt.Println("DepthV3Ch closed")
|
||||
}()
|
||||
err = ws.Subscribe("BTCUSDT", coinank_enum.Binance, "0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("sub success")
|
||||
time.Sleep(10 * time.Second)
|
||||
err = ws.UnSubscribe("BTCUSDT", coinank_enum.Binance, "0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("unsub success")
|
||||
time.Sleep(10 * time.Second)
|
||||
ws.Close()
|
||||
fmt.Println("cancel success")
|
||||
}
|
||||
@@ -73,10 +73,8 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
// 空列表是正常情况,不是错误
|
||||
if len(response.Data.Coins) == 0 {
|
||||
log.Printf("ℹ️ AI500 returned empty coin list (no coins meet criteria currently)")
|
||||
return []CoinData{}, nil
|
||||
return nil, fmt.Errorf("coin list is empty")
|
||||
}
|
||||
|
||||
// Set IsAvailable flag
|
||||
@@ -105,8 +103,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 +145,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
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +106,11 @@ func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosit
|
||||
|
||||
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
|
||||
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
|
||||
positions, _, err := c.fetchOIRanking("top", "1h", 20)
|
||||
data, err := c.GetOIRanking("1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return positions, nil
|
||||
return data.TopPositions, nil
|
||||
}
|
||||
|
||||
// GetOITopSymbols retrieves OI top coin symbol list
|
||||
@@ -129,31 +129,6 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
|
||||
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
|
||||
positions, _, err := c.fetchOIRanking("low", "1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOILowSymbols retrieves OI low coin symbol list
|
||||
func (c *Client) GetOILowSymbols() ([]string, error) {
|
||||
positions, err := c.GetOILowPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := NormalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||
if data == nil {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
// Test script to verify Lighter API authentication
|
||||
// Run: go run scripts/test_lighter_orders.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configuration - update these values
|
||||
walletAddr := os.Getenv("LIGHTER_WALLET")
|
||||
apiKeyPrivateKey := os.Getenv("LIGHTER_API_KEY")
|
||||
|
||||
if walletAddr == "" || apiKeyPrivateKey == "" {
|
||||
fmt.Println("Usage: LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go run scripts/test_lighter_orders.go")
|
||||
fmt.Println("Environment variables required:")
|
||||
fmt.Println(" LIGHTER_WALLET - Ethereum wallet address")
|
||||
fmt.Println(" LIGHTER_API_KEY - API key private key (40 bytes hex)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Lighter API Test ===")
|
||||
fmt.Printf("Wallet: %s\n\n", walletAddr)
|
||||
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(304)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Step 1: Get account info (no auth required)
|
||||
fmt.Println("1. Getting account info...")
|
||||
accountIndex, err := getAccountIndex(client, baseURL, walletAddr)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" OK: account_index = %d\n\n", accountIndex)
|
||||
|
||||
// Step 2: Create TxClient and generate auth token
|
||||
fmt.Println("2. Creating TxClient and generating auth token...")
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
txClient, err := lighterClient.NewTxClient(httpClient, apiKeyPrivateKey, accountIndex, 0, chainID)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
authToken, err := txClient.GetAuthToken(time.Now().Add(1 * time.Hour))
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" OK: auth token generated\n\n")
|
||||
|
||||
// Step 3: Test GetActiveOrders with auth query parameter (NEW method)
|
||||
fmt.Println("3. Testing GetActiveOrders with auth query parameter (FIXED)...")
|
||||
encodedAuth := url.QueryEscape(authToken)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||
baseURL, accountIndex, encodedAuth)
|
||||
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(body, &result)
|
||||
|
||||
if code, ok := result["code"].(float64); ok && code == 200 {
|
||||
orders := result["orders"].([]interface{})
|
||||
fmt.Printf(" OK: Retrieved %d orders\n", len(orders))
|
||||
if len(orders) > 0 {
|
||||
fmt.Println(" Sample orders:")
|
||||
for i, o := range orders {
|
||||
if i >= 3 {
|
||||
fmt.Printf(" ... and %d more\n", len(orders)-3)
|
||||
break
|
||||
}
|
||||
order := o.(map[string]interface{})
|
||||
fmt.Printf(" - ID: %v, Price: %v, Side: %v\n",
|
||||
order["order_id"], order["price"], order["is_ask"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" FAILED: %s\n", string(body))
|
||||
fmt.Println("\n Possible causes:")
|
||||
fmt.Println(" - API key not registered on-chain")
|
||||
fmt.Println(" - API key private key incorrect")
|
||||
fmt.Println(" - Account index mismatch")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 4: Test GetActiveOrders with Authorization header (OLD method - for comparison)
|
||||
fmt.Println("\n4. Testing GetActiveOrders with Authorization header (OLD method)...")
|
||||
endpoint2 := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0",
|
||||
baseURL, accountIndex)
|
||||
|
||||
req, _ := http.NewRequest("GET", endpoint2, nil)
|
||||
req.Header.Set("Authorization", authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
} else {
|
||||
defer resp2.Body.Close()
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
var result2 map[string]interface{}
|
||||
json.Unmarshal(body2, &result2)
|
||||
|
||||
if code, ok := result2["code"].(float64); ok && code == 200 {
|
||||
orders := result2["orders"].([]interface{})
|
||||
fmt.Printf(" OK: Retrieved %d orders (both methods work!)\n", len(orders))
|
||||
} else {
|
||||
fmt.Printf(" FAILED: %s\n", string(body2))
|
||||
fmt.Println(" ^ This is expected - Authorization header doesn't work consistently")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== TEST COMPLETE ===")
|
||||
fmt.Println("If test 3 passed, the fix is working correctly.")
|
||||
}
|
||||
|
||||
func getAccountIndex(client *http.Client, baseURL, walletAddr string) (int64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Accounts []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
} `json:"accounts"`
|
||||
SubAccounts []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
} `json:"sub_accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Accounts) > 0 {
|
||||
return result.Accounts[0].AccountIndex, nil
|
||||
}
|
||||
if len(result.SubAccounts) > 0 {
|
||||
return result.SubAccounts[0].AccountIndex, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("no account found")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
594
store/grid.go
594
store/grid.go
@@ -1,594 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== Grid Store Models ====================
|
||||
// These models mirror the grid package types but are defined here
|
||||
// to avoid import cycles between store and grid packages.
|
||||
|
||||
// GridConfigModel GORM model for grid_configs table
|
||||
type GridConfigModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
UserID string `json:"user_id" gorm:"index"`
|
||||
TraderID string `json:"trader_id" gorm:"index"`
|
||||
Symbol string `json:"symbol" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
GridCount int `json:"grid_count" gorm:"default:10"`
|
||||
TotalInvestment float64 `json:"total_investment" gorm:"not null"`
|
||||
Leverage int `json:"leverage" gorm:"default:5"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
UseATRBounds bool `json:"use_atr_bounds" gorm:"default:true"`
|
||||
ATRMultiplier float64 `json:"atr_multiplier" gorm:"default:2.0"`
|
||||
Distribution string `json:"distribution" gorm:"default:gaussian"`
|
||||
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct" gorm:"default:15.0"`
|
||||
StopLossPct float64 `json:"stop_loss_pct" gorm:"default:5.0"`
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct" gorm:"default:10"`
|
||||
MaxPositionSizePct float64 `json:"max_position_size_pct" gorm:"default:30"`
|
||||
|
||||
RegimeCheckInterval int `json:"regime_check_interval" gorm:"default:30"`
|
||||
AutoPauseOnTrend bool `json:"auto_pause_on_trend" gorm:"default:true"`
|
||||
MinRangingScore int `json:"min_ranging_score" gorm:"default:60"`
|
||||
TrendResumeThreshold int `json:"trend_resume_threshold" gorm:"default:70"`
|
||||
|
||||
// Box indicator periods (1h candles)
|
||||
ShortBoxPeriod int `json:"short_box_period" gorm:"default:72"` // 3 days
|
||||
MidBoxPeriod int `json:"mid_box_period" gorm:"default:240"` // 10 days
|
||||
LongBoxPeriod int `json:"long_box_period" gorm:"default:500"` // 21 days
|
||||
|
||||
// Effective leverage limits by regime level
|
||||
NarrowRegimeLeverage int `json:"narrow_regime_leverage" gorm:"default:2"`
|
||||
StandardRegimeLeverage int `json:"standard_regime_leverage" gorm:"default:4"`
|
||||
WideRegimeLeverage int `json:"wide_regime_leverage" gorm:"default:3"`
|
||||
VolatileRegimeLeverage int `json:"volatile_regime_leverage" gorm:"default:2"`
|
||||
|
||||
// Position limits by regime level (percentage of total investment)
|
||||
NarrowRegimePositionPct float64 `json:"narrow_regime_position_pct" gorm:"default:40"`
|
||||
StandardRegimePositionPct float64 `json:"standard_regime_position_pct" gorm:"default:70"`
|
||||
WideRegimePositionPct float64 `json:"wide_regime_position_pct" gorm:"default:60"`
|
||||
VolatileRegimePositionPct float64 `json:"volatile_regime_position_pct" gorm:"default:40"`
|
||||
|
||||
OrderRefreshSec int `json:"order_refresh_sec" gorm:"default:300"`
|
||||
UseMakerOnly bool `json:"use_maker_only" gorm:"default:true"`
|
||||
SlippageTolerPct float64 `json:"slippage_toler_pct" gorm:"default:0.1"`
|
||||
|
||||
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 {
|
||||
return "grid_configs"
|
||||
}
|
||||
|
||||
// GridInstanceModel GORM model for grid_instances table
|
||||
type GridInstanceModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
ConfigID string `json:"config_id" gorm:"index;not null"`
|
||||
Symbol string `json:"symbol" gorm:"not null"`
|
||||
State string `json:"state" gorm:"not null"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
StoppedAt *time.Time `json:"stopped_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
CurrentUpperPrice float64 `json:"current_upper_price"`
|
||||
CurrentLowerPrice float64 `json:"current_lower_price"`
|
||||
CurrentGridSpacing float64 `json:"current_grid_spacing"`
|
||||
ActiveLevelCount int `json:"active_level_count"`
|
||||
CurrentRegime string `json:"current_regime"`
|
||||
RegimeScore int `json:"regime_score"`
|
||||
LastRegimeCheck time.Time `json:"last_regime_check"`
|
||||
ConsecutiveTrending int `json:"consecutive_trending"`
|
||||
|
||||
// Current regime level (narrow/standard/wide/volatile/trending)
|
||||
CurrentRegimeLevel string `json:"current_regime_level" gorm:"default:standard"`
|
||||
|
||||
// Box state
|
||||
ShortBoxUpper float64 `json:"short_box_upper"`
|
||||
ShortBoxLower float64 `json:"short_box_lower"`
|
||||
MidBoxUpper float64 `json:"mid_box_upper"`
|
||||
MidBoxLower float64 `json:"mid_box_lower"`
|
||||
LongBoxUpper float64 `json:"long_box_upper"`
|
||||
LongBoxLower float64 `json:"long_box_lower"`
|
||||
|
||||
// Breakout state
|
||||
BreakoutLevel string `json:"breakout_level" gorm:"default:none"` // none/short/mid/long
|
||||
BreakoutDirection string `json:"breakout_direction"` // up/down
|
||||
BreakoutConfirmCount int `json:"breakout_confirm_count" gorm:"default:0"`
|
||||
BreakoutStartTime time.Time `json:"breakout_start_time"`
|
||||
|
||||
// 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"`
|
||||
WinningTrades int `json:"winning_trades" gorm:"default:0"`
|
||||
MaxDrawdown float64 `json:"max_drawdown" gorm:"default:0"`
|
||||
CurrentDrawdown float64 `json:"current_drawdown" gorm:"default:0"`
|
||||
PeakEquity float64 `json:"peak_equity" gorm:"default:0"`
|
||||
DailyProfit float64 `json:"daily_profit" gorm:"default:0"`
|
||||
DailyLoss float64 `json:"daily_loss" gorm:"default:0"`
|
||||
LastDailyReset time.Time `json:"last_daily_reset"`
|
||||
}
|
||||
|
||||
func (GridInstanceModel) TableName() string {
|
||||
return "grid_instances"
|
||||
}
|
||||
|
||||
// GridLevelModel GORM model for grid_levels table
|
||||
type GridLevelModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
LevelIndex int `json:"level_index" gorm:"not null"`
|
||||
Price float64 `json:"price" gorm:"not null"`
|
||||
State string `json:"state" gorm:"not null"`
|
||||
Side string `json:"side"`
|
||||
OrderID string `json:"order_id,omitempty"`
|
||||
OrderPrice float64 `json:"order_price,omitempty"`
|
||||
OrderQuantity float64 `json:"order_quantity,omitempty"`
|
||||
OrderCreatedAt *time.Time `json:"order_created_at,omitempty"`
|
||||
PositionSize float64 `json:"position_size,omitempty"`
|
||||
PositionEntry float64 `json:"position_entry,omitempty"`
|
||||
PositionOpenAt *time.Time `json:"position_open_at,omitempty"`
|
||||
AllocationWeight float64 `json:"allocation_weight"`
|
||||
AllocatedUSD float64 `json:"allocated_usd"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (GridLevelModel) TableName() string {
|
||||
return "grid_levels"
|
||||
}
|
||||
|
||||
// GridEventModel GORM model for grid_events table
|
||||
type GridEventModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
LevelID string `json:"level_id,omitempty" gorm:"index"`
|
||||
EventType string `json:"event_type" gorm:"not null"`
|
||||
EventTime time.Time `json:"event_time" gorm:"autoCreateTime"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Quantity float64 `json:"quantity,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
PnL float64 `json:"pnl,omitempty"`
|
||||
Fee float64 `json:"fee,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
OldRegime string `json:"old_regime,omitempty"`
|
||||
NewRegime string `json:"new_regime,omitempty"`
|
||||
TriggerType string `json:"trigger_type,omitempty"`
|
||||
RawData string `json:"raw_data,omitempty" gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (GridEventModel) TableName() string {
|
||||
return "grid_events"
|
||||
}
|
||||
|
||||
// GridRegimeAssessmentModel GORM model for grid_regime_assessments table
|
||||
type GridRegimeAssessmentModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
AssessedAt time.Time `json:"assessed_at" gorm:"autoCreateTime"`
|
||||
Regime string `json:"regime" gorm:"not null"`
|
||||
Score int `json:"score" gorm:"not null"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BollingerSignal int `json:"bollinger_signal"`
|
||||
EMASignal int `json:"ema_signal"`
|
||||
MACDSignal int `json:"macd_signal"`
|
||||
VolumeSignal int `json:"volume_signal"`
|
||||
OISignal int `json:"oi_signal"`
|
||||
FundingSignal int `json:"funding_signal"`
|
||||
CandleSignal int `json:"candle_signal"`
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerWidth float64 `json:"bollinger_width"`
|
||||
EMADistance float64 `json:"ema_distance"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
AIReasoning string `json:"ai_reasoning" gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (GridRegimeAssessmentModel) TableName() string {
|
||||
return "grid_regime_assessments"
|
||||
}
|
||||
|
||||
// ==================== Grid Store ====================
|
||||
|
||||
// GridStore provides database operations for grid trading
|
||||
type GridStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGridStore creates a new grid store
|
||||
func NewGridStore(db *gorm.DB) *GridStore {
|
||||
return &GridStore{db: db}
|
||||
}
|
||||
|
||||
// InitTables initializes grid-related tables
|
||||
func (s *GridStore) InitTables() error {
|
||||
// For PostgreSQL with existing tables, skip AutoMigrate to avoid type conflicts
|
||||
if s.db.Dialector.Name() == "postgres" {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'grid_configs'`).Scan(&tableExists)
|
||||
|
||||
if tableExists > 0 {
|
||||
// Tables exist, just ensure indexes
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_user_id ON grid_configs(user_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_trader_id ON grid_configs(trader_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_instances_config_id ON grid_instances(config_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_levels_instance_id ON grid_levels(instance_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_instance_id ON grid_events(instance_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_level_id ON grid_events(level_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_regime_assessments_instance_id ON grid_regime_assessments(instance_id)`)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate all grid tables
|
||||
if err := s.db.AutoMigrate(
|
||||
&GridConfigModel{},
|
||||
&GridInstanceModel{},
|
||||
&GridLevelModel{},
|
||||
&GridEventModel{},
|
||||
&GridRegimeAssessmentModel{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to migrate grid tables: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Config Operations ====================
|
||||
|
||||
// SaveGridConfig saves or updates a grid configuration
|
||||
func (s *GridStore) SaveGridConfig(config *GridConfigModel) error {
|
||||
config.UpdatedAt = time.Now()
|
||||
if config.CreatedAt.IsZero() {
|
||||
config.CreatedAt = time.Now()
|
||||
}
|
||||
return s.db.Save(config).Error
|
||||
}
|
||||
|
||||
// LoadGridConfig loads a grid configuration by ID
|
||||
func (s *GridStore) LoadGridConfig(id string) (*GridConfigModel, error) {
|
||||
var config GridConfigModel
|
||||
err := s.db.Where("id = ?", id).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// LoadGridConfigByTrader loads a grid configuration by trader ID
|
||||
func (s *GridStore) LoadGridConfigByTrader(traderID string) (*GridConfigModel, error) {
|
||||
var config GridConfigModel
|
||||
err := s.db.Where("trader_id = ? AND is_active = true", traderID).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ListGridConfigs lists all grid configurations for a user
|
||||
func (s *GridStore) ListGridConfigs(userID string) ([]GridConfigModel, error) {
|
||||
var configs []GridConfigModel
|
||||
err := s.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&configs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// DeleteGridConfig deletes a grid configuration and all related data
|
||||
func (s *GridStore) DeleteGridConfig(id string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Get all instances for this config
|
||||
var instances []GridInstanceModel
|
||||
if err := tx.Where("config_id = ?", id).Find(&instances).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete related data for each instance
|
||||
for _, instance := range instances {
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridLevelModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridEventModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridRegimeAssessmentModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete instances
|
||||
if err := tx.Where("config_id = ?", id).Delete(&GridInstanceModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete config
|
||||
return tx.Where("id = ?", id).Delete(&GridConfigModel{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Instance Operations ====================
|
||||
|
||||
// SaveGridInstance saves or updates a grid instance
|
||||
func (s *GridStore) SaveGridInstance(instance *GridInstanceModel) error {
|
||||
instance.UpdatedAt = time.Now()
|
||||
return s.db.Save(instance).Error
|
||||
}
|
||||
|
||||
// LoadGridInstance loads a grid instance by config ID
|
||||
func (s *GridStore) LoadGridInstance(configID string) (*GridInstanceModel, error) {
|
||||
var instance GridInstanceModel
|
||||
err := s.db.Where("config_id = ?", configID).
|
||||
Order("started_at DESC").
|
||||
First(&instance).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
// LoadGridInstanceByID loads a grid instance by ID
|
||||
func (s *GridStore) LoadGridInstanceByID(id string) (*GridInstanceModel, error) {
|
||||
var instance GridInstanceModel
|
||||
err := s.db.Where("id = ?", id).First(&instance).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
// ListGridInstances lists all instances for a config
|
||||
func (s *GridStore) ListGridInstances(configID string) ([]GridInstanceModel, error) {
|
||||
var instances []GridInstanceModel
|
||||
err := s.db.Where("config_id = ?", configID).
|
||||
Order("started_at DESC").
|
||||
Find(&instances).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ==================== Level Operations ====================
|
||||
|
||||
// SaveGridLevel saves or updates a grid level
|
||||
func (s *GridStore) SaveGridLevel(level *GridLevelModel) error {
|
||||
level.UpdatedAt = time.Now()
|
||||
return s.db.Save(level).Error
|
||||
}
|
||||
|
||||
// SaveGridLevels saves multiple grid levels
|
||||
func (s *GridStore) SaveGridLevels(levels []GridLevelModel) error {
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range levels {
|
||||
levels[i].UpdatedAt = now
|
||||
}
|
||||
return s.db.Save(&levels).Error
|
||||
}
|
||||
|
||||
// LoadGridLevels loads all levels for an instance
|
||||
func (s *GridStore) LoadGridLevels(instanceID string) ([]GridLevelModel, error) {
|
||||
var levels []GridLevelModel
|
||||
err := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("level_index ASC").
|
||||
Find(&levels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
// DeleteGridLevels deletes all levels for an instance
|
||||
func (s *GridStore) DeleteGridLevels(instanceID string) error {
|
||||
return s.db.Where("instance_id = ?", instanceID).Delete(&GridLevelModel{}).Error
|
||||
}
|
||||
|
||||
// ==================== Event Operations ====================
|
||||
|
||||
// SaveGridEvent saves a grid event
|
||||
func (s *GridStore) SaveGridEvent(event *GridEventModel) error {
|
||||
if event.EventTime.IsZero() {
|
||||
event.EventTime = time.Now()
|
||||
}
|
||||
return s.db.Create(event).Error
|
||||
}
|
||||
|
||||
// LoadRecentGridEvents loads recent events for an instance
|
||||
func (s *GridStore) LoadRecentGridEvents(instanceID string, limit int) ([]GridEventModel, error) {
|
||||
var events []GridEventModel
|
||||
query := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("event_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// LoadGridEventsByType loads events of a specific type
|
||||
func (s *GridStore) LoadGridEventsByType(instanceID, eventType string, limit int) ([]GridEventModel, error) {
|
||||
var events []GridEventModel
|
||||
query := s.db.Where("instance_id = ? AND event_type = ?", instanceID, eventType).
|
||||
Order("event_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// CountGridEvents counts events for an instance
|
||||
func (s *GridStore) CountGridEvents(instanceID string) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&GridEventModel{}).
|
||||
Where("instance_id = ?", instanceID).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ==================== Regime Assessment Operations ====================
|
||||
|
||||
// SaveGridRegimeAssessment saves a regime assessment
|
||||
func (s *GridStore) SaveGridRegimeAssessment(assessment *GridRegimeAssessmentModel) error {
|
||||
if assessment.AssessedAt.IsZero() {
|
||||
assessment.AssessedAt = time.Now()
|
||||
}
|
||||
return s.db.Create(assessment).Error
|
||||
}
|
||||
|
||||
// LoadLatestGridRegime loads the latest regime assessment
|
||||
func (s *GridStore) LoadLatestGridRegime(instanceID string) (*GridRegimeAssessmentModel, error) {
|
||||
var assessment GridRegimeAssessmentModel
|
||||
err := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC").
|
||||
First(&assessment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &assessment, nil
|
||||
}
|
||||
|
||||
// LoadGridRegimeHistory loads regime assessment history
|
||||
func (s *GridStore) LoadGridRegimeHistory(instanceID string, limit int) ([]GridRegimeAssessmentModel, error) {
|
||||
var assessments []GridRegimeAssessmentModel
|
||||
query := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&assessments).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assessments, nil
|
||||
}
|
||||
|
||||
// ==================== Statistics Operations ====================
|
||||
|
||||
// GetGridInstanceStatistics returns statistics for an instance
|
||||
func (s *GridStore) GetGridInstanceStatistics(instanceID string) (map[string]interface{}, error) {
|
||||
var instance GridInstanceModel
|
||||
if err := s.db.Where("id = ?", instanceID).First(&instance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count events by type
|
||||
var eventCounts []struct {
|
||||
EventType string
|
||||
Count int64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("event_type, count(*) as count").
|
||||
Where("instance_id = ?", instanceID).
|
||||
Group("event_type").
|
||||
Find(&eventCounts)
|
||||
|
||||
eventCountMap := make(map[string]int64)
|
||||
for _, ec := range eventCounts {
|
||||
eventCountMap[ec.EventType] = ec.Count
|
||||
}
|
||||
|
||||
// Get latest regime
|
||||
var latestRegime GridRegimeAssessmentModel
|
||||
s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC").
|
||||
First(&latestRegime)
|
||||
|
||||
winRate := 0.0
|
||||
if instance.TotalTrades > 0 {
|
||||
winRate = float64(instance.WinningTrades) / float64(instance.TotalTrades) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"instance_id": instance.ID,
|
||||
"state": instance.State,
|
||||
"started_at": instance.StartedAt,
|
||||
"stopped_at": instance.StoppedAt,
|
||||
"total_profit": instance.TotalProfit,
|
||||
"total_fees": instance.TotalFees,
|
||||
"total_trades": instance.TotalTrades,
|
||||
"winning_trades": instance.WinningTrades,
|
||||
"win_rate": winRate,
|
||||
"max_drawdown": instance.MaxDrawdown,
|
||||
"current_drawdown": instance.CurrentDrawdown,
|
||||
"peak_equity": instance.PeakEquity,
|
||||
"active_level_count": instance.ActiveLevelCount,
|
||||
"current_regime": instance.CurrentRegime,
|
||||
"regime_score": instance.RegimeScore,
|
||||
"event_counts": eventCountMap,
|
||||
"latest_regime_score": latestRegime.Score,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGridPerformanceMetrics returns performance metrics for a time period
|
||||
func (s *GridStore) GetGridPerformanceMetrics(instanceID string, from, to time.Time) (map[string]interface{}, error) {
|
||||
// Count trades in period
|
||||
var tradeCounts struct {
|
||||
TotalFills int64
|
||||
BuyFills int64
|
||||
SellFills int64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("count(*) as total_fills, "+
|
||||
"sum(case when side = 'buy' then 1 else 0 end) as buy_fills, "+
|
||||
"sum(case when side = 'sell' then 1 else 0 end) as sell_fills").
|
||||
Where("instance_id = ? AND event_type = 'order_filled' AND event_time BETWEEN ? AND ?",
|
||||
instanceID, from, to).
|
||||
Scan(&tradeCounts)
|
||||
|
||||
// Sum profit/loss
|
||||
var pnlSum struct {
|
||||
TotalPnL float64
|
||||
TotalFee float64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("coalesce(sum(pnl), 0) as total_pnl, coalesce(sum(fee), 0) as total_fee").
|
||||
Where("instance_id = ? AND event_time BETWEEN ? AND ?", instanceID, from, to).
|
||||
Scan(&pnlSum)
|
||||
|
||||
// Count regime changes
|
||||
var regimeChanges int64
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Where("instance_id = ? AND event_type = 'regime_change' AND event_time BETWEEN ? AND ?",
|
||||
instanceID, from, to).
|
||||
Count(®imeChanges)
|
||||
|
||||
return map[string]interface{}{
|
||||
"period_start": from,
|
||||
"period_end": to,
|
||||
"total_fills": tradeCounts.TotalFills,
|
||||
"buy_fills": tradeCounts.BuyFills,
|
||||
"sell_fills": tradeCounts.SellFills,
|
||||
"total_pnl": pnlSum.TotalPnL,
|
||||
"total_fees": pnlSum.TotalFee,
|
||||
"net_pnl": pnlSum.TotalPnL - pnlSum.TotalFee,
|
||||
"regime_changes": regimeChanges,
|
||||
}, nil
|
||||
}
|
||||
@@ -156,22 +156,18 @@ 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 8 decimal places for price precision (crypto standard)
|
||||
newEntryPrice = math.Round(newEntryPrice*100000000) / 100000000
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
newFee := pos.Fee + addFee
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": newQty,
|
||||
"entry_quantity": newEntryQty,
|
||||
"entry_price": newEntryPrice,
|
||||
"fee": newFee,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ReducePositionQuantity reduces position quantity for partial close
|
||||
// If quantity reaches 0 (or near 0), automatically closes the position
|
||||
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
||||
var pos TraderPosition
|
||||
if err := s.db.First(&pos, id).Error; err != nil {
|
||||
@@ -188,26 +184,7 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
||||
var newExitPrice float64
|
||||
if newClosedQty > 0 {
|
||||
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
||||
// Use 8 decimal places for price precision (crypto standard)
|
||||
newExitPrice = math.Round(newExitPrice*100000000) / 100000000
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
// Check if position should be fully closed (quantity reduced to ~0)
|
||||
const QUANTITY_TOLERANCE = 0.0001
|
||||
if newQty <= QUANTITY_TOLERANCE {
|
||||
// Auto-close: set status to CLOSED
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": 0,
|
||||
"fee": newFee,
|
||||
"exit_price": newExitPrice,
|
||||
"realized_pnl": newPnL,
|
||||
"status": "CLOSED",
|
||||
"exit_time": nowMs,
|
||||
"close_reason": "sync",
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
||||
}
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
@@ -215,17 +192,14 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
||||
"fee": newFee,
|
||||
"exit_price": newExitPrice,
|
||||
"realized_pnl": newPnL,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
||||
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exchange_id": exchangeID,
|
||||
"exchange_type": exchangeType,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,7 @@ func (pb *PositionBuilder) handleClose(
|
||||
var finalExitPrice float64
|
||||
if totalClosed > 0 {
|
||||
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
|
||||
// Use 8 decimal places for price precision (crypto standard)
|
||||
finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000
|
||||
finalExitPrice = math.Round(finalExitPrice*100) / 100
|
||||
} else {
|
||||
finalExitPrice = price
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ type Store struct {
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -157,9 +156,6 @@ func (s *Store) initTables() error {
|
||||
if err := s.Order().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize order tables: %w", err)
|
||||
}
|
||||
if err := s.Grid().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -283,16 +279,6 @@ func (s *Store) Order() *OrderStore {
|
||||
return s.order
|
||||
}
|
||||
|
||||
// Grid gets grid trading storage
|
||||
func (s *Store) Grid() *GridStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.grid == nil {
|
||||
s.grid = NewGridStore(s.gdb)
|
||||
}
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
|
||||
@@ -32,9 +32,6 @@ func (Strategy) TableName() string { return "strategies" }
|
||||
|
||||
// StrategyConfig strategy configuration details (JSON structure)
|
||||
type StrategyConfig struct {
|
||||
// Strategy type: "ai_trading" (default) or "grid_trading"
|
||||
StrategyType string `json:"strategy_type,omitempty"`
|
||||
|
||||
// language setting: "zh" for Chinese, "en" for English
|
||||
// This determines the language used for data formatting and prompt generation
|
||||
Language string `json:"language,omitempty"`
|
||||
@@ -48,43 +45,6 @@ type StrategyConfig struct {
|
||||
RiskControl RiskControlConfig `json:"risk_control"`
|
||||
// editable sections of System Prompt
|
||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||||
|
||||
// Grid trading configuration (only used when StrategyType == "grid_trading")
|
||||
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
|
||||
}
|
||||
|
||||
// GridStrategyConfig grid trading specific configuration
|
||||
type GridStrategyConfig struct {
|
||||
// Trading pair (e.g., "BTCUSDT")
|
||||
Symbol string `json:"symbol"`
|
||||
// Number of grid levels (5-50)
|
||||
GridCount int `json:"grid_count"`
|
||||
// Total investment in USDT
|
||||
TotalInvestment float64 `json:"total_investment"`
|
||||
// Leverage (1-20)
|
||||
Leverage int `json:"leverage"`
|
||||
// Upper price boundary (0 = auto-calculate from ATR)
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
// Lower price boundary (0 = auto-calculate from ATR)
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
// Use ATR to auto-calculate bounds
|
||||
UseATRBounds bool `json:"use_atr_bounds"`
|
||||
// ATR multiplier for bound calculation (default 2.0)
|
||||
ATRMultiplier float64 `json:"atr_multiplier"`
|
||||
// Position distribution: "uniform" | "gaussian" | "pyramid"
|
||||
Distribution string `json:"distribution"`
|
||||
// Maximum drawdown percentage before emergency exit
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
// Stop loss percentage per position
|
||||
StopLossPct float64 `json:"stop_loss_pct"`
|
||||
// Daily loss limit percentage
|
||||
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
|
||||
@@ -101,7 +61,7 @@ type PromptSectionsConfig struct {
|
||||
|
||||
// CoinSourceConfig coin source configuration
|
||||
type CoinSourceConfig struct {
|
||||
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
|
||||
// source type: "static" | "ai500" | "oi_top" | "mixed"
|
||||
SourceType string `json:"source_type"`
|
||||
// static coin list (used when source_type = "static")
|
||||
StaticCoins []string `json:"static_coins,omitempty"`
|
||||
@@ -111,14 +71,10 @@ type CoinSourceConfig struct {
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
// AI500 coin pool maximum count
|
||||
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||
// whether to use OI Top (持仓增加榜,适合做多)
|
||||
// whether to use OI Top
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
// OI Top maximum count
|
||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||
// whether to use OI Low (持仓减少榜,适合做空)
|
||||
UseOILow bool `json:"use_oi_low"`
|
||||
// OI Low maximum count
|
||||
OILowLimit int `json:"oi_low_limit,omitempty"`
|
||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||
}
|
||||
|
||||
@@ -256,9 +212,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
UseAI500: true,
|
||||
AI500Limit: 10,
|
||||
UseOITop: false,
|
||||
OITopLimit: 10,
|
||||
UseOILow: false,
|
||||
OILowLimit: 10,
|
||||
OITopLimit: 20,
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
|
||||
@@ -248,23 +248,3 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByExchangeID gets traders that use a specific exchange
|
||||
func (s *TraderStore) ListByExchangeID(userID, exchangeID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND exchange_id = ?", userID, exchangeID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByAIModelID gets traders that use a specific AI model
|
||||
func (s *TraderStore) ListByAIModelID(userID, aiModelID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND ai_model_id = ?", userID, aiModelID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
@@ -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,192 +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) {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
|
||||
body, err := t.request("GET", "/fapi/v3/openOrders", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrderID int64 `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"positionSide"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
StopPrice string `json:"stopPrice"`
|
||||
OrigQty string `json:"origQty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []types.OpenOrder
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: order.Side,
|
||||
PositionSide: order.PositionSide,
|
||||
Type: order.Type,
|
||||
Price: price,
|
||||
StopPrice: stopPrice,
|
||||
Quantity: quantity,
|
||||
Status: order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
logger.Infof("✓ ASTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Get precision information
|
||||
prec, err := t.getPrecision(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get precision: %w", err)
|
||||
}
|
||||
|
||||
// Convert to string with correct precision format
|
||||
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
|
||||
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
|
||||
|
||||
// Determine side
|
||||
side := "BUY"
|
||||
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"symbol": req.Symbol,
|
||||
"positionSide": "BOTH",
|
||||
"type": "LIMIT",
|
||||
"side": side,
|
||||
"timeInForce": "GTC",
|
||||
"quantity": qtyStr,
|
||||
"price": priceStr,
|
||||
}
|
||||
|
||||
// Add reduceOnly if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = "true"
|
||||
}
|
||||
|
||||
body, err := t.request("POST", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID
|
||||
orderID := ""
|
||||
if id, ok := result["orderId"].(float64); ok {
|
||||
orderID = fmt.Sprintf("%.0f", id)
|
||||
} else if id, ok := result["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
|
||||
// Extract client order ID
|
||||
clientOrderID := ""
|
||||
if cid, ok := result["clientOrderId"].(string); ok {
|
||||
clientOrderID = cid
|
||||
}
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: clientOrderID,
|
||||
Symbol: req.Symbol,
|
||||
Side: side,
|
||||
Price: formattedPrice,
|
||||
Quantity: formattedQty,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by order ID
|
||||
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 20
|
||||
}
|
||||
|
||||
// Aster uses public endpoint (no signature required)
|
||||
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"` // [[price, qty], ...]
|
||||
Asks [][]string `json:"asks"` // [[price, qty], ...]
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert string arrays to float64 arrays
|
||||
bids = make([][]float64, len(result.Bids))
|
||||
for i, bid := range result.Bids {
|
||||
if len(bid) >= 2 {
|
||||
price, _ := strconv.ParseFloat(bid[0], 64)
|
||||
qty, _ := strconv.ParseFloat(bid[1], 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
asks = make([][]float64, len(result.Asks))
|
||||
for i, ask := range result.Asks {
|
||||
if len(ask) >= 2 {
|
||||
price, _ := strconv.ParseFloat(ask[0], 64)
|
||||
qty, _ := strconv.ParseFloat(ask[1], 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Aster open orders
|
||||
return []OpenOrder{}, nil
|
||||
}
|
||||
@@ -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,7 +19,7 @@ import (
|
||||
// AsterTraderTestSuite Aster trader test suite
|
||||
// Inherits TraderTestSuite and adds Aster specific mock logic
|
||||
type AsterTraderTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
@@ -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,21 +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/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -32,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
|
||||
@@ -53,15 +44,6 @@ type AutoTraderConfig struct {
|
||||
BitgetSecretKey string
|
||||
BitgetPassphrase string
|
||||
|
||||
// Gate API configuration
|
||||
GateAPIKey string
|
||||
GateSecretKey string
|
||||
|
||||
// KuCoin API configuration
|
||||
KuCoinAPIKey string
|
||||
KuCoinSecretKey string
|
||||
KuCoinPassphrase string
|
||||
|
||||
// Hyperliquid configuration
|
||||
HyperliquidPrivateKey string
|
||||
HyperliquidWalletAddr string
|
||||
@@ -141,7 +123,6 @@ type AutoTrader struct {
|
||||
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
||||
lastBalanceSyncTime time.Time // Last balance sync time
|
||||
userID string // User ID
|
||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||
}
|
||||
|
||||
// NewAutoTrader creates an automatic trader
|
||||
@@ -242,31 +223,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)
|
||||
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)
|
||||
}
|
||||
@@ -278,7 +253,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,
|
||||
@@ -387,7 +362,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)
|
||||
}
|
||||
@@ -395,7 +370,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)
|
||||
}
|
||||
@@ -403,7 +378,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)
|
||||
}
|
||||
@@ -411,7 +386,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)
|
||||
}
|
||||
@@ -419,7 +394,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)
|
||||
}
|
||||
@@ -427,7 +402,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)
|
||||
}
|
||||
@@ -435,51 +410,19 @@ 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()
|
||||
|
||||
// Check if this is a grid trading strategy
|
||||
isGridStrategy := at.IsGridStrategy()
|
||||
if isGridStrategy {
|
||||
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
|
||||
if err := at.InitializeGrid(); err != nil {
|
||||
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
|
||||
return fmt.Errorf("grid initialization failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately on first run
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
at.isRunningMutex.RLock()
|
||||
@@ -492,15 +435,9 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
case <-at.stopMonitorCh:
|
||||
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
|
||||
return nil
|
||||
@@ -575,25 +512,8 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
logger.Info(strings.Repeat("=", 70))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||||
@@ -872,19 +792,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()
|
||||
return nil, fmt.Errorf("trader has no strategy engine configured")
|
||||
}
|
||||
candidateCoins, 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
|
||||
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
|
||||
@@ -1106,7 +1021,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
|
||||
}
|
||||
@@ -1223,7 +1138,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
|
||||
}
|
||||
@@ -1322,7 +1237,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
|
||||
}
|
||||
@@ -1386,7 +1301,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
|
||||
}
|
||||
@@ -1450,12 +1365,6 @@ func (at *AutoTrader) GetID() string {
|
||||
return at.id
|
||||
}
|
||||
|
||||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||||
// This is used by grid trading and other components that need direct exchange access
|
||||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||||
return at.trader
|
||||
}
|
||||
|
||||
// GetName gets trader name
|
||||
func (at *AutoTrader) GetName() string {
|
||||
return at.name
|
||||
@@ -1562,7 +1471,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
isRunning := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
|
||||
result := map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"trader_id": at.id,
|
||||
"trader_name": at.name,
|
||||
"ai_model": at.aiModel,
|
||||
@@ -1577,16 +1486,6 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||||
"ai_provider": aiProvider,
|
||||
}
|
||||
|
||||
// Add strategy info
|
||||
if at.config.StrategyConfig != nil {
|
||||
result["strategy_type"] = at.config.StrategyConfig.StrategyType
|
||||
if at.config.StrategyConfig.GridConfig != nil {
|
||||
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountInfo gets account information (for API)
|
||||
@@ -1982,7 +1881,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":
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||||
return
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -717,125 +716,6 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Format quantity to correct precision
|
||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price to correct precision
|
||||
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side and position side
|
||||
var side futures.SideType
|
||||
var positionSide futures.PositionSideType
|
||||
|
||||
if req.Side == "BUY" {
|
||||
side = futures.SideTypeBuy
|
||||
positionSide = futures.PositionSideTypeLong
|
||||
} else {
|
||||
side = futures.SideTypeSell
|
||||
positionSide = futures.PositionSideTypeShort
|
||||
}
|
||||
|
||||
// Build order service with broker ID
|
||||
orderService := t.client.NewCreateOrderService().
|
||||
Symbol(req.Symbol).
|
||||
Side(side).
|
||||
PositionSide(positionSide).
|
||||
Type(futures.OrderTypeLimit).
|
||||
TimeInForce(futures.TimeInForceTypeGTC).
|
||||
Quantity(quantityStr).
|
||||
Price(priceStr).
|
||||
NewClientOrderID(getBrOrderID())
|
||||
|
||||
// Execute order
|
||||
order, err := orderService.Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
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{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
ClientID: order.ClientOrderID,
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
PositionSide: string(order.PositionSide),
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: string(order.Status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
|
||||
// Parse order ID to int64
|
||||
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.client.NewCancelOrderService().
|
||||
Symbol(symbol).
|
||||
OrderID(orderIDInt).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
book, err := t.client.NewDepthService().
|
||||
Symbol(symbol).
|
||||
Limit(depth).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert bids
|
||||
bids = make([][]float64, len(book.Bids))
|
||||
for i, bid := range book.Bids {
|
||||
price, _ := strconv.ParseFloat(bid.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
// Convert asks
|
||||
asks = make([][]float64, len(book.Asks))
|
||||
for i, ask := range book.Asks {
|
||||
price, _ := strconv.ParseFloat(ask.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
|
||||
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
|
||||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
@@ -897,8 +777,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 +794,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 +817,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),
|
||||
@@ -1155,42 +1035,6 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
|
||||
return fmt.Sprintf(format, quantity), nil
|
||||
}
|
||||
|
||||
// GetSymbolPricePrecision gets the price precision for a trading pair
|
||||
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
|
||||
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get trading rules: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range exchangeInfo.Symbols {
|
||||
if s.Symbol == symbol {
|
||||
// Get precision from PRICE_FILTER filter
|
||||
for _, filter := range s.Filters {
|
||||
if filter["filterType"] == "PRICE_FILTER" {
|
||||
tickSize := filter["tickSize"].(string)
|
||||
precision := calculatePrecision(tickSize)
|
||||
return precision, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2 decimal places for price
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
// FormatPrice formats price to correct precision
|
||||
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
|
||||
precision, err := t.GetSymbolPricePrecision(symbol)
|
||||
if err != nil {
|
||||
// If retrieval fails, use default format
|
||||
return fmt.Sprintf("%.2f", price), nil
|
||||
}
|
||||
|
||||
format := fmt.Sprintf("%%.%df", precision)
|
||||
return fmt.Sprintf(format, price), nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && stringContains(s, substr)
|
||||
@@ -1248,14 +1092,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 +1128,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 +1149,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 +1167,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 +1176,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 +1192,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 +1209,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 +1235,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 +1252,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,7 +20,7 @@ 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
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -57,8 +56,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
|
||||
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||
syncStartTimeMs := nowMs
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
|
||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
@@ -97,10 +100,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
|
||||
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
|
||||
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
||||
// IMPORTANT: Must run always, not just when symbolMap is empty,
|
||||
// because a position might be fully closed (no active position) but have PnL
|
||||
if len(symbolMap) == 0 {
|
||||
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
|
||||
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||
@@ -110,6 +113,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var changedSymbols []string
|
||||
for s := range symbolMap {
|
||||
@@ -118,20 +122,21 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
|
||||
if len(changedSymbols) == 0 {
|
||||
logger.Infof("📭 No symbols with new trades to sync")
|
||||
// DON'T update lastSyncTime to current time here!
|
||||
// Keep using the last actual trade time from DB to avoid creating gaps
|
||||
// The lastSyncTimeMs from DB already has +1000ms buffer added
|
||||
// Update last sync time even if no changes
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -153,12 +158,17 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
|
||||
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
|
||||
|
||||
if len(allTrades) == 0 {
|
||||
// No trades returned, but symbols were detected - might be false positive from COMMISSION/PnL detection
|
||||
// Don't update lastSyncTime, keep using DB value
|
||||
if len(failedSymbols) > 0 {
|
||||
logger.Infof(" ⚠️ %d symbols failed: %v", len(failedSymbols), failedSymbols)
|
||||
// Only update last sync time if ALL symbols were successfully queried
|
||||
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||
if len(failedSymbols) == 0 {
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
} else {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
if len(allTrades) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,12 +182,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
skippedCount := 0
|
||||
for _, trade := range allTrades {
|
||||
// Check if trade already exists
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
skippedCount++
|
||||
continue // Trade already exists, skip
|
||||
}
|
||||
|
||||
@@ -272,21 +280,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
trade.Time.UTC().Format("01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Update lastSyncTime to the LATEST trade time (not current time!)
|
||||
// This ensures next sync starts from where we left off, not from "now"
|
||||
// allTrades is already sorted by time ASC, so last element is the latest
|
||||
if len(allTrades) > 0 && len(failedSymbols) == 0 {
|
||||
latestTradeTimeMs := allTrades[len(allTrades)-1].Time.UTC().UnixMilli()
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = latestTradeTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
logger.Infof("📅 Updated lastSyncTime to latest trade: %s (UTC)",
|
||||
time.UnixMilli(latestTradeTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
} else if len(failedSymbols) > 0 {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced, %d skipped (already exist)", syncedCount, skippedCount)
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package binance
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/store"
|
||||
@@ -92,7 +92,7 @@ func TestBinanceSyncE2E(t *testing.T) {
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s",
|
||||
i+1, order.ExchangeOrderID, order.Symbol, order.Side,
|
||||
order.Quantity, order.Price, order.OrderAction,
|
||||
time.UnixMilli(order.FilledAt).Format(time.RFC3339))
|
||||
order.FilledAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,10 @@ func TestBinanceSyncE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test GetLastFillTimeByExchange
|
||||
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
lastFillTime, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
|
||||
} else {
|
||||
lastFillTime := time.UnixMilli(lastFillTimeMs)
|
||||
t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339))
|
||||
|
||||
// Check if it would be in the future (the bug we fixed)
|
||||
@@ -176,7 +175,7 @@ func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||
Price: 50000,
|
||||
Quantity: 0.001,
|
||||
QuoteQuantity: 50,
|
||||
CreatedAt: localTime.UnixMilli(), // This time is "in the future" if interpreted as UTC
|
||||
CreatedAt: localTime, // This time is "in the future" if interpreted as UTC
|
||||
}
|
||||
if err := orderStore.CreateFill(fakeFill); err != nil {
|
||||
t.Fatalf("Failed to create fake fill: %v", err)
|
||||
@@ -187,11 +186,10 @@ func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Check GetLastFillTimeByExchange
|
||||
lastFillTimeMs2, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
lastFillTime2 := time.UnixMilli(lastFillTimeMs2)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime2.Format(time.RFC3339))
|
||||
lastFillTime, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime.Format(time.RFC3339))
|
||||
|
||||
if lastFillTime2.After(time.Now().UTC()) {
|
||||
if lastFillTime.After(time.Now().UTC()) {
|
||||
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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"` // one-way: buy_single/sell_single, hedge: open/close
|
||||
FeeDetail []struct {
|
||||
FeeCoin string `json:"feeCoin"`
|
||||
TotalFee string `json:"totalFee"`
|
||||
} `json:"feeDetail"`
|
||||
TradeSide string `json:"tradeSide"` // open, close
|
||||
} `json:"fillList"`
|
||||
}
|
||||
|
||||
// Try parsing as wrapped response first (fillList field)
|
||||
var wrappedResp struct {
|
||||
FillList []BitgetFill `json:"fillList"`
|
||||
}
|
||||
|
||||
// Try direct array format (Bitget V2 API returns data as direct array)
|
||||
var directFills []BitgetFill
|
||||
|
||||
// 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))
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
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))
|
||||
trades := make([]BitgetTrade, 0, len(resp.FillList))
|
||||
|
||||
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,262 +1098,7 @@ func genBitgetClientOid() string {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
var result []types.OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", bitgetPendingPath, params)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
TradeSide string `json:"tradeSide"` // open/close
|
||||
PosSide string `json:"posSide"` // long/short
|
||||
OrderType string `json:"orderType"` // limit/market
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders.EntrustedList {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
// Convert side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrderType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
"productType": "USDT-FUTURES",
|
||||
"planType": "profit_loss",
|
||||
}
|
||||
|
||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get plan orders: %v", err)
|
||||
}
|
||||
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"`
|
||||
} `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)
|
||||
}
|
||||
}
|
||||
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: order.Symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ BITGET GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
symbol := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Format quantity
|
||||
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
|
||||
|
||||
// Determine side
|
||||
side := "buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"marginMode": "crossed",
|
||||
"marginCoin": "USDT",
|
||||
"side": side,
|
||||
"orderType": "limit",
|
||||
"size": qtyStr,
|
||||
"price": fmt.Sprintf("%.8f", req.Price),
|
||||
"force": "GTC", // Good Till Cancel
|
||||
"clientOid": genBitgetClientOid(),
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = "YES"
|
||||
}
|
||||
|
||||
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
|
||||
|
||||
data, err := t.doRequest("POST", bitgetOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var order struct {
|
||||
OrderId string `json:"orderId"`
|
||||
ClientOid string `json:"clientOid"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &order); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
symbol, side, req.Price, order.OrderId)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: order.OrderId,
|
||||
ClientID: order.ClientOid,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"`
|
||||
Asks [][]string `json:"asks"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Bids {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Asks {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Bitget open orders
|
||||
return []OpenOrder{}, nil
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1106,159 +1105,3 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
// Format quantity
|
||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price
|
||||
priceStr := fmt.Sprintf("%.8f", req.Price)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side
|
||||
side := "Buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "Sell"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": req.Symbol,
|
||||
"side": side,
|
||||
"orderType": "Limit",
|
||||
"qty": qtyStr,
|
||||
"price": priceStr,
|
||||
"timeInForce": "GTC", // Good Till Cancel
|
||||
"positionIdx": 0, // One-way position mode
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Parse result
|
||||
orderID := ""
|
||||
if result.RetCode == 0 {
|
||||
if resultData, ok := result.Result.(map[string]interface{}); ok {
|
||||
if id, ok := resultData["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
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 by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 25
|
||||
}
|
||||
|
||||
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
|
||||
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
RetCode int `json:"retCode"`
|
||||
RetMsg string `json:"retMsg"`
|
||||
Result struct {
|
||||
S string `json:"s"` // symbol
|
||||
B [][]string `json:"b"` // bids [[price, size], ...]
|
||||
A [][]string `json:"a"` // asks [[price, size], ...]
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Result.B {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Result.A {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
@@ -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,7 +18,7 @@ import (
|
||||
// BybitTraderTestSuite Bybit trader test suite
|
||||
// Inherits TraderTestSuite and adds Bybit-specific mock logic
|
||||
type BybitTraderTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -141,7 +141,7 @@ func runStandardTests(t *testing.T, exchangeName string) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.Symbol, trade.Side, trade.Action,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -227,7 +227,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "open_long",
|
||||
0.1, 3500+float64(i*10), 0.5, 0,
|
||||
time.Now().Add(time.Duration(i*2)*time.Second).UnixMilli(),
|
||||
time.Now().Add(time.Duration(i*2)*time.Second),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -239,7 +239,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "close_long",
|
||||
0.1, 3600+float64(i*10), 0.5, 10,
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second).UnixMilli(),
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -309,7 +309,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "open_long",
|
||||
0.01, 50000, 1.0, 0,
|
||||
time.Now().UnixMilli(),
|
||||
time.Now(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -322,7 +322,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "close_long",
|
||||
0.00999999, 51000, 1.0, 10,
|
||||
time.Now().Add(time.Second).UnixMilli(),
|
||||
time.Now().Add(time.Second),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,282 +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, _ := strconv.ParseFloat(trade.Price, 64)
|
||||
|
||||
// Get quanto_multiplier for this contract to convert size to base currency
|
||||
quantoMultiplier := 1.0
|
||||
contract, err := t.getContract(trade.Contract)
|
||||
if err == nil && contract != nil {
|
||||
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
if qm > 0 {
|
||||
quantoMultiplier = qm
|
||||
}
|
||||
}
|
||||
|
||||
// Convert contract size to actual quantity
|
||||
absSize := trade.Size
|
||||
if absSize < 0 {
|
||||
absSize = -absSize
|
||||
}
|
||||
fillQty := float64(absSize) * quantoMultiplier
|
||||
|
||||
// Determine side and order action based on size and close_size
|
||||
// Gate close_size field determines if trade is opening or closing:
|
||||
// close_size=0 && size>0: Open long
|
||||
// close_size=0 && size<0: Open short
|
||||
// close_size>0 && size>0: Close short (and possibly open long if size > close_size)
|
||||
// close_size<0 && size<0: Close long (and possibly open short if |size| > |close_size|)
|
||||
side := "BUY"
|
||||
orderAction := "open_long"
|
||||
|
||||
if trade.Size > 0 {
|
||||
side = "BUY"
|
||||
if trade.CloseSize > 0 {
|
||||
// Closing short position
|
||||
orderAction = "close_short"
|
||||
} else {
|
||||
// Opening long position
|
||||
orderAction = "open_long"
|
||||
}
|
||||
} else if trade.Size < 0 {
|
||||
side = "SELL"
|
||||
if trade.CloseSize < 0 {
|
||||
// Closing long position
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
// Opening short position
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate fee (Gate returns fee as negative value)
|
||||
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
||||
if fee < 0 {
|
||||
fee = -fee
|
||||
}
|
||||
|
||||
// For closed positions, estimate PnL (Gate doesn't directly provide it in trade record)
|
||||
pnl := 0.0
|
||||
if strings.Contains(orderAction, "close") {
|
||||
// PnL would need to be calculated from position history
|
||||
// For now, we leave it as 0 and let position builder handle it
|
||||
}
|
||||
|
||||
gateTrade := GateTrade{
|
||||
Symbol: trade.Contract,
|
||||
TradeID: fmt.Sprintf("%d", trade.Id),
|
||||
OrderID: trade.OrderId,
|
||||
Side: side,
|
||||
FillPrice: fillPrice,
|
||||
FillQty: fillQty,
|
||||
Fee: fee,
|
||||
FeeAsset: "USDT",
|
||||
ExecTime: time.Unix(createTime, 0).UTC(),
|
||||
ProfitLoss: pnl,
|
||||
OrderType: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
}
|
||||
|
||||
result = append(result, gateTrade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SyncOrdersFromGate syncs Gate exchange order history to local database
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
// exchangeID: Exchange account UUID (from exchanges.id)
|
||||
// exchangeType: Exchange type ("gate")
|
||||
func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Gate trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records
|
||||
trades, err := t.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Gate", len(trades))
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
for _, trade := range trades {
|
||||
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT)
|
||||
symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", ""))
|
||||
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(trade.OrderAction, "short") {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Gate uses one-way position mode
|
||||
Type: trade.OrderType,
|
||||
OrderAction: trade.OrderAction,
|
||||
Quantity: trade.FillQty,
|
||||
Price: trade.FillPrice,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: trade.FillQty,
|
||||
AvgFillPrice: trade.FillPrice,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record - use UTC time in milliseconds
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.OrderID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: trade.FillPrice,
|
||||
Quantity: trade.FillQty,
|
||||
QuoteQuantity: trade.FillPrice * trade.FillQty,
|
||||
Commission: trade.Fee,
|
||||
CommissionAsset: trade.FeeAsset,
|
||||
RealizedPnL: trade.ProfitLoss,
|
||||
IsMaker: false,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
|
||||
}
|
||||
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||
execTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty)
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Gate order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task for Gate
|
||||
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ Gate order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 Gate order sync started (interval: %v)", interval)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Task 6: Regime Level Classification
|
||||
// ============================================================================
|
||||
|
||||
// classifyRegimeLevel determines the regime level based on market indicators
|
||||
// bollingerWidth: Bollinger band width as percentage
|
||||
// atr14Pct: ATR14 as percentage of current price
|
||||
func classifyRegimeLevel(bollingerWidth, atr14Pct float64) market.RegimeLevel {
|
||||
// Narrow: Bollinger < 2%, ATR < 1%
|
||||
if bollingerWidth < 2.0 && atr14Pct < 1.0 {
|
||||
return market.RegimeLevelNarrow
|
||||
}
|
||||
|
||||
// Standard: Bollinger 2-3%, ATR 1-2%
|
||||
if bollingerWidth <= 3.0 && atr14Pct <= 2.0 {
|
||||
return market.RegimeLevelStandard
|
||||
}
|
||||
|
||||
// Wide: Bollinger 3-4%, ATR 2-3%
|
||||
if bollingerWidth <= 4.0 && atr14Pct <= 3.0 {
|
||||
return market.RegimeLevelWide
|
||||
}
|
||||
|
||||
// Volatile: Bollinger > 4%, ATR > 3%
|
||||
return market.RegimeLevelVolatile
|
||||
}
|
||||
|
||||
// getRegimeLeverageLimit returns the effective leverage limit for a regime level
|
||||
func getRegimeLeverageLimit(level market.RegimeLevel, config *store.GridConfigModel) int {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimeLeverage > 0 {
|
||||
return config.NarrowRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimeLeverage > 0 {
|
||||
return config.StandardRegimeLeverage
|
||||
}
|
||||
return 4
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimeLeverage > 0 {
|
||||
return config.WideRegimeLeverage
|
||||
}
|
||||
return 3
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimeLeverage > 0 {
|
||||
return config.VolatileRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
default:
|
||||
return 2 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// getRegimePositionLimit returns the position limit percentage for a regime level
|
||||
func getRegimePositionLimit(level market.RegimeLevel, config *store.GridConfigModel) float64 {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimePositionPct > 0 {
|
||||
return config.NarrowRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimePositionPct > 0 {
|
||||
return config.StandardRegimePositionPct
|
||||
}
|
||||
return 70.0
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimePositionPct > 0 {
|
||||
return config.WideRegimePositionPct
|
||||
}
|
||||
return 60.0
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimePositionPct > 0 {
|
||||
return config.VolatileRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
default:
|
||||
return 40.0 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 7: Breakout Detection
|
||||
// ============================================================================
|
||||
|
||||
// detectBoxBreakout checks if price has broken out of any box level
|
||||
// Returns the highest breakout level and direction
|
||||
func detectBoxBreakout(box *market.BoxData) (market.BreakoutLevel, string) {
|
||||
if box == nil {
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
|
||||
// Check long box first (highest priority)
|
||||
if price > box.LongUpper {
|
||||
return market.BreakoutLong, "up"
|
||||
}
|
||||
if price < box.LongLower {
|
||||
return market.BreakoutLong, "down"
|
||||
}
|
||||
|
||||
// Check mid box
|
||||
if price > box.MidUpper {
|
||||
return market.BreakoutMid, "up"
|
||||
}
|
||||
if price < box.MidLower {
|
||||
return market.BreakoutMid, "down"
|
||||
}
|
||||
|
||||
// Check short box
|
||||
if price > box.ShortUpper {
|
||||
return market.BreakoutShort, "up"
|
||||
}
|
||||
if price < box.ShortLower {
|
||||
return market.BreakoutShort, "down"
|
||||
}
|
||||
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 8: Breakout Confirmation Logic
|
||||
// ============================================================================
|
||||
|
||||
const BreakoutConfirmRequired = 3 // 3 candles to confirm breakout
|
||||
|
||||
// BreakoutState tracks the current breakout state
|
||||
type BreakoutState struct {
|
||||
Level market.BreakoutLevel
|
||||
Direction string
|
||||
ConfirmCount int
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// confirmBreakout updates breakout state and returns true if breakout is confirmed
|
||||
func confirmBreakout(state *BreakoutState, currentLevel market.BreakoutLevel, direction string) bool {
|
||||
// If price returned to box, reset state
|
||||
if currentLevel == market.BreakoutNone {
|
||||
state.ConfirmCount = 0
|
||||
state.Level = market.BreakoutNone
|
||||
state.Direction = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// If same breakout continues, increment count
|
||||
if state.Level == currentLevel && state.Direction == direction {
|
||||
state.ConfirmCount++
|
||||
} else {
|
||||
// New breakout, reset count
|
||||
state.Level = currentLevel
|
||||
state.Direction = direction
|
||||
state.ConfirmCount = 1
|
||||
state.StartTime = time.Now()
|
||||
}
|
||||
|
||||
return state.ConfirmCount >= BreakoutConfirmRequired
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 9: Breakout Handler
|
||||
// ============================================================================
|
||||
|
||||
// BreakoutAction represents the action to take on breakout
|
||||
type BreakoutAction int
|
||||
|
||||
const (
|
||||
BreakoutActionNone BreakoutAction = iota
|
||||
BreakoutActionReducePosition // Short box breakout: reduce to 50%
|
||||
BreakoutActionPauseGrid // Mid box breakout: pause grid + cancel orders
|
||||
BreakoutActionCloseAll // Long box breakout: pause + cancel + close all
|
||||
)
|
||||
|
||||
// getBreakoutAction returns the appropriate action for a breakout level
|
||||
func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
|
||||
switch level {
|
||||
case market.BreakoutShort:
|
||||
return BreakoutActionReducePosition
|
||||
case market.BreakoutMid:
|
||||
return BreakoutActionPauseGrid
|
||||
case market.BreakoutLong:
|
||||
return BreakoutActionCloseAll
|
||||
default:
|
||||
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
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyRegimeLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bollingerWidth float64
|
||||
atr14Pct float64
|
||||
expected market.RegimeLevel
|
||||
}{
|
||||
{"narrow", 1.5, 0.8, market.RegimeLevelNarrow},
|
||||
{"standard", 2.5, 1.5, market.RegimeLevelStandard},
|
||||
{"wide", 3.5, 2.5, market.RegimeLevelWide},
|
||||
{"volatile", 5.0, 4.0, market.RegimeLevelVolatile},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := classifyRegimeLevel(tt.bollingerWidth, tt.atr14Pct)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBoxBreakout(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
// No breakout
|
||||
level, direction := detectBoxBreakout(box)
|
||||
if level != market.BreakoutNone {
|
||||
t.Errorf("Expected no breakout, got %v", level)
|
||||
}
|
||||
|
||||
// Short breakout up
|
||||
box.CurrentPrice = 101
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutShort || direction != "up" {
|
||||
t.Errorf("Expected short breakout up, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Mid breakout down
|
||||
box.CurrentPrice = 84
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutMid || direction != "down" {
|
||||
t.Errorf("Expected mid breakout down, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Long breakout up
|
||||
box.CurrentPrice = 112
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutLong || direction != "up" {
|
||||
t.Errorf("Expected long breakout up, got %v %v", level, direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakoutConfirmation(t *testing.T) {
|
||||
state := &BreakoutState{
|
||||
Level: market.BreakoutNone,
|
||||
Direction: "",
|
||||
ConfirmCount: 0,
|
||||
}
|
||||
|
||||
// First detection
|
||||
confirmed := confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 1 {
|
||||
t.Errorf("Expected not confirmed, count=1, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Second confirmation
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 2 {
|
||||
t.Errorf("Expected not confirmed, count=2, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Third confirmation - should confirm
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if !confirmed || state.ConfirmCount != 3 {
|
||||
t.Errorf("Expected confirmed, count=3, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Reset on price return
|
||||
state.ConfirmCount = 2
|
||||
confirmed = confirmBreakout(state, market.BreakoutNone, "")
|
||||
if state.ConfirmCount != 0 {
|
||||
t.Errorf("Expected count reset to 0, got %d", state.ConfirmCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBreakoutAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
level market.BreakoutLevel
|
||||
expected BreakoutAction
|
||||
}{
|
||||
{market.BreakoutNone, BreakoutActionNone},
|
||||
{market.BreakoutShort, BreakoutActionReducePosition},
|
||||
{market.BreakoutMid, BreakoutActionPauseGrid},
|
||||
{market.BreakoutLong, BreakoutActionCloseAll},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.level), func(t *testing.T) {
|
||||
action := getBreakoutAction(tt.level)
|
||||
if action != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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"
|
||||
@@ -103,7 +103,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-1",
|
||||
time.Now(), "order-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open long: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
||||
time.Now().UnixMilli(), "order-2",
|
||||
time.Now(), "order-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close long: %v", err)
|
||||
@@ -152,7 +152,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "open_short",
|
||||
0.05, 3500, 0.25, 0,
|
||||
time.Now().UnixMilli(), "order-3",
|
||||
time.Now(), "order-3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open short: %v", err)
|
||||
@@ -176,7 +176,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "close_short",
|
||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
||||
time.Now().UnixMilli(), "order-4",
|
||||
time.Now(), "order-4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close short: %v", err)
|
||||
@@ -205,7 +205,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-5",
|
||||
time.Now(), "order-5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process first open: %v", err)
|
||||
@@ -216,7 +216,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3600, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-6",
|
||||
time.Now(), "order-6",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process add position: %v", err)
|
||||
@@ -243,7 +243,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.2, 3700, 1.0, 30.0,
|
||||
time.Now().UnixMilli(), "order-7",
|
||||
time.Now(), "order-7",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close: %v", err)
|
||||
@@ -269,7 +269,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
1.0, 3500, 2.0, 0,
|
||||
time.Now().UnixMilli(), "order-8",
|
||||
time.Now(), "order-8",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open: %v", err)
|
||||
@@ -280,7 +280,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.3, 3600, 0.6, 30.0,
|
||||
time.Now().UnixMilli(), "order-9",
|
||||
time.Now(), "order-9",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process partial close: %v", err)
|
||||
@@ -351,7 +351,7 @@ func TestHyperliquidBugScenario(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.symbol, trade.side, trade.action,
|
||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1,4 +1,4 @@
|
||||
package hyperliquid
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// HyperliquidTrader Hyperliquid trader
|
||||
@@ -250,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
|
||||
|
||||
@@ -1951,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
|
||||
@@ -1982,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,
|
||||
@@ -2002,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)
|
||||
@@ -2010,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)
|
||||
@@ -2055,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,
|
||||
@@ -2083,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
|
||||
@@ -2100,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,
|
||||
@@ -2115,118 +2114,3 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||
|
||||
// Set leverage if specified and not xyz dex
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
if req.Leverage > 0 && !isXyz {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Round quantity to allowed decimals
|
||||
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
|
||||
|
||||
// Round price to 5 significant figures
|
||||
roundedPrice := t.roundPriceToSigfigs(req.Price)
|
||||
|
||||
// Determine if buy or sell
|
||||
isBuy := req.Side == "BUY"
|
||||
|
||||
logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity)
|
||||
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity,
|
||||
Price: roundedPrice,
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Limit: &hyperliquid.LimitOrderType{
|
||||
Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders
|
||||
},
|
||||
},
|
||||
ReduceOnly: req.ReduceOnly,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Note: Hyperliquid's Order response doesn't return the order ID directly
|
||||
// We would need to query open orders to get it, but for grid trading
|
||||
// we can track orders by price level instead
|
||||
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||
coin, req.Side, roundedPrice)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: roundedPrice,
|
||||
Quantity: roundedQuantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// Parse order ID
|
||||
oid, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.exchange.Cancel(t.ctx, coin, oid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
if l2Book == nil || len(l2Book.Levels) < 2 {
|
||||
return nil, nil, fmt.Errorf("invalid order book data")
|
||||
}
|
||||
|
||||
// Parse bids (first level array)
|
||||
for i, level := range l2Book.Levels[0] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
bids = append(bids, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
// Parse asks (second level array)
|
||||
for i, level := range l2Book.Levels[1] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
asks = append(asks, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
@@ -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,7 +20,7 @@ import (
|
||||
// HyperliquidTestSuite Hyperliquid trader test suite
|
||||
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
|
||||
type HyperliquidTestSuite struct {
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
@@ -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
|
||||
@@ -283,7 +281,7 @@ func TestNewHyperliquidTrader(t *testing.T) {
|
||||
walletAddr: "0x1234567890123456789012345678901234567890",
|
||||
testnet: true,
|
||||
wantError: true,
|
||||
errorContains: "failed to parse private key",
|
||||
errorContains: "Failed to parse private key",
|
||||
},
|
||||
{
|
||||
name: "Empty wallet address",
|
||||
@@ -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,88 +1,114 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
import "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
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewGridTraderAdapter creates an adapter for basic Trader
|
||||
func NewGridTraderAdapter(t Trader) *GridTraderAdapter {
|
||||
return &GridTraderAdapter{Trader: t}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,421 +0,0 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetActiveOrders_ParseResponse tests parsing of Lighter API response
|
||||
func TestGetActiveOrders_ParseResponse(t *testing.T) {
|
||||
// Mock response from Lighter API
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
},
|
||||
{
|
||||
"order_id": "123457",
|
||||
"order_index": 123457,
|
||||
"market_index": 0,
|
||||
"side": "bid",
|
||||
"type": "limit",
|
||||
"is_ask": false,
|
||||
"price": "3100.00",
|
||||
"initial_base_amount": "2.0",
|
||||
"remaining_base_amount": "2.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745601000,
|
||||
"created_at": 1736745601000
|
||||
},
|
||||
{
|
||||
"order_id": "123458",
|
||||
"order_index": 123458,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "stop_loss",
|
||||
"is_ask": true,
|
||||
"price": "0",
|
||||
"initial_base_amount": "1.0",
|
||||
"remaining_base_amount": "1.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "3000.00",
|
||||
"reduce_only": true,
|
||||
"timestamp": 1736745602000,
|
||||
"created_at": 1736745602000
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Parse the response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err, "Should parse response without error")
|
||||
|
||||
// Verify parsed data
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 3, len(apiResp.Orders))
|
||||
|
||||
// Test first order (sell limit)
|
||||
order1 := apiResp.Orders[0]
|
||||
assert.Equal(t, "123456", order1.OrderID)
|
||||
assert.True(t, order1.IsAsk, "First order should be ask (sell)")
|
||||
assert.Equal(t, "3150.50", order1.Price)
|
||||
assert.Equal(t, "1.5", order1.RemainingBaseAmount)
|
||||
assert.False(t, order1.ReduceOnly)
|
||||
|
||||
// Test second order (buy limit)
|
||||
order2 := apiResp.Orders[1]
|
||||
assert.Equal(t, "123457", order2.OrderID)
|
||||
assert.False(t, order2.IsAsk, "Second order should be bid (buy)")
|
||||
assert.Equal(t, "3100.00", order2.Price)
|
||||
|
||||
// Test third order (stop-loss)
|
||||
order3 := apiResp.Orders[2]
|
||||
assert.Equal(t, "123458", order3.OrderID)
|
||||
assert.Equal(t, "stop_loss", order3.Type)
|
||||
assert.Equal(t, "3000.00", order3.TriggerPrice)
|
||||
assert.True(t, order3.ReduceOnly)
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_EmptyResponse tests handling of empty orders
|
||||
func TestGetActiveOrders_EmptyResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 0, len(apiResp.Orders))
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_ErrorResponse tests handling of API error
|
||||
func TestGetActiveOrders_ErrorResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature"
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 29500, apiResp.Code)
|
||||
assert.Contains(t, apiResp.Message, "invalid signature")
|
||||
}
|
||||
|
||||
// TestConvertOrderResponseToOpenOrder tests conversion logic
|
||||
func TestConvertOrderResponseToOpenOrder(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
order OrderResponse
|
||||
expectedSide string
|
||||
expectedType string
|
||||
expectedPosSide string
|
||||
}{
|
||||
{
|
||||
name: "Sell limit order (opening short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "1",
|
||||
IsAsk: true,
|
||||
Type: "limit",
|
||||
Price: "3150.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Buy limit order (opening long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "2",
|
||||
IsAsk: false,
|
||||
Type: "limit",
|
||||
Price: "3100.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Sell stop-loss (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "3",
|
||||
IsAsk: true,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3000.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Buy stop-loss (closing short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "4",
|
||||
IsAsk: false,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3200.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Take profit (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "5",
|
||||
IsAsk: true,
|
||||
Type: "take_profit",
|
||||
TriggerPrice: "3500.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "TAKE_PROFIT_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Convert side
|
||||
side := "BUY"
|
||||
if tc.order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
assert.Equal(t, tc.expectedSide, side)
|
||||
|
||||
// Convert order type
|
||||
orderType := "LIMIT"
|
||||
if tc.order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if tc.order.Type == "stop_loss" || tc.order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if tc.order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
assert.Equal(t, tc.expectedType, orderType)
|
||||
|
||||
// Convert position side
|
||||
positionSide := "LONG"
|
||||
if tc.order.ReduceOnly {
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT"
|
||||
} else {
|
||||
positionSide = "LONG"
|
||||
}
|
||||
} else {
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tc.expectedPosSide, positionSide)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_MockServer tests the full HTTP flow with a mock server
|
||||
func TestGetActiveOrders_MockServer(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request path and auth parameter
|
||||
assert.Contains(t, r.URL.Path, "/api/v1/accountActiveOrders")
|
||||
|
||||
// Check that auth query parameter is present
|
||||
authParam := r.URL.Query().Get("auth")
|
||||
if authParam == "" {
|
||||
// Return error if no auth parameter
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []map[string]interface{}{
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test request without auth - should fail
|
||||
resp, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var errorResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errorResp)
|
||||
assert.Equal(t, 29500, errorResp.Code)
|
||||
|
||||
// Test request with auth - should succeed
|
||||
resp2, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0&auth=test_token")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
var successResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.NewDecoder(resp2.Body).Decode(&successResp)
|
||||
assert.Equal(t, 200, successResp.Code)
|
||||
assert.Equal(t, 1, len(successResp.Orders))
|
||||
}
|
||||
|
||||
// TestAuthTokenFormat tests the auth token format
|
||||
func TestAuthTokenFormat(t *testing.T) {
|
||||
// Auth token format: timestamp:account_index:api_key_index:signature
|
||||
// Example: 1768308847:687247:0:742e02...
|
||||
|
||||
sampleToken := "1768308847:687247:0:742e02abc123"
|
||||
|
||||
// The token should be URL encoded when used as query parameter
|
||||
// Colons become %3A
|
||||
expectedEncoded := "1768308847%3A687247%3A0%3A742e02abc123"
|
||||
|
||||
// URL encode the token
|
||||
encoded := url.QueryEscape(sampleToken)
|
||||
|
||||
assert.Equal(t, expectedEncoded, encoded)
|
||||
}
|
||||
|
||||
// TestOrderResponseStruct tests that OrderResponse struct matches API response
|
||||
func TestOrderResponseStruct(t *testing.T) {
|
||||
// Real API response sample (from logs)
|
||||
realResponse := `{
|
||||
"order_id": "4609885",
|
||||
"order_index": 4609885,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.00",
|
||||
"initial_base_amount": "0.0300",
|
||||
"remaining_base_amount": "0.0300",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
}`
|
||||
|
||||
var order OrderResponse
|
||||
err := json.Unmarshal([]byte(realResponse), &order)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "4609885", order.OrderID)
|
||||
assert.Equal(t, int64(4609885), order.OrderIndex)
|
||||
assert.Equal(t, 0, order.MarketIndex)
|
||||
assert.Equal(t, "ask", order.Side)
|
||||
assert.Equal(t, "limit", order.Type)
|
||||
assert.True(t, order.IsAsk)
|
||||
assert.Equal(t, "3150.00", order.Price)
|
||||
assert.Equal(t, "0.0300", order.InitialBaseAmount)
|
||||
assert.Equal(t, "0.0300", order.RemainingBaseAmount)
|
||||
assert.Equal(t, "0", order.FilledBaseAmount)
|
||||
assert.Equal(t, "open", order.Status)
|
||||
assert.Equal(t, "", order.TriggerPrice)
|
||||
assert.False(t, order.ReduceOnly)
|
||||
assert.Equal(t, int64(1736745600000), order.Timestamp)
|
||||
assert.Equal(t, int64(1736745600000), order.CreatedAt)
|
||||
}
|
||||
|
||||
// BenchmarkParseOrderResponse benchmarks response parsing
|
||||
func BenchmarkParseOrderResponse(b *testing.B) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{"order_id": "1", "is_ask": true, "price": "3150.50", "remaining_base_amount": "1.5"},
|
||||
{"order_id": "2", "is_ask": false, "price": "3100.00", "remaining_base_amount": "2.0"},
|
||||
{"order_id": "3", "is_ask": true, "price": "3200.00", "remaining_base_amount": "0.5"}
|
||||
]
|
||||
}`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
}
|
||||
}
|
||||
557
trader/lighter_integration_test.go
Normal file
557
trader/lighter_integration_test.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test configuration - uses real account
|
||||
// Run with: LIGHTER_TEST=1 go test -v ./trader -run TestLighter -timeout 120s
|
||||
const (
|
||||
testWalletAddr = ""
|
||||
testAPIKeyPrivateKey = ""
|
||||
testAPIKeyIndex = 0
|
||||
testAccountIndex = int64(681514)
|
||||
)
|
||||
|
||||
func skipIfNoEnv(t *testing.T) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
t.Skip("Skipping Lighter integration test. Set LIGHTER_TEST=1 to run")
|
||||
}
|
||||
}
|
||||
|
||||
// skipIfJurisdictionRestricted checks if error is due to geographic restriction
|
||||
// and skips the test if so (this is expected when running from restricted regions)
|
||||
func skipIfJurisdictionRestricted(t *testing.T, err error) {
|
||||
if err != nil && strings.Contains(err.Error(), "restricted jurisdiction") {
|
||||
t.Skip("Skipping: API blocked due to geographic restriction (IP-based). Use VPN to allowed region.")
|
||||
}
|
||||
}
|
||||
|
||||
func createTestTrader(t *testing.T) *LighterTraderV2 {
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
return trader
|
||||
}
|
||||
|
||||
// ==================== Account Tests ====================
|
||||
|
||||
func TestLighterAccountInit(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Verify account index
|
||||
if trader.accountIndex != testAccountIndex {
|
||||
t.Errorf("Expected account index %d, got %d", testAccountIndex, trader.accountIndex)
|
||||
}
|
||||
|
||||
t.Logf("✅ Account initialized: index=%d", trader.accountIndex)
|
||||
}
|
||||
|
||||
func TestLighterAPIKeyVerification(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Verify API key
|
||||
err := trader.checkClient()
|
||||
if err != nil {
|
||||
t.Errorf("API key verification failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ API key verified successfully")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterGetBalance(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Balance retrieved:")
|
||||
if te, ok := balance["total_equity"].(float64); ok {
|
||||
t.Logf(" Total Equity: %.2f", te)
|
||||
}
|
||||
if ab, ok := balance["available_balance"].(float64); ok {
|
||||
t.Logf(" Available Balance: %.2f", ab)
|
||||
}
|
||||
if mu, ok := balance["margin_used"].(float64); ok {
|
||||
t.Logf(" Margin Used: %.2f", mu)
|
||||
}
|
||||
if up, ok := balance["unrealized_pnl"].(float64); ok {
|
||||
t.Logf(" Unrealized PnL: %.2f", up)
|
||||
}
|
||||
|
||||
if len(balance) == 0 {
|
||||
t.Error("Expected balance data")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Position Tests ====================
|
||||
|
||||
func TestLighterGetPositions(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPositions failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Positions retrieved: %d positions", len(positions))
|
||||
for i, pos := range positions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
side, _ := pos["side"].(string)
|
||||
size, _ := pos["size"].(float64)
|
||||
entryPrice, _ := pos["entry_price"].(float64)
|
||||
unrealizedPnl, _ := pos["unrealized_pnl"].(float64)
|
||||
|
||||
t.Logf(" [%d] %s %s: size=%.4f, entry=%.2f, pnl=%.2f",
|
||||
i+1, symbol, side, size, entryPrice, unrealizedPnl)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Market Data Tests ====================
|
||||
|
||||
func TestLighterGetMarketPrice(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
symbols := []string{"ETH", "BTC", "SOL"}
|
||||
|
||||
for _, symbol := range symbols {
|
||||
price, err := trader.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
t.Errorf("GetMarketPrice(%s) failed: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
t.Logf("✅ %s price: %.2f", symbol, price)
|
||||
|
||||
if price <= 0 {
|
||||
t.Errorf("Expected positive price for %s, got %.2f", symbol, price)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterFetchMarketList(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
markets, err := trader.fetchMarketList()
|
||||
if err != nil {
|
||||
t.Fatalf("fetchMarketList failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Markets retrieved: %d markets", len(markets))
|
||||
for i, m := range markets {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... and %d more", len(markets)-10)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s (market_id=%d, size_decimals=%d, price_decimals=%d)",
|
||||
m.MarketID, m.Symbol, m.MarketID, m.SizeDecimals, m.PriceDecimals)
|
||||
}
|
||||
|
||||
if len(markets) == 0 {
|
||||
t.Error("Expected at least one market")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Trades API Tests ====================
|
||||
|
||||
func TestLighterGetTrades(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Get trades from last 7 days
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
trades, err := trader.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrades failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Trades retrieved: %d trades", len(trades))
|
||||
for i, trade := range trades {
|
||||
if i >= 5 {
|
||||
t.Logf(" ... and %d more", len(trades)-5)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s %s: qty=%.4f @ %.2f, fee=%.6f, time=%s",
|
||||
i+1, trade.Symbol, trade.Side, trade.Quantity, trade.Price, trade.Fee,
|
||||
trade.Time.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterGetClosedPnL(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
records, err := trader.GetClosedPnL(startTime, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClosedPnL failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Closed PnL records: %d records", len(records))
|
||||
for i, r := range records {
|
||||
if i >= 5 {
|
||||
t.Logf(" ... and %d more", len(records)-5)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s %s: qty=%.4f, entry=%.2f, exit=%.2f, pnl=%.2f",
|
||||
i+1, r.Symbol, r.Side, r.Quantity, r.EntryPrice, r.ExitPrice, r.RealizedPnL)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Order Tests ====================
|
||||
|
||||
func TestLighterCreateAndCancelLimitOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Get current market price
|
||||
marketPrice, err := trader.GetMarketPrice("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get market price: %v", err)
|
||||
}
|
||||
t.Logf("Current ETH price: %.2f", marketPrice)
|
||||
|
||||
// Create a limit order far from market (won't fill)
|
||||
// Buy order at 80% of market price
|
||||
limitPrice := marketPrice * 0.80
|
||||
quantity := 0.01 // Minimum quantity
|
||||
|
||||
t.Logf("Creating limit buy order: %.4f ETH @ %.2f", quantity, limitPrice)
|
||||
|
||||
result, err := trader.CreateOrder("ETH", false, quantity, limitPrice, "limit", false)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrder failed: %v", err)
|
||||
}
|
||||
|
||||
orderID, _ := result["order_id"].(string)
|
||||
t.Logf("✅ Order created: %s", orderID)
|
||||
|
||||
if orderID == "" {
|
||||
t.Fatal("Expected order ID in response")
|
||||
}
|
||||
|
||||
// Wait a moment for order to be processed
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Cancel the order
|
||||
t.Logf("Cancelling order: %s", orderID)
|
||||
err = trader.CancelOrder("ETH", orderID)
|
||||
if err != nil {
|
||||
t.Errorf("CancelOrder failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Order cancelled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterCancelAllOrders(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// First create a few test orders
|
||||
marketPrice, err := trader.GetMarketPrice("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get market price: %v", err)
|
||||
}
|
||||
|
||||
// Create 2 limit orders
|
||||
for i := 0; i < 2; i++ {
|
||||
limitPrice := marketPrice * (0.75 - float64(i)*0.05) // 75%, 70% of market
|
||||
_, err := trader.CreateOrder("ETH", false, 0.01, limitPrice, "limit", false)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Logf("Failed to create test order %d: %v", i+1, err)
|
||||
} else {
|
||||
t.Logf("Created test order %d @ %.2f", i+1, limitPrice)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Cancel all
|
||||
err = trader.CancelAllOrders("ETH")
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("CancelAllOrders failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ CancelAllOrders executed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Trading Flow Tests ====================
|
||||
|
||||
func TestLighterOpenCloseLongFlow(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
// This test actually trades - be careful!
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping actual trade test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
symbol := "ETH"
|
||||
quantity := 0.01 // Minimum quantity
|
||||
leverage := 10
|
||||
|
||||
// Get initial positions
|
||||
positionsBefore, _ := trader.GetPositions()
|
||||
t.Logf("Positions before: %d", len(positionsBefore))
|
||||
|
||||
// Open long
|
||||
t.Logf("Opening long: %s qty=%.4f leverage=%d", symbol, quantity, leverage)
|
||||
result, err := trader.OpenLong(symbol, quantity, leverage)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLong failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ OpenLong result: %v", result)
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Verify position
|
||||
positions, _ := trader.GetPositions()
|
||||
t.Logf("Positions after open: %d", len(positions))
|
||||
|
||||
// Close long
|
||||
t.Logf("Closing long: %s qty=%.4f", symbol, quantity)
|
||||
result, err = trader.CloseLong(symbol, quantity)
|
||||
if err != nil {
|
||||
t.Errorf("CloseLong failed: %v", err)
|
||||
} else {
|
||||
t.Logf("✅ CloseLong result: %v", result)
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Verify position closed
|
||||
positions, _ = trader.GetPositions()
|
||||
t.Logf("Positions after close: %d", len(positions))
|
||||
}
|
||||
|
||||
func TestLighterOpenCloseShortFlow(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping actual trade test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
symbol := "ETH"
|
||||
quantity := 0.01
|
||||
leverage := 10
|
||||
|
||||
// Open short
|
||||
t.Logf("Opening short: %s qty=%.4f leverage=%d", symbol, quantity, leverage)
|
||||
result, err := trader.OpenShort(symbol, quantity, leverage)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenShort failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ OpenShort result: %v", result)
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Close short
|
||||
t.Logf("Closing short: %s qty=%.4f", symbol, quantity)
|
||||
result, err = trader.CloseShort(symbol, quantity)
|
||||
if err != nil {
|
||||
t.Errorf("CloseShort failed: %v", err)
|
||||
} else {
|
||||
t.Logf("✅ CloseShort result: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Leverage Tests ====================
|
||||
|
||||
func TestLighterSetLeverage(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test setting leverage
|
||||
leverages := []int{5, 10, 20}
|
||||
|
||||
for _, lev := range leverages {
|
||||
err := trader.SetLeverage("ETH", lev)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetLeverage(%d) failed: %v", lev, err)
|
||||
} else {
|
||||
t.Logf("✅ SetLeverage(%d) succeeded", lev)
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Auth Token Tests ====================
|
||||
|
||||
func TestLighterAuthTokenRefresh(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Get initial token
|
||||
err := trader.ensureAuthToken()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureAuthToken failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Initial auth token obtained")
|
||||
|
||||
// Force refresh
|
||||
err = trader.refreshAuthToken()
|
||||
if err != nil {
|
||||
t.Errorf("refreshAuthToken failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Auth token refreshed successfully")
|
||||
}
|
||||
|
||||
// Verify token works by making API call
|
||||
_, err = trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Errorf("GetBalance after refresh failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Token verified working after refresh")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Error Handling Tests ====================
|
||||
|
||||
func TestLighterInvalidSymbol(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test with invalid symbol
|
||||
_, err := trader.GetMarketPrice("INVALID_SYMBOL_XYZ")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid symbol, got nil")
|
||||
} else {
|
||||
t.Logf("✅ Got expected error for invalid symbol: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterCancelNonExistentOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Try to cancel non-existent order
|
||||
err := trader.CancelOrder("ETH", "999999999999")
|
||||
if err == nil {
|
||||
t.Log("⚠️ No error for cancelling non-existent order (may be expected)")
|
||||
} else {
|
||||
t.Logf("✅ Got error for non-existent order: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OrderSync Tests ====================
|
||||
|
||||
func TestLighterOrderSync(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Get trades to simulate order sync
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
trades, err := trader.GetTrades(startTime, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrades failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ OrderSync simulation: retrieved %d trades", len(trades))
|
||||
|
||||
// Analyze trades
|
||||
openTrades := 0
|
||||
closeTrades := 0
|
||||
for _, trade := range trades {
|
||||
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
|
||||
openTrades++
|
||||
} else if trade.OrderAction == "close_long" || trade.OrderAction == "close_short" {
|
||||
closeTrades++
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf(" Open trades: %d, Close trades: %d", openTrades, closeTrades)
|
||||
}
|
||||
|
||||
// ==================== Benchmark Tests ====================
|
||||
|
||||
func BenchmarkLighterGetBalance(b *testing.B) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
defer trader.Cleanup()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
b.Fatalf("GetBalance failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLighterGetMarketPrice(b *testing.B) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
defer trader.Cleanup()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := trader.GetMarketPrice("ETH")
|
||||
if err != nil {
|
||||
b.Fatalf("GetMarketPrice failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -75,7 +74,6 @@ type LighterTraderV2 struct {
|
||||
apiKeyPrivateKey string // 40-byte API Key private key (for signing transactions)
|
||||
apiKeyIndex uint8 // API Key index (default 0)
|
||||
accountIndex int64 // Account index
|
||||
apiKeyValid bool // Whether API key has been validated against server
|
||||
|
||||
// Authentication token
|
||||
authToken string
|
||||
@@ -89,8 +87,6 @@ type LighterTraderV2 struct {
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketListCache []MarketInfo // Cached market list
|
||||
marketListCacheTime time.Time // Time when cache was populated
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
@@ -131,6 +127,9 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // Disable proxy for direct connection to Lighter API
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
@@ -163,18 +162,14 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
trader.apiKeyValid = false
|
||||
logger.Warnf("⚠️ API Key verification FAILED: %v", err)
|
||||
logger.Warnf("⚠️ ❌ The API key stored in NOFX does NOT match the API key registered on Lighter.")
|
||||
logger.Warnf("⚠️ ❌ ALL trading operations (open/close positions, cancel orders) WILL FAIL with 'invalid signature' error.")
|
||||
logger.Warnf("⚠️ 🔧 To fix: Update your Lighter API key in NOFX Exchange settings with the correct key from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue for read operations (balance, positions)
|
||||
} else {
|
||||
trader.apiKeyValid = true
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
logger.Warnf("⚠️ The API key may not be registered on-chain. Authenticated API calls (like GetTrades) will fail.")
|
||||
logger.Warnf("⚠️ To fix: Register this API key using change_api_key transaction from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized (account=%d, apiKey=%d, testnet=%v, apiKeyValid=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet, trader.apiKeyValid)
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (account=%d, apiKey=%d, testnet=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet)
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
@@ -217,7 +212,7 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
}
|
||||
|
||||
// Log raw response for debugging
|
||||
logger.Debugf("LIGHTER account API response: %s", string(body))
|
||||
logger.Infof("LIGHTER account API response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
@@ -243,10 +238,10 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Log account summary
|
||||
logger.Infof("Found %d account(s) (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
// Log all found accounts
|
||||
logger.Infof("Found %d accounts (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
for i, acc := range allAccounts {
|
||||
logger.Debugf(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
logger.Infof(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
}
|
||||
|
||||
account := &allAccounts[0]
|
||||
@@ -258,79 +253,26 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ApiKeyResponse API key query response
|
||||
type ApiKeyResponse struct {
|
||||
Code int `json:"code"`
|
||||
ApiKeys []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
ApiKeyIndex uint8 `json:"api_key_index"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"api_keys"`
|
||||
}
|
||||
|
||||
// getApiKeyFromServer Get API Key public key from Lighter server
|
||||
// Uses our own HTTP client instead of SDK's global client to avoid connection issues
|
||||
func (t *LighterTraderV2) getApiKeyFromServer() (string, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/apikeys?account_index=%d&api_key_index=%d",
|
||||
t.baseURL, t.accountIndex, t.apiKeyIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ApiKeyResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
return "", fmt.Errorf("API error (code %d)", result.Code)
|
||||
}
|
||||
|
||||
if len(result.ApiKeys) == 0 {
|
||||
return "", fmt.Errorf("no API keys found for account %d", t.accountIndex)
|
||||
}
|
||||
|
||||
return result.ApiKeys[0].PublicKey, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
func (t *LighterTraderV2) checkClient() error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get API Key public key registered on server (using our own HTTP client)
|
||||
serverPubKey, err := t.getApiKeyFromServer()
|
||||
// Get API Key public key registered on server
|
||||
publicKey, err := t.httpClient.GetApiKey(t.accountIndex, t.apiKeyIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API Key: %w", err)
|
||||
}
|
||||
|
||||
// Get local API Key public key from SDK
|
||||
// Get local API Key public key
|
||||
pubKeyBytes := t.txClient.GetKeyManager().PubKeyBytes()
|
||||
localPubKey := hexutil.Encode(pubKeyBytes[:])
|
||||
localPubKey = strings.TrimPrefix(localPubKey, "0x")
|
||||
localPubKey = strings.Replace(localPubKey, "0x", "", 1)
|
||||
|
||||
// Compare public keys
|
||||
if serverPubKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, serverPubKey)
|
||||
if publicKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, publicKey)
|
||||
}
|
||||
|
||||
logger.Infof("✓ API Key verification passed")
|
||||
@@ -399,14 +341,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 +370,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 +390,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,11 +433,15 @@ 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
|
||||
logger.Debugf("Lighter trades API response: %s", string(body))
|
||||
// Debug: log raw response (first 500 chars)
|
||||
logBody := string(body)
|
||||
if len(logBody) > 500 {
|
||||
logBody = logBody[:500] + "..."
|
||||
}
|
||||
logger.Infof("📋 Lighter trades API raw response: %s", logBody)
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
@@ -503,14 +449,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 +475,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 +562,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 +576,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 +618,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"
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
)
|
||||
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
// Supports both main accounts and sub-accounts
|
||||
func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
@@ -35,48 +34,21 @@ func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts" field
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
// Check for API error code
|
||||
if accountResp.Code != 0 && accountResp.Code != 200 {
|
||||
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
}
|
||||
|
||||
// Combine both accounts and sub_accounts - some users have sub-accounts
|
||||
var allAccounts []AccountInfo
|
||||
allAccounts = append(allAccounts, accountResp.Accounts...)
|
||||
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
||||
|
||||
if len(allAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Find the account that matches our stored accountIndex, or use the first one
|
||||
var account *AccountInfo
|
||||
for i := range allAccounts {
|
||||
acc := &allAccounts[i]
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if acc.AccountIndex == 0 && acc.Index != 0 {
|
||||
acc.AccountIndex = acc.Index
|
||||
}
|
||||
// Match by stored accountIndex if we have one
|
||||
if t.accountIndex != 0 && acc.AccountIndex == t.accountIndex {
|
||||
account = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, use the first account
|
||||
if account == nil {
|
||||
account = &allAccounts[0]
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
@@ -91,7 +63,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 +137,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,
|
||||
@@ -356,13 +328,12 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
|
||||
// GetOrderBook Get order book (implements GridTrader interface)
|
||||
// Returns bids and asks as [][]float64 where each element is [price, quantity]
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// GetOrderBook Get order book with best bid/ask prices
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64, err error) {
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get market ID: %w", err)
|
||||
return 0, 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Get order book from Lighter API
|
||||
@@ -370,22 +341,22 @@ func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return 0, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
return 0, 0, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
@@ -398,61 +369,35 @@ func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
return 0, 0, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return nil, nil, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
return 0, 0, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
}
|
||||
|
||||
// Helper to parse price/quantity from interface{}
|
||||
parseFloat := func(v interface{}) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert bids to [][]float64
|
||||
maxBids := len(apiResp.Data.Bids)
|
||||
if depth > 0 && depth < maxBids {
|
||||
maxBids = depth
|
||||
}
|
||||
bids = make([][]float64, 0, maxBids)
|
||||
for i := 0; i < maxBids; i++ {
|
||||
if len(apiResp.Data.Bids[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Bids[i][0])
|
||||
qty := parseFloat(apiResp.Data.Bids[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
// Get best bid (highest buy price)
|
||||
if len(apiResp.Data.Bids) > 0 && len(apiResp.Data.Bids[0]) >= 1 {
|
||||
if price, ok := apiResp.Data.Bids[0][0].(float64); ok {
|
||||
bestBid = price
|
||||
} else if priceStr, ok := apiResp.Data.Bids[0][0].(string); ok {
|
||||
bestBid, _ = strconv.ParseFloat(priceStr, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert asks to [][]float64
|
||||
maxAsks := len(apiResp.Data.Asks)
|
||||
if depth > 0 && depth < maxAsks {
|
||||
maxAsks = depth
|
||||
}
|
||||
asks = make([][]float64, 0, maxAsks)
|
||||
for i := 0; i < maxAsks; i++ {
|
||||
if len(apiResp.Data.Asks[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Asks[i][0])
|
||||
qty := parseFloat(apiResp.Data.Asks[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
// Get best ask (lowest sell price)
|
||||
if len(apiResp.Data.Asks) > 0 && len(apiResp.Data.Asks[0]) >= 1 {
|
||||
if price, ok := apiResp.Data.Asks[0][0].(float64); ok {
|
||||
bestAsk = price
|
||||
} else if priceStr, ok := apiResp.Data.Asks[0][0].(string); ok {
|
||||
bestAsk, _ = strconv.ParseFloat(priceStr, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
logger.Infof("✓ Lighter order book: %s best_bid=%.2f, best_ask=%.2f, depth=%d/%d",
|
||||
symbol, bids[0][0], asks[0][0], len(bids), len(asks))
|
||||
if bestBid <= 0 || bestAsk <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid order book prices: bid=%.2f, ask=%.2f", bestBid, bestAsk)
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
logger.Infof("✓ Lighter order book: %s bid=%.2f, ask=%.2f", symbol, bestBid, bestAsk)
|
||||
return bestBid, bestAsk, nil
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package lighter
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
|
||||
@@ -99,18 +100,15 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s?auth=%s", t.baseURL, orderID, encodedAuth)
|
||||
// Build request URL
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -150,7 +148,7 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
"orderId": order.OrderID,
|
||||
"status": unifiedStatus,
|
||||
"avgPrice": order.Price,
|
||||
"executedQty": order.FilledBaseAmount,
|
||||
"executedQty": order.FilledQty,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
}
|
||||
@@ -212,15 +210,9 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d&auth=%s",
|
||||
t.baseURL, t.accountIndex, marketIndex, encodedAuth)
|
||||
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders: endpoint=%s", endpoint[:min(len(endpoint), 120)]+"...")
|
||||
// Build request URL
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d",
|
||||
t.baseURL, t.accountIndex, marketIndex)
|
||||
|
||||
// Send GET request
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
@@ -228,6 +220,8 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -241,13 +235,11 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders raw response: %s", string(body))
|
||||
|
||||
// Parse response - Lighter API uses "orders" field, not "data"
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
Data []OrderResponse `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
@@ -258,15 +250,11 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to get active orders (code %d): %s", apiResp.Code, apiResp.Message)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
logger.Debugf(" Order[%d]: order_id=%s, order_index=%d, market=%d", i, order.OrderID, order.OrderIndex, order.MarketIndex)
|
||||
}
|
||||
return apiResp.Orders, nil
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Data))
|
||||
return apiResp.Data, nil
|
||||
}
|
||||
|
||||
// CancelOrder Cancel a single order
|
||||
// orderID can be either a numeric order_index or a tx_hash string
|
||||
func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
@@ -279,15 +267,10 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Try to parse orderID as numeric order_index first
|
||||
// Convert orderID to int64
|
||||
orderIndex, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
// orderID is a tx_hash, need to query order to get numeric order_index
|
||||
logger.Debugf("📋 LIGHTER CancelOrder: orderID is tx_hash, querying order...")
|
||||
orderIndex, err = t.getOrderIndexByTxHash(symbol, orderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get order index from tx_hash: %w", err)
|
||||
}
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
// Build cancel order request
|
||||
@@ -297,26 +280,22 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCancelOrderTransaction(txReq, &types.TransactOpts{
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign cancel order: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info from SDK (consistent with CreateOrder and other transactions)
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
// Serialize transaction
|
||||
txBytes, err := json.Marshal(tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
return fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
}
|
||||
|
||||
// Submit cancel order to LIGHTER API using unified submitOrder function
|
||||
_, err = t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
// Submit cancel order to LIGHTER API
|
||||
_, err = t.submitCancelOrder(txBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit cancel order: %w", err)
|
||||
}
|
||||
@@ -325,21 +304,65 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrderIndexByTxHash finds the numeric order_index by searching active orders for the tx_hash
|
||||
func (t *LighterTraderV2) getOrderIndexByTxHash(symbol, txHash string) (int64, error) {
|
||||
// Get all active orders for this symbol
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
// submitCancelOrder Submit signed cancel order to LIGHTER API using multipart/form-data
|
||||
func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interface{}, error) {
|
||||
const TX_TYPE_CANCEL_ORDER = 15
|
||||
|
||||
// Build multipart form data (Lighter API requires form-data, not JSON)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Add tx_type field
|
||||
if err := writer.WriteField("tx_type", strconv.Itoa(TX_TYPE_CANCEL_ORDER)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_type: %w", err)
|
||||
}
|
||||
|
||||
// Add tx_info field
|
||||
if err := writer.WriteField("tx_info", string(signedTx)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Send POST request to /api/v1/sendTx
|
||||
endpoint := fmt.Sprintf("%s/api/v1/sendTx", t.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", endpoint, &body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Search for the order with matching tx_hash (order_id)
|
||||
for _, order := range orders {
|
||||
if order.OrderID == txHash {
|
||||
logger.Debugf("📋 LIGHTER Found order_index %d for tx_hash %s", order.OrderIndex, txHash)
|
||||
return order.OrderIndex, nil
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("order not found with tx_hash: %s (may already be filled or cancelled)", txHash)
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to submit cancel order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": sendResp.Data["tx_hash"],
|
||||
"status": "cancelled",
|
||||
}
|
||||
|
||||
logger.Infof("✓ Cancel order submitted to LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"])
|
||||
return result, nil
|
||||
}
|
||||
@@ -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)
|
||||
@@ -274,12 +273,8 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Sign transaction using SDK (nonce will be auto-fetched)
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCreateOrderTransaction(txReq, &types.TransactOpts{
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -293,7 +288,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Debug: Log the tx_info content
|
||||
logger.Debugf("tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
logger.Infof("DEBUG tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
|
||||
// Submit order to LIGHTER API
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
@@ -307,16 +302,6 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
logger.Infof("✓ LIGHTER order created: %s %s qty=%.4f", symbol, side, quantity)
|
||||
|
||||
// For limit orders, poll for the actual order_index after submission
|
||||
// This is needed because CancelOrder requires the numeric order_index, not tx_hash
|
||||
if orderType == "limit" {
|
||||
txHash, _ := orderResp["tx_hash"].(string)
|
||||
if orderIndex, err := t.pollForOrderIndex(symbol, txHash); err == nil && orderIndex > 0 {
|
||||
orderResp["orderId"] = fmt.Sprintf("%d", orderIndex)
|
||||
orderResp["order_index"] = orderIndex
|
||||
}
|
||||
}
|
||||
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
@@ -401,19 +386,10 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
}
|
||||
|
||||
// Log full response for debugging
|
||||
logger.Debugf("API response: %s", string(respBody))
|
||||
logger.Infof("DEBUG API response: %s", string(respBody))
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
// Provide more specific error message for signature errors
|
||||
// Code 21120: invalid signature (order submission)
|
||||
// Code 29500: internal server error: invalid signature (authenticated GET APIs)
|
||||
if (sendResp.Code == 21120 || sendResp.Code == 29500) && strings.Contains(sendResp.Message, "invalid signature") {
|
||||
if !t.apiKeyValid {
|
||||
return nil, fmt.Errorf("API Key MISMATCH (code %d): The API key stored in NOFX does not match the one registered on Lighter. Please update your Lighter API key in Exchange settings at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("API Key signature invalid (code %d): Please verify your Lighter API Key in Exchange settings matches the key registered at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to submit order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
@@ -427,45 +403,17 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": txHash,
|
||||
"status": "submitted",
|
||||
"orderId": txHash, // Use tx_hash as orderId initially
|
||||
"orderId": txHash, // Use tx_hash as orderId
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pollForOrderIndex polls active orders to find the order_index for a newly created order
|
||||
// Returns the highest order_index (newest order) for the given symbol
|
||||
func (t *LighterTraderV2) pollForOrderIndex(symbol string, txHash string) (int64, error) {
|
||||
// Wait a moment for the order to be processed
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return 0, fmt.Errorf("no active orders found (order may have been filled immediately)")
|
||||
}
|
||||
|
||||
// Find the highest order_index (newest order)
|
||||
var highestIndex int64
|
||||
for _, order := range orders {
|
||||
if order.OrderIndex > highestIndex {
|
||||
highestIndex = order.OrderIndex
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order created with order_index: %d (tx_hash: %s)", highestIndex, txHash)
|
||||
return highestIndex, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol Convert NOFX symbol format to Lighter format
|
||||
// NOFX uses "BTC-PERP", "BTCUSDT", etc. Lighter uses "BTC", "ETH", etc.
|
||||
func normalizeSymbol(symbol string) string {
|
||||
@@ -483,7 +431,7 @@ func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// Fetch market list from API (cached for 1 hour)
|
||||
// 1. Fetch market list from API (TODO: cache this)
|
||||
markets, err := t.fetchMarketList()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch market list: %w", err)
|
||||
@@ -519,18 +467,8 @@ type MarketInfo struct {
|
||||
PriceDecimals int `json:"price_decimals"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API with caching (TTL: 1 hour)
|
||||
// fetchMarketList Fetch market list from API
|
||||
func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
// Check cache (TTL: 1 hour)
|
||||
t.marketMutex.RLock()
|
||||
if len(t.marketListCache) > 0 && time.Since(t.marketListCacheTime) < time.Hour {
|
||||
cached := t.marketListCache
|
||||
t.marketMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBooks", t.baseURL)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
@@ -584,12 +522,6 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.marketMutex.Lock()
|
||||
t.marketListCache = markets
|
||||
t.marketListCacheTime = time.Now()
|
||||
t.marketMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ Retrieved %d active markets from Lighter", len(markets))
|
||||
return markets, nil
|
||||
}
|
||||
@@ -618,132 +550,31 @@ func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint16, error)
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage (implements Trader interface)
|
||||
// Lighter uses InitialMarginFraction to represent leverage:
|
||||
// - InitialMarginFraction = (100 / leverage) * 100 (stored as percentage * 100)
|
||||
// - e.g., 5x leverage = 20% margin = 2000 in API
|
||||
// - e.g., 20x leverage = 5% margin = 500 in API
|
||||
func (t *LighterTraderV2) SetLeverage(symbol string, leverage int) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Validate leverage range (1x to 50x typical max)
|
||||
if leverage < 1 || leverage > 50 {
|
||||
return fmt.Errorf("leverage must be between 1 and 50, got %d", leverage)
|
||||
}
|
||||
// TODO: Sign and submit SetLeverage transaction using SDK
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx", symbol, leverage)
|
||||
|
||||
// Get market info (includes market_id)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Calculate InitialMarginFraction from leverage
|
||||
// leverage = 100 / margin_fraction_percent
|
||||
// margin_fraction_percent = 100 / leverage
|
||||
// API value = margin_fraction_percent * 100
|
||||
marginFractionPercent := 100.0 / float64(leverage)
|
||||
initialMarginFraction := uint16(marginFractionPercent * 100) // e.g., 5x => 20% => 2000
|
||||
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx (margin_fraction=%.2f%%, API value=%d)",
|
||||
symbol, leverage, marginFractionPercent, initialMarginFraction)
|
||||
|
||||
// Build UpdateLeverage request
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: 0, // 0 = cross margin (default)
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
nonce := int64(-1) // Auto-fetch nonce
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info from SDK
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API (reuse submitOrder which handles any transaction type)
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Leverage set successfully: %s = %dx (tx_hash: %v)", symbol, leverage, result["tx_hash"])
|
||||
return nil
|
||||
return nil // Return success for now
|
||||
}
|
||||
|
||||
// SetMarginMode Set margin mode (implements Trader interface)
|
||||
// Lighter uses UpdateLeverage transaction which includes both leverage and margin mode
|
||||
// MarginMode: 0 = cross, 1 = isolated
|
||||
func (t *LighterTraderV2) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market info
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Determine margin mode value
|
||||
var marginMode uint8 = 0 // cross
|
||||
modeStr := "cross"
|
||||
if !isCrossMargin {
|
||||
marginMode = 1 // isolated
|
||||
modeStr = "isolated"
|
||||
modeStr := "isolated"
|
||||
if isCrossMargin {
|
||||
modeStr = "cross"
|
||||
}
|
||||
|
||||
// Get current position to preserve leverage, or use default 10x if no position
|
||||
var initialMarginFraction uint16 = 1000 // Default 10x leverage (10% margin = 1000)
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err == nil && pos != nil && pos.Leverage > 0 {
|
||||
// Calculate InitialMarginFraction from current leverage
|
||||
marginFractionPercent := 100.0 / pos.Leverage
|
||||
initialMarginFraction = uint16(marginFractionPercent * 100)
|
||||
}
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s", symbol, modeStr)
|
||||
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s (margin_mode=%d, preserving leverage)", symbol, modeStr, marginMode)
|
||||
|
||||
// Build UpdateLeverage request (also updates margin mode)
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: marginMode,
|
||||
}
|
||||
|
||||
// Sign transaction
|
||||
nonce := int64(-1)
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Margin mode set successfully: %s = %s (tx_hash: %v)", symbol, modeStr, result["tx_hash"])
|
||||
// TODO: Sign and submit SetMarginMode transaction using SDK
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -822,7 +653,7 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
||||
return nil, fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf("stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
logger.Infof("DEBUG stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
|
||||
// Submit order
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
@@ -857,118 +688,7 @@ func pow10(n int) int64 {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder, error) {
|
||||
// Get active orders from Lighter API
|
||||
activeOrders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
var result []tradertypes.OpenOrder
|
||||
for _, order := range activeOrders {
|
||||
// Convert side: Lighter uses is_ask (true=sell, false=buy)
|
||||
side := "BUY"
|
||||
if order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Determine order type from Lighter's type field
|
||||
orderType := "LIMIT"
|
||||
if order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if order.Type == "stop_loss" || order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
// Determine position side based on order direction and reduce-only flag
|
||||
positionSide := "LONG"
|
||||
if order.ReduceOnly {
|
||||
// For reduce-only orders, position side is opposite to order side
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT" // Buying to close short
|
||||
} else {
|
||||
positionSide = "LONG" // Selling to close long
|
||||
}
|
||||
} else {
|
||||
// For opening orders
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse price and quantity from string fields
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.RemainingBaseAmount, 64)
|
||||
if quantity == 0 {
|
||||
quantity, _ = strconv.ParseFloat(order.InitialBaseAmount, 64)
|
||||
}
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
|
||||
openOrder := tradertypes.OpenOrder{
|
||||
OrderID: order.OrderID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: price,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
}
|
||||
result = append(result, openOrder)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Determine if this is a sell (ask) order
|
||||
isAsk := req.Side == "SELL"
|
||||
|
||||
logger.Infof("📝 LIGHTER placing limit order: %s %s @ %.4f, qty=%.4f, leverage=%dx",
|
||||
req.Symbol, req.Side, req.Price, req.Quantity, req.Leverage)
|
||||
|
||||
// Set leverage before placing order (important for grid trading)
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("⚠️ Failed to set leverage: %v (continuing with current leverage)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create limit order using existing CreateOrder function
|
||||
orderResult, err := t.CreateOrder(req.Symbol, isAsk, req.Quantity, req.Price, "limit", req.ReduceOnly)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID from result
|
||||
orderID := ""
|
||||
if id, ok := orderResult["orderId"]; ok {
|
||||
orderID = fmt.Sprintf("%v", id)
|
||||
} else if txHash, ok := orderResult["tx_hash"]; ok {
|
||||
orderID = fmt.Sprintf("%v", txHash)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
|
||||
req.Symbol, req.Side, req.Price, orderID)
|
||||
|
||||
return &tradertypes.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Lighter open orders
|
||||
return []OpenOrder{}, nil
|
||||
}
|
||||
@@ -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
|
||||
@@ -49,24 +41,18 @@ type CreateOrderRequest struct {
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response (Lighter API)
|
||||
// Field names must match Lighter API response exactly
|
||||
// OrderResponse Order response (Lighter)
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
OrderIndex int64 `json:"order_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Side string `json:"side"` // "bid" or "ask"
|
||||
Type string `json:"type"` // "limit", "market", etc.
|
||||
IsAsk bool `json:"is_ask"` // true = sell, false = buy
|
||||
Price string `json:"price"` // Price as string
|
||||
InitialBaseAmount string `json:"initial_base_amount"` // Original quantity
|
||||
RemainingBaseAmount string `json:"remaining_base_amount"` // Remaining quantity
|
||||
FilledBaseAmount string `json:"filled_base_amount"` // Filled quantity
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
TriggerPrice string `json:"trigger_price"` // For stop orders
|
||||
ReduceOnly bool `json:"reduce_only"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
@@ -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,293 +1389,7 @@ 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) {
|
||||
instId := t.convertSymbol(symbol)
|
||||
var result []types.OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders []struct {
|
||||
OrdId string `json:"ordId"`
|
||||
InstId string `json:"instId"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
PosSide string `json:"posSide"` // long/short/net
|
||||
OrdType string `json:"ordType"` // limit/market/post_only
|
||||
Px string `json:"px"` // price
|
||||
Sz string `json:"sz"` // size
|
||||
State string `json:"state"` // live/partially_filled
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Px, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
// Convert OKX side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
if positionSide == "NET" {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.OrdId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrdType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
algoData, err := t.doRequest("GET", algoPath, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
|
||||
}
|
||||
if err == nil && algoData != nil {
|
||||
var algoOrders []struct {
|
||||
AlgoId string `json:"algoId"`
|
||||
InstId string `json:"instId"`
|
||||
Side string `json:"side"`
|
||||
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 {
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
if positionSide == "NET" {
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ OKX GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
instId := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Get instrument info
|
||||
inst, err := t.getInstrument(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instrument info: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[OKX] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert quantity to contract size
|
||||
sz := req.Quantity / inst.CtVal
|
||||
szStr := t.formatSize(sz, inst)
|
||||
|
||||
// Determine side and position side
|
||||
side := "buy"
|
||||
posSide := "long"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"side": side,
|
||||
"posSide": posSide,
|
||||
"ordType": "limit",
|
||||
"sz": szStr,
|
||||
"px": fmt.Sprintf("%.8f", req.Price),
|
||||
"clOrdId": genOkxClOrdID(),
|
||||
"tag": okxTag,
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[OKX] PlaceLimitOrder: %s %s @ %.4f, sz=%s", instId, side, req.Price, szStr)
|
||||
|
||||
data, err := t.doRequest("POST", okxOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrdId string `json:"ordId"`
|
||||
ClOrdId string `json:"clOrdId"`
|
||||
SCode string `json:"sCode"`
|
||||
SMsg string `json:"sMsg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return nil, fmt.Errorf("empty order response")
|
||||
}
|
||||
|
||||
if orders[0].SCode != "0" {
|
||||
return nil, fmt.Errorf("OKX order failed: %s", orders[0].SMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
instId, side, req.Price, orders[0].OrdId)
|
||||
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orders[0].OrdId,
|
||||
ClientID: orders[0].ClOrdId,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) CancelOrder(symbol, orderID string) error {
|
||||
instId := t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"ordId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v5/trade/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [OKX] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
instId := t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v5/market/books?instId=%s&sz=%d", instId, depth)
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
var result []struct {
|
||||
Bids [][]string `json:"bids"`
|
||||
Asks [][]string `json:"asks"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result[0].Bids {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result[0].Asks {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement OKX open orders
|
||||
return []OpenOrder{}, nil
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -14,7 +14,6 @@ import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import { DebateArenaPage } from './pages/DebateArenaPage'
|
||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||
import { DataPage } from './pages/DataPage'
|
||||
import { LoginRequiredOverlay } from './components/LoginRequiredOverlay'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
@@ -42,7 +41,6 @@ type Page =
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'debate'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
@@ -70,7 +68,6 @@ function App() {
|
||||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||
if (path === '/data' || hash === 'data') return 'data'
|
||||
if (path === '/debate' || hash === 'debate') return 'debate'
|
||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
@@ -91,7 +88,6 @@ function App() {
|
||||
const pathMap: Record<Page, string> = {
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'data': '/data',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
@@ -156,8 +152,6 @@ function App() {
|
||||
setCurrentPage('strategy')
|
||||
} else if (path === '/strategy-market' || hash === 'strategy-market') {
|
||||
setCurrentPage('strategy-market')
|
||||
} else if (path === '/data' || hash === 'data') {
|
||||
setCurrentPage('data')
|
||||
} else if (path === '/debate' || hash === 'debate') {
|
||||
setCurrentPage('debate')
|
||||
} else if (
|
||||
@@ -376,51 +370,6 @@ function App() {
|
||||
if (route === '/reset-password') {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
// Data page - publicly accessible with embedded dashboard
|
||||
if (route === '/data') {
|
||||
const dataPageNavigate = (page: Page) => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'data': '/data',
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
'strategy': '/strategy',
|
||||
'debate': '/debate',
|
||||
'faq': '/faq',
|
||||
}
|
||||
const path = pathMap[page]
|
||||
if (path) {
|
||||
window.location.href = path
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage="data"
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={dataPageNavigate}
|
||||
/>
|
||||
<main className="pt-16">
|
||||
<DataPage />
|
||||
</main>
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Show landing page for root route
|
||||
if (route === '/' || route === '') {
|
||||
return <LandingPage />
|
||||
@@ -459,8 +408,6 @@ function App() {
|
||||
>
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'data' ? (
|
||||
<DataPage />
|
||||
) : currentPage === 'strategy-market' ? (
|
||||
<StrategyMarketPage />
|
||||
) : currentPage === 'traders' ? (
|
||||
|
||||
@@ -1384,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,
|
||||
@@ -1500,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 || '')
|
||||
@@ -1518,170 +1426,175 @@ 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)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-6 pb-4 sticky top-0 z-10"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
{/* 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)}
|
||||
{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' }}
|
||||
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>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors" style={{ color: '#848E9C' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
{!editingModelId && (
|
||||
<div className="px-6">
|
||||
<ModelStepIndicator currentStep={currentStep} labels={stepLabels} />
|
||||
</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">
|
||||
<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) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => handleSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
<option key={model.id} value={model.id}>
|
||||
{getShortName(model.name)} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '带金色标记的模型已配置' : 'Models with gold badge are already configured'}
|
||||
</div>
|
||||
</select>
|
||||
</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>
|
||||
{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="flex-1">
|
||||
<div className="font-semibold text-lg" style={{ color: '#EAECEF' }}>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(selectedModel.name)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedModel.provider} • {AI_PROVIDER_CONFIG[selectedModel.provider]?.defaultModel || selectedModel.id}
|
||||
{selectedModel.provider} • {selectedModel.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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="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)' }}
|
||||
className="inline-flex items-center gap-1.5 text-xs hover:underline"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<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>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t('applyApiKey', language)} → {AI_PROVIDER_CONFIG[selectedModel.provider].apiName}
|
||||
</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="mt-2 text-xs p-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
⚠️ {t('kimiApiNote', language)}
|
||||
</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 *
|
||||
{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-4 py-3 rounded-xl"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('customBaseURL', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -1689,20 +1602,23 @@ function ModelConfigModal({
|
||||
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' }}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefault', language)}
|
||||
</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>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('customModelName', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -1710,47 +1626,66 @@ function ModelConfigModal({
|
||||
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' }}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefaultModel', language)}
|
||||
</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
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
|
||||
{/* 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')}
|
||||
<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 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' }}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,8 +12,6 @@ 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',
|
||||
@@ -91,10 +89,6 @@ export const getExchangeIcon = (
|
||||
? 'okx'
|
||||
: lowerType.includes('bitget')
|
||||
? 'bitget'
|
||||
: lowerType.includes('gate')
|
||||
? 'gate'
|
||||
: lowerType.includes('kucoin')
|
||||
? 'kucoin'
|
||||
: lowerType.includes('hyperliquid')
|
||||
? 'hyperliquid'
|
||||
: lowerType.includes('aster')
|
||||
|
||||
@@ -13,7 +13,6 @@ type Page =
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'debate'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
@@ -99,7 +98,6 @@ 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' ? '数据' : '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 },
|
||||
@@ -329,7 +327,6 @@ 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' ? '数据' : '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 },
|
||||
|
||||
@@ -334,8 +334,6 @@ 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>
|
||||
|
||||
@@ -34,8 +34,6 @@ export default function FooterSection({ language }: FooterSectionProps) {
|
||||
{ name: 'Bybit', href: 'https://partner.bybit.com/b/83856' },
|
||||
{ name: 'OKX', href: 'https://www.okx.com/join/1865360' },
|
||||
{ name: 'Bitget', href: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172' },
|
||||
{ name: 'Gate.io', href: 'https://www.gatenode.xyz/share/VQBGUAxY' },
|
||||
{ name: 'KuCoin', href: 'https://www.kucoin.com/r/broker/CXEV7XKK' },
|
||||
{ name: 'Hyperliquid', href: 'https://app.hyperliquid.xyz/join/AITRADING' },
|
||||
{ name: 'Aster DEX', href: 'https://www.asterdex.com/en/referral/fdfc0e' },
|
||||
{ name: 'Lighter', href: 'https://app.lighter.xyz/?referral=68151432' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
||||
import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
@@ -23,38 +23,27 @@ export function CoinSourceEditor({
|
||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||
static: { zh: '静态列表', en: 'Static List' },
|
||||
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
|
||||
oi_top: { zh: 'OI 持仓增加', en: 'OI Increase' },
|
||||
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease' },
|
||||
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
||||
ai500Limit: { zh: '数量上限', en: 'Limit' },
|
||||
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase' },
|
||||
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||
oiTopLimit: { zh: '数量上限', en: 'Limit' },
|
||||
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease' },
|
||||
oiLowLimit: { zh: '数量上限', en: 'Limit' },
|
||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||
ai500Desc: {
|
||||
zh: '使用 AI500 智能筛选的热门币种',
|
||||
en: 'Use AI500 smart-filtered popular coins',
|
||||
},
|
||||
oiTopDesc: {
|
||||
zh: '持仓增加榜,适合做多',
|
||||
en: 'OI increase ranking, for long',
|
||||
},
|
||||
oi_lowDesc: {
|
||||
zh: '持仓减少榜,适合做空',
|
||||
en: 'OI decrease ranking, for short',
|
||||
zh: '使用持仓量增长最快的币种',
|
||||
en: 'Use coins with fastest OI growth',
|
||||
},
|
||||
mixedDesc: {
|
||||
zh: '组合多种数据源',
|
||||
en: 'Combine multiple sources',
|
||||
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
||||
},
|
||||
mixedConfig: { zh: '组合数据源配置', en: 'Combined Sources Configuration' },
|
||||
mixedSummary: { zh: '已选组合', en: 'Selected Sources' },
|
||||
maxCoins: { zh: '最多', en: 'Up to' },
|
||||
coins: { zh: '个币种', en: 'coins' },
|
||||
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
||||
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
||||
@@ -68,35 +57,9 @@ export function CoinSourceEditor({
|
||||
{ value: 'static', icon: List, color: '#848E9C' },
|
||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
// Calculate mixed mode summary
|
||||
const getMixedSummary = () => {
|
||||
const sources: string[] = []
|
||||
let totalLimit = 0
|
||||
|
||||
if (config.use_ai500) {
|
||||
sources.push(`AI500(${config.ai500_limit || 10})`)
|
||||
totalLimit += config.ai500_limit || 10
|
||||
}
|
||||
if (config.use_oi_top) {
|
||||
sources.push(`${language === 'zh' ? 'OI增' : 'OI↑'}(${config.oi_top_limit || 10})`)
|
||||
totalLimit += config.oi_top_limit || 10
|
||||
}
|
||||
if (config.use_oi_low) {
|
||||
sources.push(`${language === 'zh' ? 'OI减' : 'OI↓'}(${config.oi_low_limit || 10})`)
|
||||
totalLimit += config.oi_low_limit || 10
|
||||
}
|
||||
if ((config.static_coins || []).length > 0) {
|
||||
sources.push(`${language === 'zh' ? '自定义' : 'Custom'}(${config.static_coins?.length || 0})`)
|
||||
totalLimit += config.static_coins?.length || 0
|
||||
}
|
||||
|
||||
return { sources, totalLimit }
|
||||
}
|
||||
|
||||
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
|
||||
const xyzDexAssets = new Set([
|
||||
// Stocks
|
||||
@@ -193,7 +156,7 @@ export function CoinSourceEditor({
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{t('sourceType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -219,8 +182,8 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Static Coins - only for static mode */}
|
||||
{config.source_type === 'static' && (
|
||||
{/* Static Coins */}
|
||||
{(config.source_type === 'static' || config.source_type === 'mixed') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{t('staticCoins')}
|
||||
@@ -320,8 +283,8 @@ export function CoinSourceEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI500 Options - only for ai500 mode */}
|
||||
{config.source_type === 'ai500' && (
|
||||
{/* AI500 Options */}
|
||||
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
|
||||
>
|
||||
@@ -377,8 +340,8 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Top Options - only for oi_top mode */}
|
||||
{config.source_type === 'oi_top' && (
|
||||
{/* OI Top Options */}
|
||||
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
|
||||
>
|
||||
@@ -386,7 +349,7 @@ export function CoinSourceEditor({
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
OI {language === 'zh' ? '持仓增加榜' : 'Increase'} {t('dataSourceConfig')}
|
||||
OI Top {t('dataSourceConfig')}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
@@ -412,10 +375,10 @@ export function CoinSourceEditor({
|
||||
{t('oiTopLimit')}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 10}
|
||||
value={config.oi_top_limit || 20}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
@@ -433,306 +396,6 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Low Options - only for oi_low mode */}
|
||||
{config.source_type === 'oi_low' && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-danger/5 border border-nofx-danger/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-nofx-danger" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
OI {language === 'zh' ? '持仓减少榜' : 'Decrease'} {t('dataSourceConfig')}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_low}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_low: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-red-500"
|
||||
/>
|
||||
<span className="text-nofx-text">{t('useOILow')}</span>
|
||||
</label>
|
||||
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{t('oiLowLimit')}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{t('nofxosNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mixed Mode - Unified Card Selector */}
|
||||
{config.source_type === 'mixed' && (
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shuffle className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{t('mixedConfig')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 4 Source Cards in 2x2 Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{/* AI500 Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
config.use_ai500
|
||||
? 'bg-nofx-gold/10 border-nofx-gold/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-gold/30'
|
||||
}`}
|
||||
onClick={() => !disabled && onChange({ ...config, use_ai500: !config.use_ai500 })}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_ai500}
|
||||
onChange={(e) => !disabled && onChange({ ...config, use_ai500: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-nofx-gold"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Database className="w-4 h-4 text-nofx-gold" />
|
||||
<span className="text-sm font-medium text-nofx-text">AI500</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
{config.use_ai500 && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OI Top Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
config.use_oi_top
|
||||
? 'bg-nofx-success/10 border-nofx-success/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-success/30'
|
||||
}`}
|
||||
onClick={() => !disabled && onChange({ ...config, use_oi_top: !config.use_oi_top })}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) => !disabled && onChange({ ...config, use_oi_top: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-nofx-success"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{language === 'zh' ? 'OI 增加' : 'OI Increase'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
|
||||
{language === 'zh' ? '适合做多' : 'For long'}
|
||||
</p>
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OI Low Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
config.use_oi_low
|
||||
? 'bg-nofx-danger/10 border-nofx-danger/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-danger/30'
|
||||
}`}
|
||||
onClick={() => !disabled && onChange({ ...config, use_oi_low: !config.use_oi_low })}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_low}
|
||||
onChange={(e) => !disabled && onChange({ ...config, use_oi_low: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-red-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<TrendingDown className="w-4 h-4 text-nofx-danger" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{language === 'zh' ? 'OI 减少' : 'OI Decrease'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
|
||||
{language === 'zh' ? '适合做空' : 'For short'}
|
||||
</p>
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Static/Custom Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
(config.static_coins || []).length > 0
|
||||
? 'bg-gray-500/10 border-gray-500/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-gray-500/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<List className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{language === 'zh' ? '自定义' : 'Custom'}
|
||||
</span>
|
||||
{(config.static_coins || []).length > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">
|
||||
{config.static_coins?.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(config.static_coins || []).slice(0, 3).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-nofx-bg-lighter text-nofx-text"
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveCoin(coin)
|
||||
}}
|
||||
className="hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(config.static_coins || []).length > 3 && (
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
+{(config.static_coins?.length || 0) - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCoin}
|
||||
onChange={(e) => setNewCoin(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') handleAddCoin()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="BTC, ETH..."
|
||||
className="flex-1 px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddCoin()
|
||||
}}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-gold text-black hover:bg-yellow-500"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{(() => {
|
||||
const { sources, totalLimit } = getMixedSummary()
|
||||
if (sources.length === 0) return null
|
||||
return (
|
||||
<div className="p-2 rounded bg-nofx-bg border border-nofx-border">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-nofx-text-muted">{t('mixedSummary')}:</span>
|
||||
<span className="text-nofx-text font-medium">
|
||||
{sources.join(' + ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-nofx-text-muted mt-1">
|
||||
{t('maxCoins')} {totalLimit} {t('coins')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
||||
import type { GridStrategyConfig } from '../../types'
|
||||
|
||||
interface GridConfigEditorProps {
|
||||
config: GridStrategyConfig
|
||||
onChange: (config: GridStrategyConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
// Default grid config
|
||||
export const defaultGridConfig: GridStrategyConfig = {
|
||||
symbol: 'BTCUSDT',
|
||||
grid_count: 10,
|
||||
total_investment: 1000,
|
||||
leverage: 5,
|
||||
upper_price: 0,
|
||||
lower_price: 0,
|
||||
use_atr_bounds: true,
|
||||
atr_multiplier: 2.0,
|
||||
distribution: 'gaussian',
|
||||
max_drawdown_pct: 15,
|
||||
stop_loss_pct: 5,
|
||||
daily_loss_limit_pct: 10,
|
||||
use_maker_only: true,
|
||||
enable_direction_adjust: false,
|
||||
direction_bias_ratio: 0.7,
|
||||
}
|
||||
|
||||
export function GridConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: GridConfigEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
// Section titles
|
||||
tradingPair: { zh: '交易设置', en: 'Trading Setup' },
|
||||
gridParameters: { zh: '网格参数', en: 'Grid Parameters' },
|
||||
priceBounds: { zh: '价格边界', en: 'Price Bounds' },
|
||||
riskControl: { zh: '风险控制', en: 'Risk Control' },
|
||||
|
||||
// Trading pair
|
||||
symbol: { zh: '交易对', en: 'Trading Pair' },
|
||||
symbolDesc: { zh: '选择要进行网格交易的交易对', en: 'Select trading pair for grid trading' },
|
||||
|
||||
// Investment
|
||||
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)' },
|
||||
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy' },
|
||||
leverage: { zh: '杠杆倍数', en: 'Leverage' },
|
||||
leverageDesc: { zh: '交易使用的杠杆倍数 (1-5)', en: 'Leverage for trading (1-5)' },
|
||||
|
||||
// Grid parameters
|
||||
gridCount: { zh: '网格数量', en: 'Grid Count' },
|
||||
gridCountDesc: { zh: '网格层级数量 (5-50)', en: 'Number of grid levels (5-50)' },
|
||||
distribution: { zh: '资金分配方式', en: 'Distribution' },
|
||||
distributionDesc: { zh: '网格层级的资金分配方式', en: 'Fund allocation across grid levels' },
|
||||
uniform: { zh: '均匀分配', en: 'Uniform' },
|
||||
gaussian: { zh: '高斯分配 (推荐)', en: 'Gaussian (Recommended)' },
|
||||
pyramid: { zh: '金字塔分配', en: 'Pyramid' },
|
||||
|
||||
// Price bounds
|
||||
useAtrBounds: { zh: '自动计算边界 (ATR)', en: 'Auto-calculate Bounds (ATR)' },
|
||||
useAtrBoundsDesc: { zh: '基于 ATR 自动计算网格上下边界', en: 'Auto-calculate bounds based on ATR' },
|
||||
atrMultiplier: { zh: 'ATR 倍数', en: 'ATR Multiplier' },
|
||||
atrMultiplierDesc: { zh: '边界距离当前价格的 ATR 倍数', en: 'ATR multiplier for bounds distance' },
|
||||
upperPrice: { zh: '上边界价格', en: 'Upper Price' },
|
||||
upperPriceDesc: { zh: '网格上边界价格 (0=自动计算)', en: 'Grid upper bound (0=auto)' },
|
||||
lowerPrice: { zh: '下边界价格', en: 'Lower Price' },
|
||||
lowerPriceDesc: { zh: '网格下边界价格 (0=自动计算)', en: 'Grid lower bound (0=auto)' },
|
||||
|
||||
// Risk control
|
||||
maxDrawdown: { zh: '最大回撤 (%)', en: 'Max Drawdown (%)' },
|
||||
maxDrawdownDesc: { zh: '触发紧急退出的最大回撤百分比', en: 'Max drawdown before emergency exit' },
|
||||
stopLoss: { zh: '止损 (%)', en: 'Stop Loss (%)' },
|
||||
stopLossDesc: { zh: '单仓位止损百分比', en: 'Stop loss per position' },
|
||||
dailyLossLimit: { zh: '日损失限制 (%)', en: 'Daily Loss Limit (%)' },
|
||||
dailyLossLimitDesc: { zh: '每日最大亏损百分比', en: 'Maximum daily loss percentage' },
|
||||
useMakerOnly: { zh: '仅使用 Maker 订单', en: 'Maker Only Orders' },
|
||||
useMakerOnlyDesc: { zh: '使用限价单以降低手续费', en: 'Use limit orders for lower fees' },
|
||||
|
||||
// Direction adjustment
|
||||
directionAdjust: { zh: '方向自动调整', en: 'Direction Auto-Adjust' },
|
||||
enableDirectionAdjust: { zh: '启用方向调整', en: 'Enable Direction Adjust' },
|
||||
enableDirectionAdjustDesc: { zh: '根据箱体突破自动调整网格方向(做多/做空/偏多/偏空)', en: 'Auto-adjust grid direction based on box breakouts (long/short/long_bias/short_bias)' },
|
||||
directionBiasRatio: { zh: '偏向比例', en: 'Bias Ratio' },
|
||||
directionBiasRatioDesc: { zh: '偏多/偏空模式下的买卖比例(如 0.7 表示 70% 买 + 30% 卖)', en: 'Buy/sell ratio for bias modes (e.g., 0.7 = 70% buy + 30% sell)' },
|
||||
directionExplain: { zh: '短期箱体突破 → 偏向,中期箱体突破 → 全仓,价格回归 → 逐步恢复中性', en: 'Short box breakout → bias, Mid box breakout → full, Price return → gradually recover to neutral' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof GridStrategyConfig>(
|
||||
key: K,
|
||||
value: GridStrategyConfig[K]
|
||||
) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}
|
||||
|
||||
const sectionStyle = {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Trading Setup */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('tradingPair')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Symbol */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('symbol')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('symbolDesc')}
|
||||
</p>
|
||||
<select
|
||||
value={config.symbol}
|
||||
onChange={(e) => updateField('symbol', e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="BTCUSDT">BTC/USDT</option>
|
||||
<option value="ETHUSDT">ETH/USDT</option>
|
||||
<option value="SOLUSDT">SOL/USDT</option>
|
||||
<option value="BNBUSDT">BNB/USDT</option>
|
||||
<option value="XRPUSDT">XRP/USDT</option>
|
||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Investment */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('totalInvestment')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('totalInvestmentDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.total_investment}
|
||||
onChange={(e) => updateField('total_investment', parseFloat(e.target.value) || 1000)}
|
||||
disabled={disabled}
|
||||
min={100}
|
||||
step={100}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Leverage */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('leverage')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('leverageDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.leverage}
|
||||
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={5}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Parameters */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Grid className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('gridParameters')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Grid Count */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('gridCount')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('gridCountDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.grid_count}
|
||||
onChange={(e) => updateField('grid_count', parseInt(e.target.value) || 10)}
|
||||
disabled={disabled}
|
||||
min={5}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('distribution')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('distributionDesc')}
|
||||
</p>
|
||||
<select
|
||||
value={config.distribution}
|
||||
onChange={(e) => updateField('distribution', e.target.value as 'uniform' | 'gaussian' | 'pyramid')}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="uniform">{t('uniform')}</option>
|
||||
<option value="gaussian">{t('gaussian')}</option>
|
||||
<option value="pyramid">{t('pyramid')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Bounds */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('priceBounds')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* ATR Toggle */}
|
||||
<div className="p-4 rounded-lg mb-4" style={sectionStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('useAtrBounds')}
|
||||
</label>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('useAtrBoundsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_atr_bounds}
|
||||
onChange={(e) => updateField('use_atr_bounds', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_atr_bounds ? (
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('atrMultiplier')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('atrMultiplierDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.atr_multiplier}
|
||||
onChange={(e) => updateField('atr_multiplier', parseFloat(e.target.value) || 2.0)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.5}
|
||||
className="w-32 px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('upperPrice')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('upperPriceDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.upper_price}
|
||||
onChange={(e) => updateField('upper_price', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('lowerPrice')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('lowerPriceDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.lower_price}
|
||||
onChange={(e) => updateField('lower_price', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk Control */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('riskControl')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxDrawdown')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxDrawdownDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_drawdown_pct}
|
||||
onChange={(e) => updateField('max_drawdown_pct', parseFloat(e.target.value) || 15)}
|
||||
disabled={disabled}
|
||||
min={5}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('stopLoss')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('stopLossDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.stop_loss_pct}
|
||||
onChange={(e) => updateField('stop_loss_pct', parseFloat(e.target.value) || 5)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('dailyLossLimit')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('dailyLossLimitDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.daily_loss_limit_pct}
|
||||
onChange={(e) => updateField('daily_loss_limit_pct', parseFloat(e.target.value) || 10)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maker Only Toggle */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('useMakerOnly')}
|
||||
</label>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('useMakerOnlyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_maker_only}
|
||||
onChange={(e) => updateField('use_maker_only', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direction Auto-Adjust */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Compass className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('directionAdjust')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Enable Toggle */}
|
||||
<div className="p-4 rounded-lg mb-4" style={sectionStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('enableDirectionAdjust')}
|
||||
</label>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('enableDirectionAdjustDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_direction_adjust ?? false}
|
||||
onChange={(e) => updateField('enable_direction_adjust', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enable_direction_adjust && (
|
||||
<>
|
||||
{/* Direction Explanation */}
|
||||
<div className="p-3 rounded-lg mb-4" style={{ background: '#1E2329', border: '1px solid #F0B90B33' }}>
|
||||
<p className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
💡 {t('directionExplain')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bias Ratio */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('directionBiasRatio')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('directionBiasRatioDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
value={(config.direction_bias_ratio ?? 0.7) * 100}
|
||||
onChange={(e) => updateField('direction_bias_ratio', parseInt(e.target.value) / 100)}
|
||||
disabled={disabled}
|
||||
min={55}
|
||||
max={90}
|
||||
step={5}
|
||||
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer"
|
||||
style={{ background: '#2B3139' }}
|
||||
/>
|
||||
<span className="text-sm font-mono w-16 text-right" style={{ color: '#F0B90B' }}>
|
||||
{Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% / {Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Shield, TrendingUp, AlertTriangle, Activity, Box, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { GridRiskInfo } from '../../types'
|
||||
|
||||
interface GridRiskPanelProps {
|
||||
traderId: string
|
||||
language?: string
|
||||
refreshInterval?: number // ms, default 5000
|
||||
}
|
||||
|
||||
export function GridRiskPanel({
|
||||
traderId,
|
||||
language = 'en',
|
||||
refreshInterval = 5000,
|
||||
}: GridRiskPanelProps) {
|
||||
const [riskInfo, setRiskInfo] = useState<GridRiskInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
// Section titles
|
||||
gridRisk: { zh: '网格风控', en: 'Grid Risk' },
|
||||
leverageInfo: { zh: '杠杆', en: 'Leverage' },
|
||||
positionInfo: { zh: '仓位', en: 'Position' },
|
||||
liquidationInfo: { zh: '清算', en: 'Liquidation' },
|
||||
marketState: { zh: '市场', en: 'Market' },
|
||||
boxState: { zh: '箱体', en: 'Box' },
|
||||
|
||||
// Leverage
|
||||
currentLeverage: { zh: '当前', en: 'Current' },
|
||||
effectiveLeverage: { zh: '有效', en: 'Effective' },
|
||||
recommendedLeverage: { zh: '建议', en: 'Recommend' },
|
||||
|
||||
// Position
|
||||
currentPosition: { zh: '当前', en: 'Current' },
|
||||
maxPosition: { zh: '最大', en: 'Max' },
|
||||
positionPercent: { zh: '占比', en: 'Usage' },
|
||||
|
||||
// Liquidation
|
||||
liquidationPrice: { zh: '清算价', en: 'Liq Price' },
|
||||
liquidationDistance: { zh: '距离', en: 'Distance' },
|
||||
|
||||
// Market
|
||||
regimeLevel: { zh: '波动', en: 'Regime' },
|
||||
currentPrice: { zh: '价格', en: 'Price' },
|
||||
breakoutLevel: { zh: '突破', en: 'Breakout' },
|
||||
breakoutDirection: { zh: '方向', en: 'Direction' },
|
||||
|
||||
// Box
|
||||
shortBox: { zh: '短期', en: 'Short' },
|
||||
midBox: { zh: '中期', en: 'Mid' },
|
||||
longBox: { zh: '长期', en: 'Long' },
|
||||
|
||||
// Regime levels
|
||||
narrow: { zh: '窄幅', en: 'Narrow' },
|
||||
standard: { zh: '标准', en: 'Standard' },
|
||||
wide: { zh: '宽幅', en: 'Wide' },
|
||||
volatile: { zh: '剧烈', en: 'Volatile' },
|
||||
trending: { zh: '趋势', en: 'Trending' },
|
||||
|
||||
// Breakout levels
|
||||
none: { zh: '无', en: 'None' },
|
||||
short: { zh: '短期', en: 'Short' },
|
||||
mid: { zh: '中期', en: 'Mid' },
|
||||
long: { zh: '长期', en: 'Long' },
|
||||
|
||||
// Directions
|
||||
up: { zh: '↑', en: '↑' },
|
||||
down: { zh: '↓', en: '↓' },
|
||||
|
||||
// Status
|
||||
loading: { zh: '加载中...', en: 'Loading...' },
|
||||
error: { zh: '加载失败', en: 'Load Failed' },
|
||||
noData: { zh: '暂无数据', en: 'No Data' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const fetchRiskInfo = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const response = await fetch(`/api/traders/${traderId}/grid-risk`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setRiskInfo(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [traderId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRiskInfo()
|
||||
const interval = setInterval(fetchRiskInfo, refreshInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchRiskInfo, refreshInterval])
|
||||
|
||||
const getRegimeColor = (regime: string) => {
|
||||
switch (regime) {
|
||||
case 'narrow': return '#0ECB81'
|
||||
case 'standard': return '#F0B90B'
|
||||
case 'wide': return '#F7931A'
|
||||
case 'volatile': return '#F6465D'
|
||||
case 'trending': return '#8B5CF6'
|
||||
default: return '#848E9C'
|
||||
}
|
||||
}
|
||||
|
||||
const getBreakoutColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'none': return '#0ECB81'
|
||||
case 'short': return '#F0B90B'
|
||||
case 'mid': return '#F7931A'
|
||||
case 'long': return '#F6465D'
|
||||
default: return '#848E9C'
|
||||
}
|
||||
}
|
||||
|
||||
const getPositionColor = (percent: number) => {
|
||||
if (percent < 50) return '#0ECB81'
|
||||
if (percent < 80) return '#F0B90B'
|
||||
return '#F6465D'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (price === 0) return '-'
|
||||
if (price >= 1000) return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
if (price >= 1) return price.toFixed(4)
|
||||
return price.toFixed(6)
|
||||
}
|
||||
|
||||
const formatUSD = (value: number) => {
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-3 text-center text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-3 text-center text-xs" style={{ color: '#F6465D' }}>
|
||||
{t('error')}: {error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!riskInfo) {
|
||||
return (
|
||||
<div className="p-3 text-center text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('noData')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg" style={cardStyle}>
|
||||
{/* Collapsible Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-[#1E2329] transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('gridRisk')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Summary badges when collapsed */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded"
|
||||
style={{ background: getRegimeColor(riskInfo.regime_level) + '20', color: getRegimeColor(riskInfo.regime_level) }}
|
||||
>
|
||||
{t(riskInfo.regime_level || 'standard')}
|
||||
</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{riskInfo.effective_leverage.toFixed(1)}x
|
||||
</span>
|
||||
<span
|
||||
className="font-mono"
|
||||
style={{ color: getPositionColor(riskInfo.position_percent) }}
|
||||
>
|
||||
{riskInfo.position_percent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Row 1: Leverage & Position */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Leverage */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<TrendingUp className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('leverageInfo')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('currentLeverage')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{riskInfo.current_leverage}x</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('effectiveLeverage')}</div>
|
||||
<div className="font-mono" style={{ color: '#F0B90B' }}>{riskInfo.effective_leverage.toFixed(2)}x</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('recommendedLeverage')}</div>
|
||||
<div
|
||||
className="font-mono"
|
||||
style={{ color: riskInfo.current_leverage > riskInfo.recommended_leverage ? '#F6465D' : '#0ECB81' }}
|
||||
>
|
||||
{riskInfo.recommended_leverage}x
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Activity className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('positionInfo')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('currentPosition')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{formatUSD(riskInfo.current_position)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('maxPosition')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{formatUSD(riskInfo.max_position)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('positionPercent')}</div>
|
||||
<div className="font-mono" style={{ color: getPositionColor(riskInfo.position_percent) }}>
|
||||
{riskInfo.position_percent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mini progress bar */}
|
||||
<div className="h-1 mt-2 rounded-full overflow-hidden" style={{ background: '#2B3139' }}>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.min(riskInfo.position_percent, 100)}%`, background: getPositionColor(riskInfo.position_percent) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Market State & Liquidation */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Market State */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Shield className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('marketState')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('regimeLevel')}</div>
|
||||
<div className="font-medium" style={{ color: getRegimeColor(riskInfo.regime_level) }}>
|
||||
{t(riskInfo.regime_level || 'standard')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('currentPrice')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{formatPrice(riskInfo.current_price)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('breakoutLevel')}</div>
|
||||
<div className="font-medium" style={{ color: getBreakoutColor(riskInfo.breakout_level) }}>
|
||||
{t(riskInfo.breakout_level || 'none')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('breakoutDirection')}</div>
|
||||
<div
|
||||
className="font-medium"
|
||||
style={{ color: riskInfo.breakout_direction === 'up' ? '#0ECB81' : riskInfo.breakout_direction === 'down' ? '#F6465D' : '#848E9C' }}
|
||||
>
|
||||
{riskInfo.breakout_direction ? t(riskInfo.breakout_direction) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liquidation */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: '#F6465D' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('liquidationInfo')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('liquidationPrice')}</div>
|
||||
<div className="font-mono" style={{ color: '#F6465D' }}>
|
||||
{riskInfo.liquidation_price > 0 ? formatPrice(riskInfo.liquidation_price) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('liquidationDistance')}</div>
|
||||
<div className="font-mono" style={{ color: '#F6465D' }}>
|
||||
{riskInfo.liquidation_distance > 0 ? `${riskInfo.liquidation_distance.toFixed(1)}%` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Box State */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Box className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('boxState')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: '#5E6673' }}>{t('shortBox')}</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(riskInfo.short_box_lower)} - {formatPrice(riskInfo.short_box_upper)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: '#5E6673' }}>{t('midBox')}</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(riskInfo.mid_box_lower)} - {formatPrice(riskInfo.mid_box_upper)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: '#5E6673' }}>{t('longBox')}</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(riskInfo.long_box_lower)} - {formatPrice(riskInfo.long_box_upper)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user