3 Commits

Author SHA1 Message Date
tinklefund
8b4ce279da feat: Add powerful flexible strategy system
Strategy Builder:
- Create strategies from natural language
- Grid trading strategy
- DCA (Dollar Cost Averaging) strategy
- Trend following (EMA crossover) strategy
- Custom rule-based strategies

Strategy Components:
- Entry/exit rules with indicators (RSI, EMA, MACD, etc.)
- Position sizing (fixed, percent, risk-based, kelly)
- Risk management (max drawdown, daily loss limit, cooldown)
- Leverage config (fixed, dynamic, per-symbol, per-volatility)
- Time-based rules (trading hours, hold time limits)
- AI enhancement (confidence threshold, personality)

11 New Tools:
- create_strategy - Natural language strategy creation
- create_grid_strategy - Grid trading setup
- create_dca_strategy - DCA setup
- create_trend_strategy - Trend following setup
- list_smart_strategies - List all strategies
- get_strategy_details - Strategy details
- update_strategy - Modify strategy settings
- activate_strategy - Start trading
- deactivate_strategy - Stop trading
- delete_strategy - Remove strategy
- get_strategy_templates - Show available templates

Total tools now: 24 (13 trading + 11 strategy)
2026-01-30 03:45:10 +08:00
tinklefund
f9d8318869 feat: Add smart trading assistant with context awareness
- Add SmartAgent with automatic context injection
  - Real-time portfolio/position data in every prompt
  - AI knows current state before responding

- Add TradingContext builder
  - Aggregates balance, positions, P&L across all traders
  - Auto-generates alerts (liquidation risk, large loss, etc.)

- Add background Monitor
  - Proactive position monitoring every 30s
  - Detects new positions, closed positions
  - Forwards alerts to Telegram

- Enhanced system prompts
  - Professional trading assistant persona
  - Risk assessment guidelines
  - Clear response formatting rules

Features:
 Context-aware responses
 Proactive risk alerts
 Background monitoring
 Alert broadcasting to Telegram
2026-01-30 03:40:14 +08:00
tinklefund
01ba348841 feat: Add Telegram AI Assistant (moltbot-nofx integration)
- Add assistant package with AI Agent runtime
  - agent.go: Core agent loop with tool calling
  - session.go: Conversation memory management
  - tool.go: Tool interface and base implementation
  - trading_tools.go: Trading-specific tools (13 tools)
  - prompts.go: Trading expert system prompts (EN/ZH)

- Add telegram package for Telegram bot integration
  - bot.go: Telegram bot with rate limiting & access control
  - config.go: Environment-based configuration

- Update main.go to initialize Telegram bot on startup
- Update .env.example with new configuration options
- Add gopkg.in/telebot.v3 dependency

Trading tools available:
- Query: get_balance, get_positions, list_traders, get_trader_status
- Control: start_trader, stop_trader
- Trading: get_market_price, open_long, open_short, close_position
- Config: list_strategies, list_exchanges, list_ai_models
2026-01-30 03:29:22 +08:00
99 changed files with 6306 additions and 6568 deletions

View File

@@ -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`.

View File

@@ -49,12 +49,51 @@ RSA_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END RSA PRI
TRANSPORT_ENCRYPTION=false TRANSPORT_ENCRYPTION=false
# =========================================== # ===========================================
# Optional: External Services # Telegram AI Assistant (NEW - moltbot-nofx)
# =========================================== # ===========================================
# Telegram notifications (optional) # Telegram Bot Token (get from @BotFather)
# TELEGRAM_BOT_TOKEN=your-bot-token # This enables the AI trading assistant via Telegram
# TELEGRAM_CHAT_ID=your-chat-id TELEGRAM_BOT_TOKEN=
# Allowed users (comma-separated Telegram user IDs)
# Leave empty to allow all users (not recommended for production)
# Get your ID from @userinfobot
TELEGRAM_ALLOWED_USERS=
# Admin users (comma-separated Telegram user IDs)
# Admins can manage bot settings
TELEGRAM_ADMIN_USERS=
# Rate limit (messages per minute per user)
TELEGRAM_RATE_LIMIT=30
# Default language: "en" or "zh"
TELEGRAM_LANGUAGE=zh
# ===========================================
# AI Model Configuration (for Assistant)
# ===========================================
# DeepSeek (recommended - cost-effective)
DEEPSEEK_API_KEY=
DEEPSEEK_API_URL=
DEEPSEEK_MODEL=deepseek-chat
# Claude (optional alternative)
CLAUDE_API_KEY=
CLAUDE_API_URL=
CLAUDE_MODEL=
# OpenAI (optional alternative)
OPENAI_API_KEY=
OPENAI_API_URL=
OPENAI_MODEL=
# Qwen (optional alternative)
QWEN_API_KEY=
QWEN_API_URL=
QWEN_MODEL=
DB_TYPE=postgres DB_TYPE=postgres
DB_HOST=10. DB_HOST=10.

1
.gitignore vendored
View File

@@ -124,3 +124,4 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
PR_DESCRIPTION.md PR_DESCRIPTION.md
nofx-moltbot

View File

@@ -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バトル ### 🏆 競争モード - リアルタイムAIバトル

View File

@@ -1,21 +1,9 @@
<h1 align="center">NOFX — Open Source AI Trading OS</h1> # NOFX - Agentic Trading OS
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>The infrastructure layer for AI-powered financial trading.</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
| CONTRIBUTOR AIRDROP PROGRAM | | 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 ### Supported Markets
| Market | Trading | Status | | Market | Trading | Status |
@@ -38,7 +30,7 @@
### Core Features ### Core Features
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime - **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 - **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 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 - **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
@@ -78,35 +70,33 @@ To use NOFX, you'll need:
### CEX (Centralized Exchanges) ### CEX (Centralized Exchanges)
| Exchange | Status | Register (Fee Discount) | | 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) | | **Binance** | ✅ Supported | [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) | | **Bybit** | ✅ Supported | [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) | | **OKX** | ✅ Supported | [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) | | **Bitget** | ✅ Supported | [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) |
### Perp-DEX (Decentralized Perpetual Exchanges) ### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) | | 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) | | **Hyperliquid** | ✅ Supported | [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) | | **Aster DEX** | ✅ Supported | [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) | | **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
--- ---
## Supported AI Models ## Supported AI Models
| AI Model | Status | Get API Key | | 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) | | **DeepSeek** | ✅ Supported | [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) | | **Qwen** | ✅ Supported | [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) | | **OpenAI (GPT)** | ✅ Supported | [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) | | **Claude** | ✅ Supported | [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) | | **Gemini** | ✅ Supported | [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) | | **Grok** | ✅ Supported | [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) | | **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
--- ---

View File

@@ -20,15 +20,6 @@ import (
"nofx/provider/twelvedata" "nofx/provider/twelvedata"
"nofx/store" "nofx/store"
"nofx/trader" "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" "strconv"
"strings" "strings"
"time" "time"
@@ -265,14 +256,13 @@ func (s *Server) handleGetServerIP(c *gin.Context) {
}) })
} }
// getPublicIPFromAPI Get public IP via third-party API (IPv4 only) // getPublicIPFromAPI Get public IP via third-party API
func getPublicIPFromAPI() string { func getPublicIPFromAPI() string {
// Try multiple public IP query services (IPv4-only endpoints) // Try multiple public IP query services
services := []string{ services := []string{
"https://api4.ipify.org?format=text", // IPv4 only "https://api.ipify.org?format=text",
"https://ipv4.icanhazip.com", // IPv4 only "https://icanhazip.com",
"https://v4.ident.me", // IPv4 only "https://ifconfig.me",
"https://api.ipify.org?format=text", // May return IPv4 or IPv6
} }
client := &http.Client{ client := &http.Client{
@@ -294,9 +284,8 @@ func getPublicIPFromAPI() string {
} }
ip := strings.TrimSpace(string(body[:n])) ip := strings.TrimSpace(string(body[:n]))
parsedIP := net.ParseIP(ip) // Verify if it's a valid IP address
// Verify if it's a valid IPv4 address (not containing ":") if net.ParseIP(ip) != nil {
if parsedIP != nil && parsedIP.To4() != nil {
return ip return ip
} }
} }
@@ -594,43 +583,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// Convert EncryptedString fields to string // Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid": case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( tempTrader, createErr = trader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), // private key string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
) )
case "aster": case "aster":
tempTrader, createErr = aster.NewAsterTrader( tempTrader, createErr = trader.NewAsterTrader(
exchangeCfg.AsterUser, exchangeCfg.AsterUser,
exchangeCfg.AsterSigner, exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey), string(exchangeCfg.AsterPrivateKey),
) )
case "bybit": case "bybit":
tempTrader = bybit.NewBybitTrader( tempTrader = trader.NewBybitTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
) )
case "okx": case "okx":
tempTrader = okx.NewOKXTrader( tempTrader = trader.NewOKXTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
) )
case "bitget": case "bitget":
tempTrader = bitget.NewBitgetTrader( tempTrader = trader.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(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
@@ -638,7 +616,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
case "lighter": case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet // Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2( tempTrader, createErr = trader.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr, exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey), string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex, exchangeCfg.LighterAPIKeyIndex,
@@ -1163,43 +1141,32 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
// Convert EncryptedString fields to string // Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid": case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( tempTrader, createErr = trader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
) )
case "aster": case "aster":
tempTrader, createErr = aster.NewAsterTrader( tempTrader, createErr = trader.NewAsterTrader(
exchangeCfg.AsterUser, exchangeCfg.AsterUser,
exchangeCfg.AsterSigner, exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey), string(exchangeCfg.AsterPrivateKey),
) )
case "bybit": case "bybit":
tempTrader = bybit.NewBybitTrader( tempTrader = trader.NewBybitTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
) )
case "okx": case "okx":
tempTrader = okx.NewOKXTrader( tempTrader = trader.NewOKXTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
) )
case "bitget": case "bitget":
tempTrader = bitget.NewBitgetTrader( tempTrader = trader.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(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
@@ -1207,7 +1174,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
case "lighter": case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet // Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2( tempTrader, createErr = trader.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr, exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey), string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex, exchangeCfg.LighterAPIKeyIndex,
@@ -1326,43 +1293,32 @@ func (s *Server) handleClosePosition(c *gin.Context) {
// Convert EncryptedString fields to string // Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid": case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( tempTrader, createErr = trader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
) )
case "aster": case "aster":
tempTrader, createErr = aster.NewAsterTrader( tempTrader, createErr = trader.NewAsterTrader(
exchangeCfg.AsterUser, exchangeCfg.AsterUser,
exchangeCfg.AsterSigner, exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey), string(exchangeCfg.AsterPrivateKey),
) )
case "bybit": case "bybit":
tempTrader = bybit.NewBybitTrader( tempTrader = trader.NewBybitTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
) )
case "okx": case "okx":
tempTrader = okx.NewOKXTrader( tempTrader = trader.NewOKXTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
) )
case "bitget": case "bitget":
tempTrader = bitget.NewBitgetTrader( tempTrader = trader.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(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
@@ -1370,7 +1326,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
case "lighter": case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet // Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2( tempTrader, createErr = trader.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr, exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey), string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex, exchangeCfg.LighterAPIKeyIndex,
@@ -1449,7 +1405,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) { 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 // Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType { 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") logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return return
} }
@@ -2003,7 +1959,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
// Validate exchange type // Validate exchange type
validTypes := map[string]bool{ validTypes := map[string]bool{
"binance": true, "bybit": true, "okx": true, "bitget": true, "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] { if !validTypes[req.ExchangeType] {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
@@ -2535,16 +2491,11 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
coinankExchange = coinank_enum.Okex coinankExchange = coinank_enum.Okex
case "bitget": case "bitget":
coinankExchange = coinank_enum.Bitget coinankExchange = coinank_enum.Bitget
case "gate":
coinankExchange = coinank_enum.Gate
case "aster": case "aster":
coinankExchange = coinank_enum.Aster coinankExchange = coinank_enum.Aster
case "lighter": case "lighter":
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback // Lighter doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance coinankExchange = coinank_enum.Binance
case "kucoin":
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance
default: default:
// For any unknown exchange, default to Binance // For any unknown exchange, default to Binance
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange) logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
@@ -3389,8 +3340,6 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"}, {ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"}, {ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
{ExchangeType: "okx", Name: "OKX 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: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"}, {ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"}, {ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},

350
assistant/agent.go Normal file
View File

@@ -0,0 +1,350 @@
// Package assistant implements the AI Agent runtime with tool calling capabilities
// Inspired by moltbot's agent architecture, specialized for trading
package assistant
import (
"context"
"encoding/json"
"fmt"
"nofx/logger"
"nofx/mcp"
"strings"
"sync"
"time"
)
// Agent represents an AI assistant with tool-calling capabilities
type Agent struct {
// AI client for LLM calls
aiClient mcp.AIClient
// Tool registry
tools map[string]Tool
toolsLock sync.RWMutex
// Session/memory management
sessions map[string]*Session
sessionsLock sync.RWMutex
// Configuration
config AgentConfig
// System prompt
systemPrompt string
}
// AgentConfig holds agent configuration
type AgentConfig struct {
// Max tool calls per turn (prevent infinite loops)
MaxToolCalls int `json:"max_tool_calls"`
// Max conversation history to keep
MaxHistoryMessages int `json:"max_history_messages"`
// Timeout for single AI call
AITimeout time.Duration `json:"ai_timeout"`
// Model to use
Model string `json:"model"`
}
// DefaultAgentConfig returns sensible defaults
func DefaultAgentConfig() AgentConfig {
return AgentConfig{
MaxToolCalls: 10,
MaxHistoryMessages: 50,
AITimeout: 120 * time.Second,
Model: "deepseek-chat",
}
}
// NewAgent creates a new AI agent
func NewAgent(aiClient mcp.AIClient, config AgentConfig) *Agent {
agent := &Agent{
aiClient: aiClient,
tools: make(map[string]Tool),
sessions: make(map[string]*Session),
config: config,
}
// Set default system prompt
agent.systemPrompt = DefaultTradingSystemPrompt()
return agent
}
// RegisterTool adds a tool to the agent's toolkit
func (a *Agent) RegisterTool(tool Tool) {
a.toolsLock.Lock()
defer a.toolsLock.Unlock()
a.tools[tool.Name()] = tool
logger.Infof("🔧 Registered tool: %s", tool.Name())
}
// RegisterTools adds multiple tools
func (a *Agent) RegisterTools(tools ...Tool) {
for _, tool := range tools {
a.RegisterTool(tool)
}
}
// SetSystemPrompt sets the agent's system prompt
func (a *Agent) SetSystemPrompt(prompt string) {
a.systemPrompt = prompt
}
// GetSession returns or creates a session for the given ID
func (a *Agent) GetSession(sessionID string) *Session {
a.sessionsLock.Lock()
defer a.sessionsLock.Unlock()
if session, ok := a.sessions[sessionID]; ok {
return session
}
session := NewSession(sessionID, a.config.MaxHistoryMessages)
a.sessions[sessionID] = session
return session
}
// Chat processes a user message and returns the agent's response
// This is the main entry point for the agent loop
func (a *Agent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
session := a.GetSession(sessionID)
// Add user message to history
session.AddMessage(Message{
Role: "user",
Content: userMessage,
Timestamp: time.Now(),
})
// Build the full prompt with tools
systemPrompt := a.buildSystemPromptWithTools()
conversationPrompt := a.buildConversationPrompt(session)
// Agent loop - keep calling AI until it's done or max iterations
var finalResponse string
toolCallCount := 0
for {
// Check context cancellation
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Check max tool calls
if toolCallCount >= a.config.MaxToolCalls {
logger.Warnf("⚠️ Max tool calls reached (%d), stopping agent loop", a.config.MaxToolCalls)
break
}
// Call AI
response, err := a.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
// Parse response for tool calls
toolCalls, textResponse, err := a.parseResponse(response)
if err != nil {
// If parsing fails, treat entire response as text
finalResponse = response
break
}
// If no tool calls, we're done
if len(toolCalls) == 0 {
finalResponse = textResponse
break
}
// Execute tool calls
toolResults := a.executeToolCalls(ctx, toolCalls)
toolCallCount += len(toolCalls)
// Add tool calls and results to conversation for next iteration
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, please provide your response to the user:",
formatToolCalls(toolCalls),
formatToolResults(toolResults))
// If there's also a text response, capture it
if textResponse != "" {
finalResponse = textResponse
}
}
// Add assistant response to history
session.AddMessage(Message{
Role: "assistant",
Content: finalResponse,
Timestamp: time.Now(),
})
return &AgentResponse{
Text: finalResponse,
SessionID: sessionID,
}, nil
}
// buildSystemPromptWithTools creates the system prompt including tool definitions
func (a *Agent) buildSystemPromptWithTools() string {
a.toolsLock.RLock()
defer a.toolsLock.RUnlock()
var toolDefs []string
for _, tool := range a.tools {
toolDef := fmt.Sprintf(`- **%s**: %s
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
toolDefs = append(toolDefs, toolDef)
}
toolsSection := ""
if len(toolDefs) > 0 {
toolsSection = fmt.Sprintf(`
## Available Tools
You can call tools by responding with JSON in this format:
{"tool_calls": [{"name": "tool_name", "arguments": {"param": "value"}}]}
After receiving tool results, provide a natural language response to the user.
Tools:
%s
`, strings.Join(toolDefs, "\n"))
}
return a.systemPrompt + toolsSection
}
// buildConversationPrompt builds the conversation history as a prompt
func (a *Agent) buildConversationPrompt(session *Session) string {
messages := session.GetMessages()
var parts []string
for _, msg := range messages {
parts = append(parts, fmt.Sprintf("%s: %s", strings.Title(msg.Role), msg.Content))
}
return strings.Join(parts, "\n\n")
}
// parseResponse extracts tool calls and text from AI response
func (a *Agent) parseResponse(response string) ([]ToolCall, string, error) {
// Try to find JSON tool calls in response
// Look for {"tool_calls": [...]} pattern
var toolCalls []ToolCall
textResponse := response
// Try to parse as JSON
if strings.Contains(response, "tool_calls") {
// Find JSON block
start := strings.Index(response, "{")
end := strings.LastIndex(response, "}")
if start >= 0 && end > start {
jsonStr := response[start : end+1]
var parsed struct {
ToolCalls []struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(jsonStr), &parsed); err == nil {
for _, tc := range parsed.ToolCalls {
toolCalls = append(toolCalls, ToolCall{
Name: tc.Name,
Arguments: tc.Arguments,
})
}
// Extract text before/after JSON
textResponse = strings.TrimSpace(response[:start] + response[end+1:])
}
}
}
return toolCalls, textResponse, nil
}
// executeToolCalls runs the requested tools
func (a *Agent) executeToolCalls(ctx context.Context, calls []ToolCall) []ToolResult {
a.toolsLock.RLock()
defer a.toolsLock.RUnlock()
var results []ToolResult
for _, call := range calls {
tool, ok := a.tools[call.Name]
if !ok {
results = append(results, ToolResult{
Name: call.Name,
Error: fmt.Sprintf("unknown tool: %s", call.Name),
})
continue
}
logger.Infof("🔧 Executing tool: %s", call.Name)
result, err := tool.Execute(ctx, call.Arguments)
if err != nil {
logger.Errorf("❌ Tool %s failed: %v", call.Name, err)
results = append(results, ToolResult{
Name: call.Name,
Error: err.Error(),
})
} else {
logger.Infof("✅ Tool %s completed", call.Name)
results = append(results, ToolResult{
Name: call.Name,
Result: result,
})
}
}
return results
}
// ToolCall represents a tool invocation request from the AI
type ToolCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
Name string `json:"name"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// AgentResponse is the final response from the agent
type AgentResponse struct {
Text string `json:"text"`
SessionID string `json:"session_id"`
}
func formatToolCalls(calls []ToolCall) string {
var parts []string
for _, c := range calls {
parts = append(parts, fmt.Sprintf("- %s(%s)", c.Name, string(c.Arguments)))
}
return strings.Join(parts, "\n")
}
func formatToolResults(results []ToolResult) string {
var parts []string
for _, r := range results {
if r.Error != "" {
parts = append(parts, fmt.Sprintf("- %s: ERROR: %s", r.Name, r.Error))
} else {
resultJSON, _ := json.Marshal(r.Result)
parts = append(parts, fmt.Sprintf("- %s: %s", r.Name, string(resultJSON)))
}
}
return strings.Join(parts, "\n")
}

312
assistant/context.go Normal file
View File

@@ -0,0 +1,312 @@
// Package assistant - Trading Context Builder
// Automatically enriches AI prompts with real-time market and portfolio data
package assistant
import (
"fmt"
"nofx/manager"
"nofx/store"
"strings"
"time"
)
// TradingContext holds real-time trading context for AI decision making
type TradingContext struct {
// Portfolio state
TotalEquity float64 `json:"total_equity"`
AvailableBalance float64 `json:"available_balance"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
Positions []PositionSummary `json:"positions"`
// Market data
MarketPrices map[string]float64 `json:"market_prices"`
PriceChanges24h map[string]float64 `json:"price_changes_24h"`
// Trader states
ActiveTraders []TraderSummary `json:"active_traders"`
// Alerts
Alerts []Alert `json:"alerts"`
// Timestamp
UpdatedAt time.Time `json:"updated_at"`
}
// PositionSummary summarizes a position
type PositionSummary struct {
Symbol string `json:"symbol"`
Side string `json:"side"` // "long" or "short"
Size float64 `json:"size"`
EntryPrice float64 `json:"entry_price"`
MarkPrice float64 `json:"mark_price"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
PnLPercent float64 `json:"pnl_percent"`
Leverage int `json:"leverage"`
LiquidationPrice float64 `json:"liquidation_price,omitempty"`
TraderID string `json:"trader_id"`
TraderName string `json:"trader_name"`
}
// TraderSummary summarizes a trader's state
type TraderSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Exchange string `json:"exchange"`
IsRunning bool `json:"is_running"`
Equity float64 `json:"equity"`
PositionCount int `json:"position_count"`
TodayPnL float64 `json:"today_pnl,omitempty"`
}
// Alert represents a trading alert
type Alert struct {
Level string `json:"level"` // "info", "warning", "danger"
Type string `json:"type"` // "liquidation_risk", "large_loss", "price_alert", etc.
Message string `json:"message"`
}
// ContextBuilder builds trading context for AI
type ContextBuilder struct {
traderManager *manager.TraderManager
store *store.Store
}
// NewContextBuilder creates a context builder
func NewContextBuilder(tm *manager.TraderManager, st *store.Store) *ContextBuilder {
return &ContextBuilder{
traderManager: tm,
store: st,
}
}
// BuildContext builds current trading context
func (cb *ContextBuilder) BuildContext() *TradingContext {
ctx := &TradingContext{
MarketPrices: make(map[string]float64),
PriceChanges24h: make(map[string]float64),
UpdatedAt: time.Now(),
}
// Get all traders
allTraders := cb.traderManager.GetAllTraders()
for id, trader := range allTraders {
summary := TraderSummary{
ID: id,
Name: trader.GetName(),
Exchange: trader.GetExchange(),
IsRunning: true, // If in map, it's running
}
// Get account info
if accountInfo, err := trader.GetAccountInfo(); err == nil {
if equity, ok := accountInfo["total_equity"].(float64); ok {
summary.Equity = equity
ctx.TotalEquity += equity
}
if available, ok := accountInfo["available_balance"].(float64); ok {
ctx.AvailableBalance += available
}
}
// Get positions
if positions, err := trader.GetPositions(); err == nil {
summary.PositionCount = len(positions)
for _, pos := range positions {
posSummary := cb.parsePosition(pos, id, trader.GetName())
if posSummary != nil {
ctx.Positions = append(ctx.Positions, *posSummary)
ctx.UnrealizedPnL += posSummary.UnrealizedPnL
// Track market prices
ctx.MarketPrices[posSummary.Symbol] = posSummary.MarkPrice
// Check for alerts
cb.checkPositionAlerts(ctx, posSummary)
}
}
}
ctx.ActiveTraders = append(ctx.ActiveTraders, summary)
}
return ctx
}
// parsePosition parses position data into summary
func (cb *ContextBuilder) parsePosition(pos map[string]interface{}, traderID, traderName string) *PositionSummary {
summary := &PositionSummary{
TraderID: traderID,
TraderName: traderName,
}
if symbol, ok := pos["symbol"].(string); ok {
summary.Symbol = symbol
}
if side, ok := pos["side"].(string); ok {
summary.Side = strings.ToLower(side)
}
if size, ok := pos["size"].(float64); ok {
summary.Size = size
}
if entry, ok := pos["entry_price"].(float64); ok {
summary.EntryPrice = entry
}
if mark, ok := pos["mark_price"].(float64); ok {
summary.MarkPrice = mark
}
if pnl, ok := pos["unrealized_pnl"].(float64); ok {
summary.UnrealizedPnL = pnl
}
if lev, ok := pos["leverage"].(int); ok {
summary.Leverage = lev
}
if liq, ok := pos["liquidation_price"].(float64); ok {
summary.LiquidationPrice = liq
}
// Calculate PnL percent
if summary.EntryPrice > 0 && summary.Size > 0 {
if summary.Side == "long" {
summary.PnLPercent = ((summary.MarkPrice - summary.EntryPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
} else {
summary.PnLPercent = ((summary.EntryPrice - summary.MarkPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
}
}
return summary
}
// checkPositionAlerts checks for position-related alerts
func (cb *ContextBuilder) checkPositionAlerts(ctx *TradingContext, pos *PositionSummary) {
// Liquidation risk alert
if pos.LiquidationPrice > 0 && pos.MarkPrice > 0 {
var distancePercent float64
if pos.Side == "long" {
distancePercent = ((pos.MarkPrice - pos.LiquidationPrice) / pos.MarkPrice) * 100
} else {
distancePercent = ((pos.LiquidationPrice - pos.MarkPrice) / pos.MarkPrice) * 100
}
if distancePercent < 5 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "danger",
Type: "liquidation_risk",
Message: fmt.Sprintf("⚠️ %s %s仓位距离强平仅 %.1f%%", pos.Symbol, pos.Side, distancePercent),
})
} else if distancePercent < 10 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "warning",
Type: "liquidation_risk",
Message: fmt.Sprintf("⚡ %s %s仓位距离强平 %.1f%%,注意风险", pos.Symbol, pos.Side, distancePercent),
})
}
}
// Large loss alert
if pos.PnLPercent < -20 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "danger",
Type: "large_loss",
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%,考虑止损", pos.Symbol, pos.Side, pos.PnLPercent),
})
} else if pos.PnLPercent < -10 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "warning",
Type: "large_loss",
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%", pos.Symbol, pos.Side, pos.PnLPercent),
})
}
// Large profit - consider taking profit
if pos.PnLPercent > 50 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "info",
Type: "large_profit",
Message: fmt.Sprintf("📈 %s %s仓位盈利 %.1f%%,考虑部分止盈", pos.Symbol, pos.Side, pos.PnLPercent),
})
}
}
// FormatContextForPrompt formats context as text for AI prompt injection
func (ctx *TradingContext) FormatContextForPrompt() string {
var sb strings.Builder
sb.WriteString("\n\n---\n## 📊 当前交易状态 (实时)\n\n")
// Portfolio summary
sb.WriteString(fmt.Sprintf("**总权益:** $%.2f | **可用余额:** $%.2f | **未实现盈亏:** $%.2f\n\n",
ctx.TotalEquity, ctx.AvailableBalance, ctx.UnrealizedPnL))
// Alerts (high priority)
if len(ctx.Alerts) > 0 {
sb.WriteString("### ⚠️ 警报\n")
for _, alert := range ctx.Alerts {
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
}
sb.WriteString("\n")
}
// Active positions
if len(ctx.Positions) > 0 {
sb.WriteString("### 📈 持仓\n")
sb.WriteString("| 交易对 | 方向 | 数量 | 入场价 | 现价 | 盈亏 | 盈亏% | 杠杆 | 交易员 |\n")
sb.WriteString("|--------|------|------|--------|------|------|-------|------|--------|\n")
for _, pos := range ctx.Positions {
pnlEmoji := "🟢"
if pos.UnrealizedPnL < 0 {
pnlEmoji = "🔴"
}
sb.WriteString(fmt.Sprintf("| %s | %s | %.4f | %.2f | %.2f | %s$%.2f | %.1f%% | %dx | %s |\n",
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.MarkPrice,
pnlEmoji, pos.UnrealizedPnL, pos.PnLPercent, pos.Leverage, pos.TraderName))
}
sb.WriteString("\n")
} else {
sb.WriteString("### 📈 持仓\n无持仓\n\n")
}
// Active traders
if len(ctx.ActiveTraders) > 0 {
sb.WriteString("### 🤖 运行中的交易员\n")
for _, t := range ctx.ActiveTraders {
status := "✅ 运行中"
if !t.IsRunning {
status = "❌ 已停止"
}
sb.WriteString(fmt.Sprintf("- **%s** (%s) %s | 权益: $%.2f | 持仓: %d\n",
t.Name, t.Exchange, status, t.Equity, t.PositionCount))
}
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("*数据更新时间: %s*\n---\n", ctx.UpdatedAt.Format("2006-01-02 15:04:05")))
return sb.String()
}
// GetTopSymbols returns symbols with positions for market data queries
func (ctx *TradingContext) GetTopSymbols() []string {
symbolSet := make(map[string]bool)
for _, pos := range ctx.Positions {
symbolSet[pos.Symbol] = true
}
// Always include major pairs
symbolSet["BTCUSDT"] = true
symbolSet["ETHUSDT"] = true
symbols := make([]string, 0, len(symbolSet))
for s := range symbolSet {
symbols = append(symbols, s)
}
return symbols
}
// EnrichWithMarketData adds market data to context
// Note: Market prices are already populated from position data
func (cb *ContextBuilder) EnrichWithMarketData(ctx *TradingContext, symbols []string) {
// Market prices are populated from position mark prices
// Additional market data enrichment can be added here in the future
}

200
assistant/monitor.go Normal file
View File

@@ -0,0 +1,200 @@
package assistant
import (
"fmt"
"nofx/logger"
"nofx/manager"
"nofx/store"
"sync"
"time"
)
// Monitor provides proactive monitoring and alerts
type Monitor struct {
traderManager *manager.TraderManager
store *store.Store
contextBuilder *ContextBuilder
// Alert callbacks
alertCallbacks []func(Alert)
callbackMu sync.RWMutex
// State
running bool
stopChan chan struct{}
interval time.Duration
// Last known state for change detection
lastPositions map[string]PositionSummary
lastAlerts map[string]time.Time // Prevent alert spam
mu sync.RWMutex
}
// NewMonitor creates a new trading monitor
func NewMonitor(tm *manager.TraderManager, st *store.Store) *Monitor {
return &Monitor{
traderManager: tm,
store: st,
contextBuilder: NewContextBuilder(tm, st),
stopChan: make(chan struct{}),
interval: 30 * time.Second, // Check every 30 seconds
lastPositions: make(map[string]PositionSummary),
lastAlerts: make(map[string]time.Time),
}
}
// OnAlert registers an alert callback
func (m *Monitor) OnAlert(callback func(Alert)) {
m.callbackMu.Lock()
defer m.callbackMu.Unlock()
m.alertCallbacks = append(m.alertCallbacks, callback)
}
// Start starts the monitor
func (m *Monitor) Start() {
m.mu.Lock()
if m.running {
m.mu.Unlock()
return
}
m.running = true
m.stopChan = make(chan struct{})
m.mu.Unlock()
logger.Info("🔍 Starting trading monitor...")
go m.monitorLoop()
}
// Stop stops the monitor
func (m *Monitor) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if !m.running {
return
}
m.running = false
close(m.stopChan)
logger.Info("🔍 Trading monitor stopped")
}
// monitorLoop is the main monitoring loop
func (m *Monitor) monitorLoop() {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
// Initial check
m.checkAndAlert()
for {
select {
case <-ticker.C:
m.checkAndAlert()
case <-m.stopChan:
return
}
}
}
// checkAndAlert checks positions and sends alerts
func (m *Monitor) checkAndAlert() {
ctx := m.contextBuilder.BuildContext()
// Process built-in alerts from context
for _, alert := range ctx.Alerts {
m.sendAlertIfNew(alert)
}
// Check for position changes
m.checkPositionChanges(ctx)
// Check for new large movements
m.checkMarketMovements(ctx)
}
// checkPositionChanges detects significant position changes
func (m *Monitor) checkPositionChanges(ctx *TradingContext) {
m.mu.Lock()
defer m.mu.Unlock()
currentPositions := make(map[string]PositionSummary)
for _, pos := range ctx.Positions {
key := fmt.Sprintf("%s_%s_%s", pos.TraderID, pos.Symbol, pos.Side)
currentPositions[key] = pos
// Check if this is a new position
if _, existed := m.lastPositions[key]; !existed {
m.sendAlert(Alert{
Level: "info",
Type: "new_position",
Message: fmt.Sprintf("📍 新开仓位: %s %s %.4f @ %.2f (%dx)",
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.Leverage),
})
}
}
// Check for closed positions
for key, oldPos := range m.lastPositions {
if _, exists := currentPositions[key]; !exists {
m.sendAlert(Alert{
Level: "info",
Type: "position_closed",
Message: fmt.Sprintf("📍 仓位已平: %s %s (入场价: %.2f)",
oldPos.Symbol, oldPos.Side, oldPos.EntryPrice),
})
}
}
m.lastPositions = currentPositions
}
// checkMarketMovements checks for significant market movements
func (m *Monitor) checkMarketMovements(ctx *TradingContext) {
// This could be expanded to check price movements
// For now, we rely on the context builder's alerts
}
// sendAlertIfNew sends an alert only if it's new (avoid spam)
func (m *Monitor) sendAlertIfNew(alert Alert) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s_%s", alert.Type, alert.Message)
// Check if we sent this alert recently (within 5 minutes)
if lastSent, ok := m.lastAlerts[key]; ok {
if time.Since(lastSent) < 5*time.Minute {
return // Skip, already sent recently
}
}
m.lastAlerts[key] = time.Now()
m.sendAlert(alert)
}
// sendAlert sends alert to all registered callbacks
func (m *Monitor) sendAlert(alert Alert) {
m.callbackMu.RLock()
callbacks := make([]func(Alert), len(m.alertCallbacks))
copy(callbacks, m.alertCallbacks)
m.callbackMu.RUnlock()
for _, cb := range callbacks {
go cb(alert)
}
}
// GetCurrentContext returns the current trading context
func (m *Monitor) GetCurrentContext() *TradingContext {
return m.contextBuilder.BuildContext()
}
// SetInterval sets the monitoring interval
func (m *Monitor) SetInterval(d time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.interval = d
}

117
assistant/prompts.go Normal file
View File

@@ -0,0 +1,117 @@
package assistant
// DefaultTradingSystemPrompt returns the default system prompt for trading assistant
func DefaultTradingSystemPrompt() string {
return `# NOFX Trading Assistant
You are an expert AI trading assistant powered by NOFX - an advanced AI-powered trading system.
## Your Capabilities
1. **Account Management**
- Check balances across multiple exchanges
- View current positions and P&L
- Monitor portfolio performance
2. **Trading Operations**
- Execute trades (open/close positions)
- Manage stop-loss and take-profit orders
- Adjust leverage and margin settings
3. **AI Traders Management**
- Start/stop AI traders
- Monitor AI trader performance
- Configure trading strategies
4. **Strategy & Analysis**
- Create and modify trading strategies
- Initiate AI debate sessions for market analysis
- Backtest strategies on historical data
5. **Market Intelligence**
- Get real-time prices and market data
- Analyze market conditions
- Track open interest and funding rates
## Guidelines
1. **Safety First**: Always confirm with the user before executing trades or making significant changes
2. **Be Precise**: When dealing with numbers, be exact - trading involves real money
3. **Explain Reasoning**: Help users understand your analysis and recommendations
4. **Risk Awareness**: Always remind users about the risks involved in trading
5. **Proactive Monitoring**: Alert users to important position changes or market movements
## Response Style
- Be concise but thorough
- Use tables for data when appropriate
- Include relevant metrics (P&L, ROI, etc.)
- Provide actionable insights, not just data dumps
- Support both English and Chinese (respond in the user's language)
## Important Notes
- Never share API keys or sensitive credentials
- Always use proper position sizing based on user's risk tolerance
- Warn users about high-risk operations (high leverage, large positions)
Remember: You are a professional trading assistant. Users trust you with their trading operations. Be accurate, be helpful, and be responsible.`
}
// ChineseSystemPrompt returns Chinese version of the system prompt
func ChineseSystemPrompt() string {
return `# NOFX 交易助手
你是一个由 NOFX 驱动的专业 AI 交易助手 - 一个先进的 AI 驱动交易系统。
## 你的能力
1. **账户管理**
- 查询多交易所余额
- 查看当前持仓和盈亏
- 监控投资组合表现
2. **交易操作**
- 执行交易(开仓/平仓)
- 管理止损止盈订单
- 调整杠杆和保证金设置
3. **AI 交易员管理**
- 启动/停止 AI 交易员
- 监控 AI 交易员表现
- 配置交易策略
4. **策略与分析**
- 创建和修改交易策略
- 发起 AI 辩论会议进行市场分析
- 回测历史数据
5. **市场情报**
- 获取实时价格和市场数据
- 分析市场状况
- 跟踪持仓量和资金费率
## 行为准则
1. **安全第一**:执行交易或重大操作前,务必与用户确认
2. **精确无误**:涉及数字时必须精确 - 交易涉及真金白银
3. **解释逻辑**:帮助用户理解你的分析和建议
4. **风险意识**:始终提醒用户交易风险
5. **主动监控**:及时提醒用户重要的仓位变化或市场波动
## 回复风格
- 简洁但全面
- 适当使用表格展示数据
- 包含相关指标(盈亏、收益率等)
- 提供可操作的见解,而非单纯的数据罗列
- 支持中英文(根据用户使用的语言回复)
## 重要提示
- 永远不要分享 API 密钥或敏感凭证
- 根据用户的风险承受能力进行合理的仓位管理
- 对高风险操作(高杠杆、大仓位)发出警告
记住:你是专业的交易助手。用户将交易操作托付于你。准确、有用、负责。`
}

122
assistant/session.go Normal file
View File

@@ -0,0 +1,122 @@
package assistant
import (
"sync"
"time"
)
// Message represents a single message in conversation
type Message struct {
Role string `json:"role"` // "user", "assistant", "system", "tool"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
// For tool messages
ToolName string `json:"tool_name,omitempty"`
ToolResult interface{} `json:"tool_result,omitempty"`
}
// Session represents a conversation session with memory
type Session struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// User info
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Platform string `json:"platform"` // "telegram", "web", etc.
// Conversation history
messages []Message
maxMessages int
mu sync.RWMutex
// Custom metadata
Metadata map[string]interface{} `json:"metadata"`
}
// NewSession creates a new conversation session
func NewSession(id string, maxMessages int) *Session {
return &Session{
ID: id,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
messages: make([]Message, 0),
maxMessages: maxMessages,
Metadata: make(map[string]interface{}),
}
}
// AddMessage adds a message to the session
func (s *Session) AddMessage(msg Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, msg)
s.UpdatedAt = time.Now()
// Trim old messages if exceeding max
if len(s.messages) > s.maxMessages {
// Keep the most recent messages
s.messages = s.messages[len(s.messages)-s.maxMessages:]
}
}
// GetMessages returns a copy of all messages
func (s *Session) GetMessages() []Message {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Message, len(s.messages))
copy(result, s.messages)
return result
}
// GetRecentMessages returns the N most recent messages
func (s *Session) GetRecentMessages(n int) []Message {
s.mu.RLock()
defer s.mu.RUnlock()
if n >= len(s.messages) {
result := make([]Message, len(s.messages))
copy(result, s.messages)
return result
}
result := make([]Message, n)
copy(result, s.messages[len(s.messages)-n:])
return result
}
// Clear removes all messages from the session
func (s *Session) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = make([]Message, 0)
s.UpdatedAt = time.Now()
}
// SetUserInfo sets user information
func (s *Session) SetUserInfo(userID, userName, platform string) {
s.mu.Lock()
defer s.mu.Unlock()
s.UserID = userID
s.UserName = userName
s.Platform = platform
}
// SetMetadata sets a metadata value
func (s *Session) SetMetadata(key string, value interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.Metadata[key] = value
}
// GetMetadata gets a metadata value
func (s *Session) GetMetadata(key string) (interface{}, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.Metadata[key]
return v, ok
}

216
assistant/smart_agent.go Normal file
View File

@@ -0,0 +1,216 @@
package assistant
import (
"context"
"fmt"
"nofx/logger"
"nofx/manager"
"nofx/mcp"
"nofx/store"
"strings"
"time"
)
// SmartAgent is an enhanced AI agent with trading context awareness
type SmartAgent struct {
*Agent
contextBuilder *ContextBuilder
monitor *Monitor
// Auto-inject context into prompts
autoInjectContext bool
}
// NewSmartAgent creates a new smart trading agent
func NewSmartAgent(aiClient mcp.AIClient, config AgentConfig, tm *manager.TraderManager, st *store.Store) *SmartAgent {
baseAgent := NewAgent(aiClient, config)
baseAgent.SetSystemPrompt(SmartTradingPrompt())
contextBuilder := NewContextBuilder(tm, st)
monitor := NewMonitor(tm, st)
return &SmartAgent{
Agent: baseAgent,
contextBuilder: contextBuilder,
monitor: monitor,
autoInjectContext: true,
}
}
// SetAutoInjectContext enables/disables automatic context injection
func (sa *SmartAgent) SetAutoInjectContext(enabled bool) {
sa.autoInjectContext = enabled
}
// StartMonitor starts the background monitor
func (sa *SmartAgent) StartMonitor() {
sa.monitor.Start()
}
// StopMonitor stops the background monitor
func (sa *SmartAgent) StopMonitor() {
sa.monitor.Stop()
}
// OnAlert registers an alert callback
func (sa *SmartAgent) OnAlert(callback func(Alert)) {
sa.monitor.OnAlert(callback)
}
// Chat processes a message with smart context injection
func (sa *SmartAgent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
session := sa.GetSession(sessionID)
// Add user message to history
session.AddMessage(Message{
Role: "user",
Content: userMessage,
Timestamp: time.Now(),
})
// Build system prompt with tools
systemPrompt := sa.buildSmartSystemPrompt()
// Build conversation prompt with context injection
conversationPrompt := sa.buildSmartConversationPrompt(session, userMessage)
// Agent loop
var finalResponse string
toolCallCount := 0
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if toolCallCount >= sa.config.MaxToolCalls {
logger.Warnf("⚠️ Max tool calls reached (%d)", sa.config.MaxToolCalls)
break
}
response, err := sa.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
toolCalls, textResponse, err := sa.parseResponse(response)
if err != nil {
finalResponse = response
break
}
if len(toolCalls) == 0 {
finalResponse = textResponse
break
}
// Execute tool calls
toolResults := sa.executeToolCalls(ctx, toolCalls)
toolCallCount += len(toolCalls)
// Add results to conversation
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, provide a helpful response:",
formatToolCalls(toolCalls),
formatToolResults(toolResults))
if textResponse != "" {
finalResponse = textResponse
}
}
// Add response to history
session.AddMessage(Message{
Role: "assistant",
Content: finalResponse,
Timestamp: time.Now(),
})
return &AgentResponse{
Text: finalResponse,
SessionID: sessionID,
}, nil
}
// buildSmartSystemPrompt builds system prompt with tools
func (sa *SmartAgent) buildSmartSystemPrompt() string {
sa.toolsLock.RLock()
defer sa.toolsLock.RUnlock()
var toolDefs []string
for _, tool := range sa.tools {
toolDef := fmt.Sprintf(`- **%s**: %s
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
toolDefs = append(toolDefs, toolDef)
}
toolsSection := ""
if len(toolDefs) > 0 {
toolsSection = fmt.Sprintf(`
## 可用工具
调用工具时,使用以下 JSON 格式:
{"tool_calls": [{"name": "工具名", "arguments": {"参数": "值"}}]}
收到工具结果后,用自然语言回复用户。
可用工具:
%s
`, strings.Join(toolDefs, "\n"))
}
return sa.systemPrompt + toolsSection
}
// buildSmartConversationPrompt builds conversation with context injection
func (sa *SmartAgent) buildSmartConversationPrompt(session *Session, currentMessage string) string {
var sb strings.Builder
// Inject current trading context if enabled
if sa.autoInjectContext {
tradingCtx := sa.contextBuilder.BuildContext()
sb.WriteString(tradingCtx.FormatContextForPrompt())
}
// Add conversation history
messages := session.GetMessages()
for _, msg := range messages {
sb.WriteString(fmt.Sprintf("\n%s: %s\n", strings.Title(msg.Role), msg.Content))
}
return sb.String()
}
// QuickStatus returns a quick status summary
func (sa *SmartAgent) QuickStatus() string {
ctx := sa.contextBuilder.BuildContext()
var sb strings.Builder
sb.WriteString("📊 **交易状态概览**\n\n")
sb.WriteString(fmt.Sprintf("💰 总权益: $%.2f\n", ctx.TotalEquity))
sb.WriteString(fmt.Sprintf("💵 可用余额: $%.2f\n", ctx.AvailableBalance))
if ctx.UnrealizedPnL >= 0 {
sb.WriteString(fmt.Sprintf("📈 未实现盈亏: 🟢 +$%.2f\n", ctx.UnrealizedPnL))
} else {
sb.WriteString(fmt.Sprintf("📉 未实现盈亏: 🔴 $%.2f\n", ctx.UnrealizedPnL))
}
sb.WriteString(fmt.Sprintf("📍 持仓数: %d\n", len(ctx.Positions)))
sb.WriteString(fmt.Sprintf("🤖 运行交易员: %d\n", len(ctx.ActiveTraders)))
if len(ctx.Alerts) > 0 {
sb.WriteString("\n⚠ **警报**\n")
for _, alert := range ctx.Alerts {
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
}
}
return sb.String()
}
// GetTradingContext returns current trading context
func (sa *SmartAgent) GetTradingContext() *TradingContext {
return sa.contextBuilder.BuildContext()
}

115
assistant/smart_prompts.go Normal file
View File

@@ -0,0 +1,115 @@
package assistant
import "fmt"
// SmartTradingPrompt returns an enhanced system prompt with trading intelligence
func SmartTradingPrompt() string {
return `# 🧠 NOFX 智能交易助手
你是一个专业的 AI 交易助手,具备以下能力:
## 核心能力
### 1. 智能分析
- 分析用户意图,理解交易需求
- 在执行交易前,主动评估风险
- 结合市场数据给出建议
### 2. 主动提醒
- 发现持仓风险时主动警告
- 大额亏损时建议止损
- 接近强平时紧急提醒
### 3. 专业建议
- 根据仓位情况建议操作
- 评估杠杆和仓位大小是否合理
- 提供入场/出场时机建议
## 交易原则
1. **安全第一**:任何交易操作前必须确认,高风险操作要多次确认
2. **风险控制**
- 单笔交易不超过总资金的 10%
- 杠杆建议BTC/ETH ≤10x山寨币 ≤5x
- 发现强平风险立即警告
3. **理性决策**:不鼓励情绪化交易,亏损时建议冷静
## 回复风格
- 简洁专业,像交易员一样说话
- 数据说话,给出具体数字
- 风险提示放在显眼位置
- 支持中英文,根据用户语言回复
## 工具使用策略
当用户问到持仓、余额时:
1. 先调用 list_traders 获取交易员列表
2. 对运行中的交易员调用 get_balance 和 get_positions
3. 汇总数据后清晰展示
当用户想交易时:
1. 先获取当前持仓和余额
2. 评估这笔交易的风险
3. 明确告知风险后请求确认
4. 确认后执行交易
当用户问市场行情时:
1. 获取相关币种价格
2. 结合持仓情况分析
3. 给出操作建议(但声明不构成投资建议)
## 重要:响应格式
- 持仓展示用表格
- 重要警告用 ⚠️ 标注
- 盈利用 🟢,亏损用 🔴
- 操作建议用列表
记住:你的目标是帮助用户更好地管理交易,而不是鼓励频繁交易。稳健盈利比追求高收益更重要。`
}
// RiskAssessmentPrompt returns a prompt for risk assessment before trades
func RiskAssessmentPrompt(action, symbol string, quantity, leverage float64, currentBalance, currentPositions string) string {
return fmt.Sprintf(`## 交易风险评估
请评估以下交易的风险:
**操作**: %s %s
**数量**: %.4f
**杠杆**: %.0fx
**当前账户状态**:
%s
**当前持仓**:
%s
请分析:
1. 这笔交易是否合理?
2. 仓位大小是否过大?
3. 杠杆是否过高?
4. 有什么潜在风险?
5. 你的建议是什么?
如果风险过高,请明确警告用户。`, action, symbol, quantity, leverage, currentBalance, currentPositions)
}
// MarketAnalysisPrompt returns a prompt for market analysis
func MarketAnalysisPrompt(symbol string, priceData, positionData string) string {
return fmt.Sprintf(`## %s 市场分析
**价格数据**:
%s
**相关持仓**:
%s
请分析:
1. 当前价格趋势
2. 关键支撑/阻力位
3. 持仓建议(继续持有/加仓/减仓/平仓)
4. 风险提示
注:这是基于有限数据的分析,不构成投资建议。`, symbol, priceData, positionData)
}

View File

@@ -0,0 +1,382 @@
// Package assistant - Intelligent Strategy Builder
// Allows users to create powerful, flexible trading strategies through natural language
package assistant
import (
"fmt"
"nofx/store"
"strings"
"time"
"github.com/google/uuid"
)
// StrategyType defines the type of trading strategy
type StrategyType string
const (
StrategyTypeAI StrategyType = "ai" // AI decides everything
StrategyTypeTrend StrategyType = "trend" // Trend following
StrategyTypeMeanRevert StrategyType = "mean_revert" // Mean reversion
StrategyTypeGrid StrategyType = "grid" // Grid trading
StrategyTypeDCA StrategyType = "dca" // Dollar cost averaging
StrategyTypeBreakout StrategyType = "breakout" // Breakout trading
StrategyTypeArbitrage StrategyType = "arbitrage" // Cross-exchange arbitrage
StrategyTypeCustom StrategyType = "custom" // Custom rules
)
// SmartStrategy represents a user-defined trading strategy
type SmartStrategy struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type StrategyType `json:"type"`
// Trading pairs
Symbols []string `json:"symbols"` // e.g., ["BTCUSDT", "ETHUSDT"]
SymbolMode string `json:"symbol_mode"` // "static", "ai_select", "top_volume", "top_oi"
MaxSymbols int `json:"max_symbols"` // Max symbols to trade simultaneously
// Entry conditions
EntryRules []Rule `json:"entry_rules"`
EntryMode string `json:"entry_mode"` // "any" (OR) or "all" (AND)
// Exit conditions
ExitRules []Rule `json:"exit_rules"`
TakeProfit *float64 `json:"take_profit"` // TP percentage
StopLoss *float64 `json:"stop_loss"` // SL percentage
TrailingStop *float64 `json:"trailing_stop"` // Trailing stop percentage
// Position sizing
PositionSize PositionSizeConfig `json:"position_size"`
MaxPositions int `json:"max_positions"` // Max concurrent positions
MaxPerSymbol int `json:"max_per_symbol"` // Max positions per symbol
// Risk management
RiskConfig RiskConfig `json:"risk_config"`
// Leverage settings
LeverageConfig LeverageConfig `json:"leverage_config"`
// Time settings
TimeConfig TimeConfig `json:"time_config"`
// AI enhancement
AIConfig AIStrategyConfig `json:"ai_config"`
// Metadata
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
IsActive bool `json:"is_active"`
Performance *StrategyPerformance `json:"performance,omitempty"`
}
// Rule represents a trading rule/condition
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "indicator", "price", "time", "volume", "ai", "custom"
Indicator string `json:"indicator"` // e.g., "RSI", "MACD", "EMA"
Condition string `json:"condition"` // e.g., "crosses_above", "greater_than", "less_than"
Value interface{} `json:"value"` // The value to compare against
Timeframe string `json:"timeframe"` // e.g., "1h", "4h", "1d"
Weight float64 `json:"weight"` // Weight for scoring (0-1)
Description string `json:"description"` // Human readable description
}
// PositionSizeConfig defines how to size positions
type PositionSizeConfig struct {
Mode string `json:"mode"` // "fixed", "percent", "risk_based", "kelly"
FixedAmount float64 `json:"fixed_amount"` // Fixed USDT amount
PercentOfEquity float64 `json:"percent_of_equity"` // Percentage of total equity
RiskPerTrade float64 `json:"risk_per_trade"` // Max risk per trade (%)
MaxSingleTrade float64 `json:"max_single_trade"` // Max single trade size (USDT)
}
// RiskConfig defines risk management rules
type RiskConfig struct {
MaxDrawdown float64 `json:"max_drawdown"` // Max drawdown before stopping (%)
MaxDailyLoss float64 `json:"max_daily_loss"` // Max daily loss (%)
MaxOpenRisk float64 `json:"max_open_risk"` // Max total open risk (%)
CooldownAfterLoss int `json:"cooldown_after_loss"` // Minutes to wait after a loss
RequireConfirmation bool `json:"require_confirmation"` // Require user confirmation for trades
EmergencyStopLoss float64 `json:"emergency_stop_loss"` // Emergency SL for all positions (%)
}
// LeverageConfig defines leverage settings
type LeverageConfig struct {
Mode string `json:"mode"` // "fixed", "dynamic", "per_symbol"
DefaultLeverage int `json:"default_leverage"`
MaxLeverage int `json:"max_leverage"`
PerSymbol map[string]int `json:"per_symbol"` // Symbol-specific leverage
PerVolatility []VolatilityLever `json:"per_volatility"` // Volatility-based leverage
}
// VolatilityLever defines leverage based on volatility
type VolatilityLever struct {
MaxVolatility float64 `json:"max_volatility"` // ATR percentage threshold
Leverage int `json:"leverage"`
}
// TimeConfig defines time-based settings
type TimeConfig struct {
TradingHours []TimeRange `json:"trading_hours"` // When to trade
AvoidNews bool `json:"avoid_news"` // Avoid major news events
AvoidWeekends bool `json:"avoid_weekends"`
MinHoldTime int `json:"min_hold_time"` // Minimum hold time (minutes)
MaxHoldTime int `json:"max_hold_time"` // Maximum hold time (minutes)
ScanInterval int `json:"scan_interval"` // How often to scan (minutes)
}
// TimeRange represents a time range
type TimeRange struct {
Start string `json:"start"` // "09:00"
End string `json:"end"` // "17:00"
TZ string `json:"tz"` // Timezone
}
// AIStrategyConfig defines AI-specific settings
type AIStrategyConfig struct {
Enabled bool `json:"enabled"`
Model string `json:"model"` // AI model to use
ConfidenceThreshold float64 `json:"confidence_threshold"` // Min confidence to act
UseMarketSentiment bool `json:"use_market_sentiment"`
UseTechnicalAnalysis bool `json:"use_technical_analysis"`
UseOnChainData bool `json:"use_onchain_data"`
CustomPrompt string `json:"custom_prompt"` // Custom instructions for AI
Personality string `json:"personality"` // "aggressive", "conservative", "balanced"
}
// StrategyPerformance tracks strategy performance
type StrategyPerformance struct {
TotalTrades int `json:"total_trades"`
WinningTrades int `json:"winning_trades"`
LosingTrades int `json:"losing_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
MaxDrawdown float64 `json:"max_drawdown"`
SharpeRatio float64 `json:"sharpe_ratio"`
ProfitFactor float64 `json:"profit_factor"`
AvgWin float64 `json:"avg_win"`
AvgLoss float64 `json:"avg_loss"`
LastUpdated time.Time `json:"last_updated"`
}
// StrategyBuilder helps users create strategies through conversation
type StrategyBuilder struct {
store *store.Store
}
// NewStrategyBuilder creates a new strategy builder
func NewStrategyBuilder(st *store.Store) *StrategyBuilder {
return &StrategyBuilder{store: st}
}
// CreateFromNaturalLanguage creates a strategy from natural language description
func (sb *StrategyBuilder) CreateFromNaturalLanguage(description string, userID string) (*SmartStrategy, error) {
// This would typically call an AI to parse the description
// For now, we create a basic template
strategy := &SmartStrategy{
ID: uuid.New().String()[:8],
Name: "Custom Strategy",
Description: description,
Type: StrategyTypeAI,
SymbolMode: "ai_select",
MaxSymbols: 5,
EntryMode: "all",
MaxPositions: 5,
MaxPerSymbol: 1,
PositionSize: PositionSizeConfig{
Mode: "percent",
PercentOfEquity: 5,
MaxSingleTrade: 1000,
},
RiskConfig: RiskConfig{
MaxDrawdown: 20,
MaxDailyLoss: 5,
MaxOpenRisk: 10,
CooldownAfterLoss: 30,
RequireConfirmation: true,
EmergencyStopLoss: 30,
},
LeverageConfig: LeverageConfig{
Mode: "dynamic",
DefaultLeverage: 3,
MaxLeverage: 10,
},
TimeConfig: TimeConfig{
ScanInterval: 5,
AvoidWeekends: false,
},
AIConfig: AIStrategyConfig{
Enabled: true,
ConfidenceThreshold: 0.7,
UseMarketSentiment: true,
UseTechnicalAnalysis: true,
Personality: "balanced",
CustomPrompt: description,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedBy: userID,
IsActive: false,
}
return strategy, nil
}
// CreateGridStrategy creates a grid trading strategy
func (sb *StrategyBuilder) CreateGridStrategy(symbol string, lowerPrice, upperPrice float64, gridCount int, amountPerGrid float64) *SmartStrategy {
return &SmartStrategy{
ID: uuid.New().String()[:8],
Name: fmt.Sprintf("Grid %s", symbol),
Description: fmt.Sprintf("Grid trading %s from %.2f to %.2f with %d grids", symbol, lowerPrice, upperPrice, gridCount),
Type: StrategyTypeGrid,
Symbols: []string{symbol},
SymbolMode: "static",
MaxPositions: gridCount,
PositionSize: PositionSizeConfig{
Mode: "fixed",
FixedAmount: amountPerGrid,
},
EntryRules: []Rule{
{
ID: "grid_entry",
Type: "price",
Condition: "grid_level",
Value: map[string]interface{}{
"lower_price": lowerPrice,
"upper_price": upperPrice,
"grid_count": gridCount,
},
},
},
CreatedAt: time.Now(),
IsActive: false,
}
}
// CreateDCAStrategy creates a DCA strategy
func (sb *StrategyBuilder) CreateDCAStrategy(symbol string, intervalMinutes int, amountPerBuy float64, maxBuys int) *SmartStrategy {
return &SmartStrategy{
ID: uuid.New().String()[:8],
Name: fmt.Sprintf("DCA %s", symbol),
Description: fmt.Sprintf("DCA into %s every %d minutes, $%.2f per buy, max %d buys", symbol, intervalMinutes, amountPerBuy, maxBuys),
Type: StrategyTypeDCA,
Symbols: []string{symbol},
SymbolMode: "static",
MaxPositions: maxBuys,
PositionSize: PositionSizeConfig{
Mode: "fixed",
FixedAmount: amountPerBuy,
},
TimeConfig: TimeConfig{
ScanInterval: intervalMinutes,
},
CreatedAt: time.Now(),
IsActive: false,
}
}
// CreateTrendStrategy creates a trend following strategy
func (sb *StrategyBuilder) CreateTrendStrategy(symbols []string, emaFast, emaSlow int, leverage int) *SmartStrategy {
return &SmartStrategy{
ID: uuid.New().String()[:8],
Name: "Trend Following",
Description: fmt.Sprintf("EMA %d/%d crossover strategy", emaFast, emaSlow),
Type: StrategyTypeTrend,
Symbols: symbols,
SymbolMode: "static",
EntryMode: "all",
EntryRules: []Rule{
{
ID: "ema_cross",
Name: "EMA Crossover",
Type: "indicator",
Indicator: "EMA",
Condition: "crosses_above",
Value: map[string]int{
"fast_period": emaFast,
"slow_period": emaSlow,
},
Timeframe: "1h",
Weight: 1.0,
},
},
ExitRules: []Rule{
{
ID: "ema_cross_exit",
Name: "EMA Crossover Exit",
Type: "indicator",
Indicator: "EMA",
Condition: "crosses_below",
Value: map[string]int{
"fast_period": emaFast,
"slow_period": emaSlow,
},
Timeframe: "1h",
},
},
LeverageConfig: LeverageConfig{
Mode: "fixed",
DefaultLeverage: leverage,
},
CreatedAt: time.Now(),
IsActive: false,
}
}
// StrategyToPrompt converts a strategy to an AI prompt
func StrategyToPrompt(s *SmartStrategy) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("# 策略: %s\n\n", s.Name))
sb.WriteString(fmt.Sprintf("**描述**: %s\n", s.Description))
sb.WriteString(fmt.Sprintf("**类型**: %s\n\n", s.Type))
// Trading pairs
if len(s.Symbols) > 0 {
sb.WriteString(fmt.Sprintf("**交易对**: %s\n", strings.Join(s.Symbols, ", ")))
} else {
sb.WriteString(fmt.Sprintf("**选币模式**: %s (最多 %d 个)\n", s.SymbolMode, s.MaxSymbols))
}
// Entry rules
if len(s.EntryRules) > 0 {
sb.WriteString("\n## 入场规则\n")
for _, rule := range s.EntryRules {
sb.WriteString(fmt.Sprintf("- %s: %s %s %v\n", rule.Name, rule.Indicator, rule.Condition, rule.Value))
}
}
// Exit rules
sb.WriteString("\n## 出场规则\n")
if s.TakeProfit != nil {
sb.WriteString(fmt.Sprintf("- 止盈: %.1f%%\n", *s.TakeProfit))
}
if s.StopLoss != nil {
sb.WriteString(fmt.Sprintf("- 止损: %.1f%%\n", *s.StopLoss))
}
if s.TrailingStop != nil {
sb.WriteString(fmt.Sprintf("- 移动止损: %.1f%%\n", *s.TrailingStop))
}
// Risk management
sb.WriteString("\n## 风险管理\n")
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n", s.RiskConfig.MaxDrawdown))
sb.WriteString(fmt.Sprintf("- 单日最大亏损: %.1f%%\n", s.RiskConfig.MaxDailyLoss))
sb.WriteString(fmt.Sprintf("- 最大持仓数: %d\n", s.MaxPositions))
// AI settings
if s.AIConfig.Enabled {
sb.WriteString("\n## AI 配置\n")
sb.WriteString(fmt.Sprintf("- 置信度阈值: %.0f%%\n", s.AIConfig.ConfidenceThreshold*100))
sb.WriteString(fmt.Sprintf("- 风格: %s\n", s.AIConfig.Personality))
if s.AIConfig.CustomPrompt != "" {
sb.WriteString(fmt.Sprintf("- 自定义指令: %s\n", s.AIConfig.CustomPrompt))
}
}
return sb.String()
}

548
assistant/strategy_tools.go Normal file
View File

@@ -0,0 +1,548 @@
package assistant
import (
"context"
"encoding/json"
"fmt"
"nofx/store"
)
// StrategyTools provides strategy management tools for the AI agent
type StrategyTools struct {
store *store.Store
strategyBuilder *StrategyBuilder
strategies map[string]*SmartStrategy // In-memory strategy cache
}
// NewStrategyTools creates strategy tools
func NewStrategyTools(st *store.Store) *StrategyTools {
return &StrategyTools{
store: st,
strategyBuilder: NewStrategyBuilder(st),
strategies: make(map[string]*SmartStrategy),
}
}
// GetAllTools returns all strategy tools
func (st *StrategyTools) GetAllTools() []Tool {
return []Tool{
st.CreateStrategyTool(),
st.CreateGridStrategyTool(),
st.CreateDCAStrategyTool(),
st.CreateTrendStrategyTool(),
st.ListSmartStrategiesTool(),
st.GetStrategyDetailsTool(),
st.UpdateStrategyTool(),
st.ActivateStrategyTool(),
st.DeactivateStrategyTool(),
st.DeleteStrategyTool(),
st.GetStrategyTemplates(),
}
}
// CreateStrategyTool creates a strategy from natural language
func (st *StrategyTools) CreateStrategyTool() Tool {
return NewTool(
"create_strategy",
`Create a new trading strategy from natural language description.
Examples:
- "当RSI低于30时买入BTCRSI高于70时卖出"
- "每天定投100美元ETH"
- "BTC在5万到6万之间做网格交易"`,
`{
"name": "string (required) - Strategy name",
"description": "string (required) - Natural language description of the strategy",
"symbols": "array (optional) - Trading pairs, e.g., [\"BTCUSDT\", \"ETHUSDT\"]",
"take_profit": "number (optional) - Take profit percentage",
"stop_loss": "number (optional) - Stop loss percentage",
"leverage": "number (optional) - Leverage to use (default: 3)",
"max_positions": "number (optional) - Max concurrent positions (default: 5)"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Name string `json:"name"`
Description string `json:"description"`
Symbols []string `json:"symbols"`
TakeProfit *float64 `json:"take_profit"`
StopLoss *float64 `json:"stop_loss"`
Leverage int `json:"leverage"`
MaxPositions int `json:"max_positions"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.Description == "" {
return nil, fmt.Errorf("strategy description is required")
}
strategy, err := st.strategyBuilder.CreateFromNaturalLanguage(params.Description, "default")
if err != nil {
return nil, err
}
// Apply user customizations
if params.Name != "" {
strategy.Name = params.Name
}
if len(params.Symbols) > 0 {
strategy.Symbols = params.Symbols
strategy.SymbolMode = "static"
}
if params.TakeProfit != nil {
strategy.TakeProfit = params.TakeProfit
}
if params.StopLoss != nil {
strategy.StopLoss = params.StopLoss
}
if params.Leverage > 0 {
strategy.LeverageConfig.DefaultLeverage = params.Leverage
}
if params.MaxPositions > 0 {
strategy.MaxPositions = params.MaxPositions
}
// Store in memory
st.strategies[strategy.ID] = strategy
return map[string]interface{}{
"success": true,
"strategy": strategy,
"message": fmt.Sprintf("策略 '%s' (ID: %s) 创建成功!使用 activate_strategy 激活它。", strategy.Name, strategy.ID),
}, nil
},
)
}
// CreateGridStrategyTool creates a grid trading strategy
func (st *StrategyTools) CreateGridStrategyTool() Tool {
return NewTool(
"create_grid_strategy",
"Create a grid trading strategy. Grid trading places buy and sell orders at predetermined price levels.",
`{
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
"lower_price": "number (required) - Lower price bound",
"upper_price": "number (required) - Upper price bound",
"grid_count": "number (required) - Number of grids (10-100)",
"amount_per_grid": "number (required) - USDT amount per grid"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Symbol string `json:"symbol"`
LowerPrice float64 `json:"lower_price"`
UpperPrice float64 `json:"upper_price"`
GridCount int `json:"grid_count"`
AmountPerGrid float64 `json:"amount_per_grid"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.LowerPrice >= params.UpperPrice {
return nil, fmt.Errorf("lower_price must be less than upper_price")
}
if params.GridCount < 2 || params.GridCount > 100 {
return nil, fmt.Errorf("grid_count must be between 2 and 100")
}
strategy := st.strategyBuilder.CreateGridStrategy(
params.Symbol, params.LowerPrice, params.UpperPrice,
params.GridCount, params.AmountPerGrid,
)
st.strategies[strategy.ID] = strategy
gridSize := (params.UpperPrice - params.LowerPrice) / float64(params.GridCount)
totalInvestment := params.AmountPerGrid * float64(params.GridCount)
return map[string]interface{}{
"success": true,
"strategy": strategy,
"details": map[string]interface{}{
"grid_size": gridSize,
"total_investment": totalInvestment,
"profit_per_grid": (gridSize / params.LowerPrice) * 100,
},
"message": fmt.Sprintf("网格策略创建成功!\n价格区间: %.2f - %.2f\n网格数: %d\n每格间距: %.2f\n总投资: $%.2f",
params.LowerPrice, params.UpperPrice, params.GridCount, gridSize, totalInvestment),
}, nil
},
)
}
// CreateDCAStrategyTool creates a DCA strategy
func (st *StrategyTools) CreateDCAStrategyTool() Tool {
return NewTool(
"create_dca_strategy",
"Create a Dollar Cost Averaging (DCA) strategy. Automatically buy at regular intervals.",
`{
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
"interval_minutes": "number (required) - Buy interval in minutes (min: 5)",
"amount_per_buy": "number (required) - USDT amount per purchase",
"max_buys": "number (optional) - Maximum number of buys (default: unlimited)"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Symbol string `json:"symbol"`
IntervalMinutes int `json:"interval_minutes"`
AmountPerBuy float64 `json:"amount_per_buy"`
MaxBuys int `json:"max_buys"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.IntervalMinutes < 5 {
return nil, fmt.Errorf("interval must be at least 5 minutes")
}
if params.MaxBuys == 0 {
params.MaxBuys = 1000 // Effectively unlimited
}
strategy := st.strategyBuilder.CreateDCAStrategy(
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys,
)
st.strategies[strategy.ID] = strategy
return map[string]interface{}{
"success": true,
"strategy": strategy,
"message": fmt.Sprintf("DCA策略创建成功\n币种: %s\n定投间隔: %d分钟\n每次金额: $%.2f\n最大次数: %d",
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys),
}, nil
},
)
}
// CreateTrendStrategyTool creates a trend following strategy
func (st *StrategyTools) CreateTrendStrategyTool() Tool {
return NewTool(
"create_trend_strategy",
"Create a trend following strategy using EMA crossover.",
`{
"symbols": "array (required) - Trading pairs",
"ema_fast": "number (optional) - Fast EMA period (default: 9)",
"ema_slow": "number (optional) - Slow EMA period (default: 21)",
"leverage": "number (optional) - Leverage (default: 3)",
"take_profit": "number (optional) - Take profit %",
"stop_loss": "number (optional) - Stop loss %"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Symbols []string `json:"symbols"`
EMAFast int `json:"ema_fast"`
EMASlow int `json:"ema_slow"`
Leverage int `json:"leverage"`
TakeProfit *float64 `json:"take_profit"`
StopLoss *float64 `json:"stop_loss"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if len(params.Symbols) == 0 {
params.Symbols = []string{"BTCUSDT", "ETHUSDT"}
}
if params.EMAFast == 0 {
params.EMAFast = 9
}
if params.EMASlow == 0 {
params.EMASlow = 21
}
if params.Leverage == 0 {
params.Leverage = 3
}
strategy := st.strategyBuilder.CreateTrendStrategy(
params.Symbols, params.EMAFast, params.EMASlow, params.Leverage,
)
strategy.TakeProfit = params.TakeProfit
strategy.StopLoss = params.StopLoss
st.strategies[strategy.ID] = strategy
return map[string]interface{}{
"success": true,
"strategy": strategy,
"message": fmt.Sprintf("趋势策略创建成功!\nEMA %d/%d 交叉\n交易对: %v\n杠杆: %dx",
params.EMAFast, params.EMASlow, params.Symbols, params.Leverage),
}, nil
},
)
}
// ListSmartStrategiesTool lists all smart strategies
func (st *StrategyTools) ListSmartStrategiesTool() Tool {
return NewTool(
"list_smart_strategies",
"List all smart strategies (both in-memory and saved).",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var result []map[string]interface{}
for _, s := range st.strategies {
result = append(result, map[string]interface{}{
"id": s.ID,
"name": s.Name,
"type": s.Type,
"description": s.Description,
"is_active": s.IsActive,
"symbols": s.Symbols,
"created_at": s.CreatedAt,
})
}
// Also get strategies from store
if dbStrategies, err := st.store.Strategy().List("default"); err == nil {
for _, s := range dbStrategies {
result = append(result, map[string]interface{}{
"id": s.ID,
"name": s.Name,
"type": "db_strategy",
"description": s.Description,
"is_active": s.IsActive,
"source": "database",
})
}
}
if len(result) == 0 {
return map[string]interface{}{
"strategies": []interface{}{},
"message": "暂无策略。使用 create_strategy 创建一个新策略。",
}, nil
}
return map[string]interface{}{
"strategies": result,
"count": len(result),
}, nil
},
)
}
// GetStrategyDetailsTool gets detailed strategy info
func (st *StrategyTools) GetStrategyDetailsTool() Tool {
return NewTool(
"get_strategy_details",
"Get detailed information about a specific strategy.",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if s, ok := st.strategies[params.StrategyID]; ok {
return map[string]interface{}{
"strategy": s,
"prompt_text": StrategyToPrompt(s),
}, nil
}
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
},
)
}
// UpdateStrategyTool updates a strategy
func (st *StrategyTools) UpdateStrategyTool() Tool {
return NewTool(
"update_strategy",
"Update an existing strategy's settings.",
`{
"strategy_id": "string (required)",
"name": "string (optional)",
"take_profit": "number (optional)",
"stop_loss": "number (optional)",
"leverage": "number (optional)",
"max_positions": "number (optional)",
"symbols": "array (optional)"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
Name string `json:"name"`
TakeProfit *float64 `json:"take_profit"`
StopLoss *float64 `json:"stop_loss"`
Leverage int `json:"leverage"`
MaxPositions int `json:"max_positions"`
Symbols []string `json:"symbols"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
s, ok := st.strategies[params.StrategyID]
if !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
if params.Name != "" {
s.Name = params.Name
}
if params.TakeProfit != nil {
s.TakeProfit = params.TakeProfit
}
if params.StopLoss != nil {
s.StopLoss = params.StopLoss
}
if params.Leverage > 0 {
s.LeverageConfig.DefaultLeverage = params.Leverage
}
if params.MaxPositions > 0 {
s.MaxPositions = params.MaxPositions
}
if len(params.Symbols) > 0 {
s.Symbols = params.Symbols
}
return map[string]interface{}{
"success": true,
"strategy": s,
"message": "策略已更新",
}, nil
},
)
}
// ActivateStrategyTool activates a strategy
func (st *StrategyTools) ActivateStrategyTool() Tool {
return NewTool(
"activate_strategy",
"Activate a strategy to start trading. ⚠️ This will start real trading!",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
s, ok := st.strategies[params.StrategyID]
if !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
s.IsActive = true
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("⚠️ 策略 '%s' 已激活!将开始真实交易。", s.Name),
"strategy": s,
}, nil
},
)
}
// DeactivateStrategyTool deactivates a strategy
func (st *StrategyTools) DeactivateStrategyTool() Tool {
return NewTool(
"deactivate_strategy",
"Deactivate a strategy to stop trading.",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
s, ok := st.strategies[params.StrategyID]
if !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
s.IsActive = false
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("策略 '%s' 已停用", s.Name),
}, nil
},
)
}
// DeleteStrategyTool deletes a strategy
func (st *StrategyTools) DeleteStrategyTool() Tool {
return NewTool(
"delete_strategy",
"Delete a strategy permanently.",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if _, ok := st.strategies[params.StrategyID]; !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
delete(st.strategies, params.StrategyID)
return map[string]interface{}{
"success": true,
"message": "策略已删除",
}, nil
},
)
}
// GetStrategyTemplates returns available strategy templates
func (st *StrategyTools) GetStrategyTemplates() Tool {
return NewTool(
"get_strategy_templates",
"Get available strategy templates and examples.",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
templates := []map[string]interface{}{
{
"name": "AI 智能交易",
"type": "ai",
"description": "让 AI 自主分析市场并决策,适合不想手动盯盘的用户",
"example": "create_strategy(name='AI智能', description='分析BTC和ETH的技术指标和市场情绪在有明确趋势时入场')",
},
{
"name": "网格交易",
"type": "grid",
"description": "在价格区间内自动低买高卖,适合震荡行情",
"example": "create_grid_strategy(symbol='BTCUSDT', lower_price=90000, upper_price=100000, grid_count=20, amount_per_grid=100)",
},
{
"name": "定投 DCA",
"type": "dca",
"description": "定期定额买入,摊薄成本,适合长期投资",
"example": "create_dca_strategy(symbol='ETHUSDT', interval_minutes=1440, amount_per_buy=50, max_buys=365)",
},
{
"name": "趋势跟踪",
"type": "trend",
"description": "跟随趋势EMA金叉买入死叉卖出",
"example": "create_trend_strategy(symbols=['BTCUSDT','ETHUSDT'], ema_fast=9, ema_slow=21, leverage=3)",
},
{
"name": "RSI 超买超卖",
"type": "custom",
"description": "RSI 低于 30 买入,高于 70 卖出",
"example": "create_strategy(name='RSI策略', description='当RSI14低于30时买入高于70时卖出止损10%')",
},
{
"name": "突破策略",
"type": "breakout",
"description": "价格突破关键位时入场",
"example": "create_strategy(name='突破策略', description='当价格突破20日最高点时做多突破20日最低点时做空')",
},
}
return map[string]interface{}{
"templates": templates,
"message": "以上是可用的策略模板,选择一个并告诉我你想怎么定制!",
}, nil
},
)
}

47
assistant/tool.go Normal file
View File

@@ -0,0 +1,47 @@
package assistant
import (
"context"
"encoding/json"
)
// Tool represents a callable tool that the AI agent can use
type Tool interface {
// Name returns the tool's unique identifier
Name() string
// Description returns a human-readable description for the AI
Description() string
// ParameterSchema returns JSON schema for the tool's parameters
ParameterSchema() string
// Execute runs the tool with the given arguments
Execute(ctx context.Context, args json.RawMessage) (interface{}, error)
}
// BaseTool provides common functionality for tools
type BaseTool struct {
ToolName string
ToolDescription string
ToolSchema string
ExecuteFunc func(ctx context.Context, args json.RawMessage) (interface{}, error)
}
func (t *BaseTool) Name() string { return t.ToolName }
func (t *BaseTool) Description() string { return t.ToolDescription }
func (t *BaseTool) ParameterSchema() string { return t.ToolSchema }
func (t *BaseTool) Execute(ctx context.Context, args json.RawMessage) (interface{}, error) {
return t.ExecuteFunc(ctx, args)
}
// NewTool creates a simple tool from a function
func NewTool(name, description, schema string, fn func(ctx context.Context, args json.RawMessage) (interface{}, error)) Tool {
return &BaseTool{
ToolName: name,
ToolDescription: description,
ToolSchema: schema,
ExecuteFunc: fn,
}
}

530
assistant/trading_tools.go Normal file
View File

@@ -0,0 +1,530 @@
package assistant
import (
"context"
"encoding/json"
"fmt"
"nofx/logger"
"nofx/manager"
"nofx/store"
)
// TradingTools provides all trading-related tools for the AI agent
type TradingTools struct {
traderManager *manager.TraderManager
store *store.Store
}
// NewTradingTools creates trading tools with access to NOFX core
func NewTradingTools(tm *manager.TraderManager, st *store.Store) *TradingTools {
return &TradingTools{
traderManager: tm,
store: st,
}
}
// GetAllTools returns all trading tools
func (t *TradingTools) GetAllTools() []Tool {
return []Tool{
t.GetBalanceTool(),
t.GetPositionsTool(),
t.ListTradersTool(),
t.GetTraderStatusTool(),
t.StartTraderTool(),
t.StopTraderTool(),
t.GetMarketPriceTool(),
t.OpenLongTool(),
t.OpenShortTool(),
t.ClosePositionTool(),
t.ListStrategiesTool(),
t.ListExchangesTool(),
t.ListAIModelsTool(),
}
}
// ==================== Query Tools ====================
// GetBalanceTool returns the get_balance tool
func (t *TradingTools) GetBalanceTool() Tool {
return NewTool(
"get_balance",
"Get account balance for a trader. Returns available balance, total equity, and margin info.",
`{"trader_id": "string (required) - The trader ID to query"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
balance, err := trader.GetAccountInfo()
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
return balance, nil
},
)
}
// GetPositionsTool returns the get_positions tool
func (t *TradingTools) GetPositionsTool() Tool {
return NewTool(
"get_positions",
"Get all open positions for a trader. Returns symbol, side, size, entry price, unrealized P&L.",
`{"trader_id": "string (required) - The trader ID to query"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
positions, err := trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
return positions, nil
},
)
}
// ListTradersTool returns the list_traders tool
func (t *TradingTools) ListTradersTool() Tool {
return NewTool(
"list_traders",
"List all configured AI traders with their status (running/stopped), exchange, AI model, and performance.",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
traders, err := t.store.Trader().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list traders: %w", err)
}
var result []map[string]interface{}
for _, tr := range traders {
traderInfo := map[string]interface{}{
"id": tr.ID,
"name": tr.Name,
"is_running": tr.IsRunning,
"ai_model_id": tr.AIModelID,
"exchange_id": tr.ExchangeID,
"strategy_id": tr.StrategyID,
"created_at": tr.CreatedAt,
}
// Try to get live status if trader is running
if liveTrader, err := t.traderManager.GetTrader(tr.ID); err == nil {
status := liveTrader.GetStatus()
traderInfo["live_status"] = status
}
result = append(result, traderInfo)
}
return result, nil
},
)
}
// GetTraderStatusTool returns detailed status of a specific trader
func (t *TradingTools) GetTraderStatusTool() Tool {
return NewTool(
"get_trader_status",
"Get detailed status of a specific trader including current positions, recent trades, and performance metrics.",
`{"trader_id": "string (required) - The trader ID to query"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
// Get trader config from store
traderConfig, err := t.store.Trader().GetByID(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
result := map[string]interface{}{
"id": traderConfig.ID,
"name": traderConfig.Name,
"is_running": traderConfig.IsRunning,
"ai_model_id": traderConfig.AIModelID,
"exchange_id": traderConfig.ExchangeID,
"strategy_id": traderConfig.StrategyID,
}
// If trader is running, get live data
trader, err := t.traderManager.GetTrader(params.TraderID)
if err == nil && trader != nil {
result["live_status"] = trader.GetStatus()
if balance, err := trader.GetAccountInfo(); err == nil {
result["balance"] = balance
}
if positions, err := trader.GetPositions(); err == nil {
result["positions"] = positions
}
}
return result, nil
},
)
}
// ==================== Control Tools ====================
// StartTraderTool starts an AI trader
func (t *TradingTools) StartTraderTool() Tool {
return NewTool(
"start_trader",
"Start an AI trader to begin automated trading. The trader will execute trades based on its configured strategy.",
`{"trader_id": "string (required) - The trader ID to start"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
// Check if already running
existingTrader, _ := t.traderManager.GetTrader(params.TraderID)
if existingTrader != nil {
status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
return nil, fmt.Errorf("trader is already running")
}
// Remove from memory to reload
t.traderManager.RemoveTrader(params.TraderID)
}
// Load and start trader
if err := t.traderManager.LoadUserTradersFromStore(t.store, "default"); err != nil {
return nil, fmt.Errorf("failed to load trader: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("failed to get trader after load: %w", err)
}
// Start the trader in a goroutine
go func() {
if err := trader.Run(); err != nil {
logger.Errorf("Trader %s error: %v", params.TraderID, err)
}
}()
// Update status in database
if err := t.store.Trader().UpdateStatus("default", params.TraderID, true); err != nil {
logger.Warnf("Failed to update trader status in DB: %v", err)
}
return map[string]interface{}{
"success": true,
"trader_id": params.TraderID,
"message": "Trader started successfully",
}, nil
},
)
}
// StopTraderTool stops an AI trader
func (t *TradingTools) StopTraderTool() Tool {
return NewTool(
"stop_trader",
"Stop an AI trader. This will halt automated trading but keep existing positions open.",
`{"trader_id": "string (required) - The trader ID to stop"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
// Check if running
status := trader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
return nil, fmt.Errorf("trader is already stopped")
}
// Stop the trader
trader.Stop()
// Update status in database
if err := t.store.Trader().UpdateStatus("default", params.TraderID, false); err != nil {
logger.Warnf("Failed to update trader status in DB: %v", err)
}
return map[string]interface{}{
"success": true,
"trader_id": params.TraderID,
"message": "Trader stopped successfully",
}, nil
},
)
}
// ==================== Trading Tools ====================
// GetMarketPriceTool gets current market price
func (t *TradingTools) GetMarketPriceTool() Tool {
return NewTool(
"get_market_price",
"Get current market price for a trading pair from a specific trader's exchange.",
`{"trader_id": "string (required)", "symbol": "string (required) - e.g., BTCUSDT"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
// Get the underlying trader interface
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
price, err := underlyingTrader.GetMarketPrice(params.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get price: %w", err)
}
return map[string]interface{}{
"symbol": params.Symbol,
"price": price,
}, nil
},
)
}
// OpenLongTool opens a long position
func (t *TradingTools) OpenLongTool() Tool {
return NewTool(
"open_long",
"Open a long (buy) position. WARNING: This will execute a real trade!",
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.Leverage == 0 {
params.Leverage = 1
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
result, err := underlyingTrader.OpenLong(params.Symbol, params.Quantity, params.Leverage)
if err != nil {
return nil, fmt.Errorf("failed to open long: %w", err)
}
return result, nil
},
)
}
// OpenShortTool opens a short position
func (t *TradingTools) OpenShortTool() Tool {
return NewTool(
"open_short",
"Open a short (sell) position. WARNING: This will execute a real trade!",
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.Leverage == 0 {
params.Leverage = 1
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
result, err := underlyingTrader.OpenShort(params.Symbol, params.Quantity, params.Leverage)
if err != nil {
return nil, fmt.Errorf("failed to open short: %w", err)
}
return result, nil
},
)
}
// ClosePositionTool closes a position
func (t *TradingTools) ClosePositionTool() Tool {
return NewTool(
"close_position",
"Close an existing position (long or short). WARNING: This will execute a real trade!",
`{"trader_id": "string (required)", "symbol": "string (required)", "side": "string (required) - 'long' or 'short'", "quantity": "number (optional) - leave empty to close all"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Quantity float64 `json:"quantity"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
var result map[string]interface{}
if params.Side == "long" {
result, err = underlyingTrader.CloseLong(params.Symbol, params.Quantity)
} else if params.Side == "short" {
result, err = underlyingTrader.CloseShort(params.Symbol, params.Quantity)
} else {
return nil, fmt.Errorf("invalid side: %s (must be 'long' or 'short')", params.Side)
}
if err != nil {
return nil, fmt.Errorf("failed to close position: %w", err)
}
return result, nil
},
)
}
// ==================== Config Tools ====================
// ListStrategiesTool lists all strategies
func (t *TradingTools) ListStrategiesTool() Tool {
return NewTool(
"list_strategies",
"List all trading strategies configured in the system.",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
strategies, err := t.store.Strategy().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list strategies: %w", err)
}
return strategies, nil
},
)
}
// ListExchangesTool lists all exchange configurations
func (t *TradingTools) ListExchangesTool() Tool {
return NewTool(
"list_exchanges",
"List all configured exchanges (without showing API keys).",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
exchanges, err := t.store.Exchange().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list exchanges: %w", err)
}
// Remove sensitive data
var result []map[string]interface{}
for _, ex := range exchanges {
result = append(result, map[string]interface{}{
"id": ex.ID,
"name": ex.Name,
"exchange_type": ex.ExchangeType,
"type": ex.Type,
"enabled": ex.Enabled,
})
}
return result, nil
},
)
}
// ListAIModelsTool lists all AI model configurations
func (t *TradingTools) ListAIModelsTool() Tool {
return NewTool(
"list_ai_models",
"List all configured AI models (without showing API keys).",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
models, err := t.store.AIModel().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list AI models: %w", err)
}
// Remove sensitive data
var result []map[string]interface{}
for _, m := range models {
result = append(result, map[string]interface{}{
"id": m.ID,
"name": m.Name,
"provider": m.Provider,
"custom_model": m.CustomModelName,
"enabled": m.Enabled,
})
}
return result, nil
},
)
}

View File

@@ -75,7 +75,7 @@ func main() {
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err) fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Println("SUCCESS: TxClient created") fmt.Println("SUCCESS: TxClient created\n")
// Step 3: Generate auth token // Step 3: Generate auth token
fmt.Println("Step 3: Generating auth token...") fmt.Println("Step 3: Generating auth token...")

View File

@@ -1,30 +1,22 @@
<h1 align="center">NOFX — オープンソース AI トレーディング OS</h1> # NOFX - AI トレーディングシステム
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>AI 駆動金融取引のインフラストラクチャレイヤー</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md) **言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
--- ---
## AI 駆動の暗号通貨取引プラットフォーム
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
### コア機能 ### コア機能
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能 - **マルチ 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 トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡 - **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定 - **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
@@ -63,8 +55,6 @@ NOFXを使用するには以下が必要です:
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) | | **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **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 (分散型永久先物取引所) ### Perp-DEX (分散型永久先物取引所)

View File

@@ -1,30 +1,22 @@
<h1 align="center">NOFX — 오픈소스 AI 트레이딩 OS</h1> # NOFX - AI 트레이딩 시스템
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>AI 기반 금융 거래를 위한 인프라 레이어</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md) **언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
--- ---
## AI 기반 암호화폐 거래 플랫폼
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
### 핵심 기능 ### 핵심 기능
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능 - **다중 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 트레이더가 실시간으로 경쟁, 성과를 나란히 추적 - **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료 - **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
@@ -63,8 +55,6 @@ NOFX를 사용하려면 다음이 필요합니다:
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) | | **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **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 (탈중앙화 영구 선물 거래소) ### Perp-DEX (탈중앙화 영구 선물 거래소)

View File

@@ -1,30 +1,22 @@
<h1 align="center">NOFX — Open Source AI Торговая ОС</h1> # NOFX - AI Торговая Система
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>Инфраструктурный слой для AI-powered финансовой торговли</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
**Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md) **Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
--- ---
## Криптовалютная торговая платформа на базе ИИ
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
### Основные функции ### Основные функции
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время - **Мульти-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 трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок - **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс - **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
@@ -63,8 +55,6 @@
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) | | **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **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 (Децентрализованные биржи) ### Perp-DEX (Децентрализованные биржи)

View File

@@ -1,30 +1,22 @@
<h1 align="center">NOFX — Open Source AI Торгова ОС</h1> # NOFX - AI Торгова Система
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>Інфраструктурний рівень для AI-powered фінансової торгівлі</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
**Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md) **Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
--- ---
## Криптовалютна торгова платформа на базі ШІ
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
### Основні функції ### Основні функції
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли - **Мульти-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 трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч - **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс - **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
@@ -63,8 +55,6 @@
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) | | **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **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 (Децентралізовані біржі) ### Perp-DEX (Децентралізовані біржі)

View File

@@ -1,30 +1,22 @@
<h1 align="center">NOFX Hệ Điều Hành Giao Dịch AI Mã Nguồn Mở</h1> # NOFX - Hệ Thống Giao Dịch AI
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>Lớp cơ sở hạ tầng cho giao dịch tài chính AI-powered</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
**Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md) **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 ### 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 - **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 - **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 - **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 - **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
@@ -63,8 +55,6 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) | | **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) | | **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) ### Perp-DEX (Sàn Phi Tập Trung)

View File

@@ -1,21 +1,9 @@
<h1 align="center">NOFX — 开源 AI 交易操作系统</h1> # NOFX - AI 交易系统
<p align="center"> [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
<strong>AI 驱动金融交易的基础设施层</strong> [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
</p> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。 > **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
@@ -28,10 +16,14 @@
--- ---
## AI 驱动的加密货币交易平台
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案。
### 核心功能 ### 核心功能
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型 - **多 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 交易员实时竞争,并排追踪表现 - **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置 - **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
@@ -75,8 +67,6 @@
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) | | **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) | | **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 (去中心化永续交易所) ### Perp-DEX (去中心化永续交易所)

20
go.mod
View File

@@ -5,30 +5,34 @@ go 1.25.3
require ( require (
github.com/adshao/go-binance/v2 v2.8.9 github.com/adshao/go-binance/v2 v2.8.9
github.com/agiledragon/gomonkey/v2 v2.13.0 github.com/agiledragon/gomonkey/v2 v2.13.0
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
github.com/ethereum/go-ethereum v1.16.7 github.com/ethereum/go-ethereum v1.16.7
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/pquerna/otp v1.4.0 github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.26.0 github.com/sonirico/go-hyperliquid v0.26.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/net v0.43.0
gopkg.in/telebot.v3 v3.3.8
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.40.0 modernc.org/sqlite v1.40.0
) )
require ( require (
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect 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/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
@@ -40,18 +44,17 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect
github.com/elastic/go-windows v1.0.2 // indirect github.com/elastic/go-windows v1.0.2 // indirect
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
github.com/elliottech/poseidon_crypto v0.0.11 // indirect github.com/elliottech/poseidon_crypto v0.0.11 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // 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/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -64,7 +67,6 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.9.1 // indirect github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -95,16 +97,12 @@ require (
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.27.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

852
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -84,9 +84,6 @@ type GridContext struct {
// Box indicators (Donchian Channels) // Box indicators (Donchian Channels)
BoxData *market.BoxData `json:"box_data,omitempty"` BoxData *market.BoxData `json:"box_data,omitempty"`
// Grid direction (neutral, long, short, long_bias, short_bias)
CurrentDirection string `json:"current_direction,omitempty"`
} }
// ============================================================================ // ============================================================================
@@ -282,20 +279,6 @@ func buildGridUserPromptZh(ctx *GridContext) string {
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount)) sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount)) sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused)) 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") sb.WriteString("\n")
// Grid levels detail // Grid levels detail
@@ -393,20 +376,6 @@ func buildGridUserPromptEn(ctx *GridContext) string {
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount)) sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount)) sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused)) 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") sb.WriteString("\n")
// Grid levels detail // Grid levels detail

102
main.go
View File

@@ -2,6 +2,7 @@ package main
import ( import (
"nofx/api" "nofx/api"
"nofx/assistant"
"nofx/auth" "nofx/auth"
"nofx/backtest" "nofx/backtest"
"nofx/config" "nofx/config"
@@ -11,6 +12,7 @@ import (
"nofx/manager" "nofx/manager"
"nofx/mcp" "nofx/mcp"
"nofx/store" "nofx/store"
"nofx/telegram"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -136,6 +138,52 @@ func main() {
} }
}() }()
// Initialize and start Telegram bot (if configured)
var telegramBot *telegram.Bot
telegramConfig := telegram.LoadConfigFromEnv()
if telegramConfig.Token != "" {
logger.Info("🤖 Initializing Smart Trading Assistant...")
// Create AI client for the assistant
aiClient := createAssistantAIClient()
if aiClient == nil {
logger.Error("❌ No AI API key configured, Telegram bot disabled")
} else {
// Create Smart AI Agent with trading context awareness
agentConfig := assistant.DefaultAgentConfig()
smartAgent := assistant.NewSmartAgent(aiClient, agentConfig, traderManager, st)
// Register trading tools
tradingTools := assistant.NewTradingTools(traderManager, st)
smartAgent.RegisterTools(tradingTools.GetAllTools()...)
// Register strategy tools
strategyTools := assistant.NewStrategyTools(st)
smartAgent.RegisterTools(strategyTools.GetAllTools()...)
// Create and start Telegram bot
var err error
telegramBot, err = telegram.NewBot(telegramConfig, smartAgent.Agent)
if err != nil {
logger.Errorf("❌ Failed to create Telegram bot: %v", err)
} else {
// Start background monitor with alert forwarding to Telegram
smartAgent.OnAlert(func(alert assistant.Alert) {
telegramBot.BroadcastAlert(alert.Message)
})
smartAgent.StartMonitor()
go telegramBot.Start()
logger.Info("✅ Smart Trading Assistant started successfully")
logger.Info(" 📊 Real-time context injection: enabled")
logger.Info(" 🔍 Background monitoring: enabled")
logger.Info(" ⚠️ Proactive alerts: enabled")
}
}
} else {
logger.Info(" Telegram bot not configured (set TELEGRAM_BOT_TOKEN to enable)")
}
// Wait for interrupt signal // Wait for interrupt signal
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -146,6 +194,11 @@ func main() {
<-quit <-quit
logger.Info("📴 Shutdown signal received, closing system...") logger.Info("📴 Shutdown signal received, closing system...")
// Stop Telegram bot
if telegramBot != nil {
telegramBot.Stop()
}
// Stop all traders // Stop all traders
traderManager.StopAll() traderManager.StopAll()
logger.Info("✅ System shut down safely") logger.Info("✅ System shut down safely")
@@ -161,6 +214,55 @@ func newSharedMCPClient() mcp.AIClient {
return mcp.NewDeepSeekClient() return mcp.NewDeepSeekClient()
} }
// createAssistantAIClient creates an AI client for the Telegram assistant
// Supports multiple providers based on environment configuration
func createAssistantAIClient() mcp.AIClient {
// Try different providers in order of preference
// 1. DeepSeek (cost-effective, recommended)
if apiKey := os.Getenv("DEEPSEEK_API_KEY"); apiKey != "" {
client := mcp.NewDeepSeekClient()
customURL := os.Getenv("DEEPSEEK_API_URL")
customModel := os.Getenv("DEEPSEEK_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using DeepSeek AI")
return client
}
// 2. Claude
if apiKey := os.Getenv("CLAUDE_API_KEY"); apiKey != "" {
client := mcp.NewClaudeClient()
customURL := os.Getenv("CLAUDE_API_URL")
customModel := os.Getenv("CLAUDE_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using Claude AI")
return client
}
// 3. OpenAI
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
client := mcp.NewOpenAIClient()
customURL := os.Getenv("OPENAI_API_URL")
customModel := os.Getenv("OPENAI_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using OpenAI")
return client
}
// 4. Qwen
if apiKey := os.Getenv("QWEN_API_KEY"); apiKey != "" {
client := mcp.NewQwenClient()
customURL := os.Getenv("QWEN_API_URL")
customModel := os.Getenv("QWEN_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using Qwen AI")
return client
}
logger.Warn("⚠️ No AI API key configured for assistant")
return nil
}
// initInstallationID initializes the anonymous installation ID for experience improvement // initInstallationID initializes the anonymous installation ID for experience improvement
// This ID is persisted in database and used for anonymous usage statistics // This ID is persisted in database and used for anonymous usage statistics
func initInstallationID(st *store.Store) { func initInstallationID(st *store.Store) {

View File

@@ -690,13 +690,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey) traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey) traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase) 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": case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey) traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr

View File

@@ -31,7 +31,7 @@ var (
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication // 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) // 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 // Map interval string to coinank enum
var coinankInterval coinank_enum.Interval var coinankInterval coinank_enum.Interval
switch interval { switch interval {
@@ -67,45 +67,14 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
return nil, fmt.Errorf("unsupported interval: %s", interval) 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) // Call CoinAnk free/open API (no authentication required)
ctx := context.Background() ctx := context.Background()
ts := time.Now().UnixMilli() ts := time.Now().UnixMilli()
// Use "To" side to search backward from current time (get historical klines) // 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 err != nil {
// If exchange-specific data fails, fallback to Binance
if coinankExchange != coinank_enum.Binance {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
}
} else {
return nil, fmt.Errorf("CoinAnk API error: %w", err) return nil, fmt.Errorf("CoinAnk API error: %w", err)
} }
}
// Convert coinank kline format to market.Kline format // Convert coinank kline format to market.Kline format
klines := make([]Kline, len(coinankKlines)) klines := make([]Kline, len(coinankKlines))
@@ -165,13 +134,8 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
return klines, nil 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) { 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 klines3m, klines4h []Kline
var err error var err error
// Normalize symbol // Normalize symbol
@@ -180,21 +144,18 @@ func GetWithExchange(symbol, exchange string) (*Data, error) {
// Check if this is an xyz dex asset (use Hyperliquid API) // Check if this is an xyz dex asset (use Hyperliquid API)
isXyzAsset := IsXyzDexAsset(symbol) 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) // 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) // Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100) klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err) return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
} }
} else { } else {
// Use CoinAnk for regular crypto assets with exchange-specific data // Use CoinAnk for regular crypto assets
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100) klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
if err != nil { 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 // Get 4-hour K-line data
if useHyperliquidAPI { if isXyzAsset {
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100) klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err) return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
} }
} else { } else {
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100) klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
if err != nil { 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 continue
} }
} else { } else {
// Use CoinAnk for regular crypto assets (default to Binance) // Use CoinAnk for regular crypto assets
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200) klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
if err != nil { if err != nil {
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err) logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
continue continue
@@ -1107,11 +1068,6 @@ func Normalize(symbol string) string {
return "xyz:" + base 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 // For regular crypto assets
if strings.HasSuffix(symbol, "USDT") { if strings.HasSuffix(symbol, "USDT") {
return symbol return symbol
@@ -1327,7 +1283,7 @@ func GetBoxData(symbol string) (*BoxData, error) {
if IsXyzDexAsset(symbol) { if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod) klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else { } else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod) klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
} }
if err != nil { if err != nil {

View File

@@ -226,37 +226,3 @@ const (
BreakoutMid BreakoutLevel = "mid" BreakoutMid BreakoutLevel = "mid"
BreakoutLong BreakoutLevel = "long" 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
}
}

View File

@@ -105,8 +105,7 @@ func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
} }
if len(availableCoins) == 0 { if len(availableCoins) == 0 {
// Empty list is normal - just return empty slice, not an error return nil, fmt.Errorf("no available coins")
return []string{}, nil
} }
// Sort by Score descending (bubble sort) // Sort by Score descending (bubble sort)
@@ -148,7 +147,10 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
} }
} }
// Empty list is normal - just return empty slice, not an error if len(symbols) == 0 {
return nil, fmt.Errorf("no available coins")
}
return symbols, nil return symbols, nil
} }

View File

@@ -1,5 +1,3 @@
//go:build ignore
package main package main
import ( import (

View File

@@ -1,5 +1,3 @@
//go:build ignore
package main package main
import ( import (

View File

@@ -1,5 +1,3 @@
//go:build ignore
package main package main
import ( import (

View File

@@ -1,5 +1,3 @@
//go:build ignore
package main package main
import ( import (

View File

@@ -1,5 +1,3 @@
//go:build ignore
package main package main
import ( import (

View File

@@ -53,9 +53,7 @@ func (s *EquityStore) Save(snapshot *EquitySnapshot) error {
snapshot.Timestamp = snapshot.Timestamp.UTC() snapshot.Timestamp = snapshot.Timestamp.UTC()
} }
// Omit ID to let PostgreSQL sequence auto-generate it if err := s.db.Create(snapshot).Error; err != nil {
// Without this, GORM inserts ID=0 which causes duplicate key errors
if err := s.db.Omit("ID").Create(snapshot).Error; err != nil {
return fmt.Errorf("failed to save equity snapshot: %w", err) return fmt.Errorf("failed to save equity snapshot: %w", err)
} }
return nil return nil

View File

@@ -63,10 +63,6 @@ type GridConfigModel struct {
AIProvider string `json:"ai_provider" gorm:"default:deepseek"` AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"` AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
IsActive bool `json:"is_active" gorm:"default:false"` 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 { func (GridConfigModel) TableName() string {
@@ -112,11 +108,6 @@ type GridInstanceModel struct {
// Position adjustment due to breakout // Position adjustment due to breakout
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced 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"` TotalProfit float64 `json:"total_profit" gorm:"default:0"`
TotalFees float64 `json:"total_fees" gorm:"default:0"` TotalFees float64 `json:"total_fees" gorm:"default:0"`
TotalTrades int `json:"total_trades" gorm:"default:0"` TotalTrades int `json:"total_trades" gorm:"default:0"`

View File

@@ -156,8 +156,7 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000 newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000 newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
// Use 8 decimal places for price precision (crypto standard) newEntryPrice = math.Round(newEntryPrice*100) / 100
newEntryPrice = math.Round(newEntryPrice*100000000) / 100000000
newFee := pos.Fee + addFee newFee := pos.Fee + addFee
nowMs := time.Now().UTC().UnixMilli() nowMs := time.Now().UTC().UnixMilli()
@@ -188,8 +187,7 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
var newExitPrice float64 var newExitPrice float64
if newClosedQty > 0 { if newClosedQty > 0 {
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
// Use 8 decimal places for price precision (crypto standard) newExitPrice = math.Round(newExitPrice*100) / 100
newExitPrice = math.Round(newExitPrice*100000000) / 100000000
} }
nowMs := time.Now().UTC().UnixMilli() nowMs := time.Now().UTC().UnixMilli()

View File

@@ -147,8 +147,7 @@ func (pb *PositionBuilder) handleClose(
var finalExitPrice float64 var finalExitPrice float64
if totalClosed > 0 { if totalClosed > 0 {
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
// Use 8 decimal places for price precision (crypto standard) finalExitPrice = math.Round(finalExitPrice*100) / 100
finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000
} else { } else {
finalExitPrice = price finalExitPrice = price
} }

View File

@@ -81,10 +81,6 @@ type GridStrategyConfig struct {
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"` DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
// Use maker-only orders for lower fees // Use maker-only orders for lower fees
UseMakerOnly bool `json:"use_maker_only"` 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 // PromptSectionsConfig editable sections of System Prompt

490
telegram/bot.go Normal file
View File

@@ -0,0 +1,490 @@
// Package telegram provides Telegram bot integration for NOFX trading assistant
package telegram
import (
"context"
"fmt"
"nofx/assistant"
"nofx/logger"
"strconv"
"strings"
"sync"
"time"
tele "gopkg.in/telebot.v3"
)
// Bot represents the Telegram bot for NOFX
type Bot struct {
bot *tele.Bot
agent *assistant.Agent
config BotConfig
// Allowed users (for security)
allowedUsers map[int64]bool
allowedUsersLock sync.RWMutex
// Rate limiting
rateLimiter *RateLimiter
}
// BotConfig holds bot configuration
type BotConfig struct {
Token string `json:"token"`
// Polling or webhook mode
UseWebhook bool `json:"use_webhook"`
WebhookURL string `json:"webhook_url"`
WebhookPort int `json:"webhook_port"`
// Security
AllowedUserIDs []int64 `json:"allowed_user_ids"` // Empty = allow all
AdminUserIDs []int64 `json:"admin_user_ids"`
// Rate limiting
MaxMessagesPerMinute int `json:"max_messages_per_minute"`
// Language
DefaultLanguage string `json:"default_language"` // "en" or "zh"
}
// DefaultBotConfig returns default configuration
func DefaultBotConfig() BotConfig {
return BotConfig{
MaxMessagesPerMinute: 30,
DefaultLanguage: "zh",
}
}
// NewBot creates a new Telegram bot
func NewBot(config BotConfig, agent *assistant.Agent) (*Bot, error) {
if config.Token == "" {
return nil, fmt.Errorf("telegram bot token is required")
}
settings := tele.Settings{
Token: config.Token,
Poller: &tele.LongPoller{Timeout: 30 * time.Second},
}
teleBot, err := tele.NewBot(settings)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
bot := &Bot{
bot: teleBot,
agent: agent,
config: config,
allowedUsers: make(map[int64]bool),
rateLimiter: NewRateLimiter(config.MaxMessagesPerMinute),
}
// Initialize allowed users
for _, uid := range config.AllowedUserIDs {
bot.allowedUsers[uid] = true
}
// Register handlers
bot.registerHandlers()
return bot, nil
}
// Start starts the bot
func (b *Bot) Start() {
logger.Info("🤖 Starting Telegram bot...")
b.bot.Start()
}
// Stop stops the bot
func (b *Bot) Stop() {
logger.Info("🤖 Stopping Telegram bot...")
b.bot.Stop()
}
// registerHandlers sets up all message handlers
func (b *Bot) registerHandlers() {
// Middleware for access control and rate limiting
b.bot.Use(b.accessControlMiddleware)
b.bot.Use(b.rateLimitMiddleware)
// Command handlers
b.bot.Handle("/start", b.handleStart)
b.bot.Handle("/help", b.handleHelp)
b.bot.Handle("/status", b.handleStatus)
b.bot.Handle("/balance", b.handleBalance)
b.bot.Handle("/positions", b.handlePositions)
b.bot.Handle("/traders", b.handleTraders)
b.bot.Handle("/clear", b.handleClear)
// Handle all text messages (send to AI agent)
b.bot.Handle(tele.OnText, b.handleText)
// Handle callbacks (for inline keyboards)
b.bot.Handle(tele.OnCallback, b.handleCallback)
logger.Info("✅ Telegram handlers registered")
}
// accessControlMiddleware checks if user is allowed
func (b *Bot) accessControlMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
userID := c.Sender().ID
// If allowlist is empty, allow all
if len(b.config.AllowedUserIDs) == 0 {
return next(c)
}
b.allowedUsersLock.RLock()
allowed := b.allowedUsers[userID]
b.allowedUsersLock.RUnlock()
if !allowed {
logger.Warnf("⚠️ Unauthorized access attempt from user %d", userID)
return c.Send("⛔ Sorry, you are not authorized to use this bot.\n\n抱歉您没有使用此机器人的权限。")
}
return next(c)
}
}
// rateLimitMiddleware implements rate limiting
func (b *Bot) rateLimitMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
userID := c.Sender().ID
if !b.rateLimiter.Allow(userID) {
return c.Send("⏳ Please slow down. Too many messages.\n\n请稍等消息发送过于频繁。")
}
return next(c)
}
}
// ==================== Command Handlers ====================
func (b *Bot) handleStart(c tele.Context) error {
welcome := `🚀 *Welcome to NOFX Trading Assistant!*
I'm your AI-powered trading assistant. I can help you:
📊 *Monitor* - Check balances, positions, and market prices
🤖 *Manage* - Start/stop AI traders, configure strategies
💹 *Trade* - Execute trades (with confirmation)
📈 *Analyze* - Market analysis and AI debates
*Commands:*
/help - Show all commands
/status - System status
/balance - Account balances
/positions - Current positions
/traders - List AI traders
/clear - Clear conversation history
Or just chat with me in natural language!
---
🚀 *欢迎使用 NOFX 交易助手!*
我是你的 AI 交易助手,可以帮你:
📊 *监控* - 查看余额、持仓、行情
🤖 *管理* - 启停 AI 交易员、配置策略
💹 *交易* - 执行交易(需确认)
📈 *分析* - 市场分析和 AI 辩论
直接用自然语言和我对话即可!`
return c.Send(welcome, tele.ModeMarkdown)
}
func (b *Bot) handleHelp(c tele.Context) error {
help := `📖 *NOFX Trading Assistant Help*
*Commands:*
• /start - Welcome message
• /help - This help message
• /status - System overview
• /balance - Show all balances
• /positions - Show all positions
• /traders - List AI traders
• /clear - Clear conversation history
*Natural Language Examples:*
• "查看我的余额"
• "BTC 现在多少钱"
• "启动交易员 xxx"
• "帮我平掉 ETH 的多单"
• "我的持仓盈亏怎么样"
• "列出所有策略"
*Tips:*
• I'll always confirm before executing trades
• Use specific trader names/IDs for operations
• Ask me anything about your trading!`
return c.Send(help, tele.ModeMarkdown)
}
func (b *Bot) handleStatus(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "Please give me a brief system status: list all traders and their status, show total positions count.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to get status. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handleBalance(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "Show me all account balances from all running traders.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to get balances. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handlePositions(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "Show me all current positions from all running traders with P&L.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to get positions. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handleTraders(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "List all configured AI traders with their status, exchange, and AI model.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to list traders. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handleClear(c tele.Context) error {
sessionID := b.getSessionID(c)
session := b.agent.GetSession(sessionID)
session.Clear()
return c.Send("🧹 Conversation history cleared.\n\n对话历史已清除。")
}
// ==================== Message Handler ====================
func (b *Bot) handleText(c tele.Context) error {
text := strings.TrimSpace(c.Text())
if text == "" {
return nil
}
// Show typing indicator
_ = c.Notify(tele.Typing)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
sessionID := b.getSessionID(c)
// Set user info in session
session := b.agent.GetSession(sessionID)
session.SetUserInfo(
strconv.FormatInt(c.Sender().ID, 10),
c.Sender().Username,
"telegram",
)
logger.Infof("💬 [%s] %s: %s", sessionID, c.Sender().Username, text)
response, err := b.agent.Chat(ctx, sessionID, text)
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Sorry, something went wrong. Please try again.\n\n抱歉出现了问题请重试。")
}
logger.Infof("🤖 [%s] Response: %s", sessionID, truncate(response.Text, 100))
// Send response (split if too long)
return b.sendLongMessage(c, response.Text)
}
// handleCallback handles inline keyboard callbacks
func (b *Bot) handleCallback(c tele.Context) error {
data := c.Callback().Data
// Parse callback data (format: "action:param1:param2")
parts := strings.Split(data, ":")
if len(parts) == 0 {
return c.Respond()
}
action := parts[0]
switch action {
case "confirm_trade":
if len(parts) >= 2 {
// Execute the confirmed trade
return b.executeConfirmedTrade(c, parts[1:])
}
case "cancel_trade":
_ = c.Respond(&tele.CallbackResponse{Text: "Trade cancelled / 交易已取消"})
return c.Edit("❌ Trade cancelled.\n\n交易已取消。")
}
return c.Respond()
}
// ==================== Helpers ====================
func (b *Bot) getSessionID(c tele.Context) string {
return fmt.Sprintf("tg_%d", c.Chat().ID)
}
func (b *Bot) sendLongMessage(c tele.Context, text string) error {
// Telegram message limit is 4096 characters
const maxLen = 4000
if len(text) <= maxLen {
return c.Send(text, tele.ModeMarkdown)
}
// Split into chunks
for len(text) > 0 {
chunk := text
if len(chunk) > maxLen {
// Try to split at newline
idx := strings.LastIndex(text[:maxLen], "\n")
if idx > 0 {
chunk = text[:idx]
text = text[idx+1:]
} else {
chunk = text[:maxLen]
text = text[maxLen:]
}
} else {
text = ""
}
if err := c.Send(chunk, tele.ModeMarkdown); err != nil {
// Try without markdown if it fails
if err := c.Send(chunk); err != nil {
return err
}
}
}
return nil
}
func (b *Bot) executeConfirmedTrade(c tele.Context, params []string) error {
// TODO: Implement trade execution from callback
_ = c.Respond(&tele.CallbackResponse{Text: "Executing trade..."})
return c.Edit("✅ Trade executed.\n\n交易已执行。")
}
// AddAllowedUser adds a user to the allowlist
func (b *Bot) AddAllowedUser(userID int64) {
b.allowedUsersLock.Lock()
defer b.allowedUsersLock.Unlock()
b.allowedUsers[userID] = true
}
// BroadcastAlert sends an alert to all admin users
func (b *Bot) BroadcastAlert(message string) {
for _, adminID := range b.config.AdminUserIDs {
chat := &tele.Chat{ID: adminID}
_, err := b.bot.Send(chat, "🚨 "+message)
if err != nil {
logger.Errorf("Failed to send alert to admin %d: %v", adminID, err)
}
}
// If no admins configured, send to allowed users
if len(b.config.AdminUserIDs) == 0 {
for userID := range b.allowedUsers {
chat := &tele.Chat{ID: userID}
_, _ = b.bot.Send(chat, "🚨 "+message)
}
}
}
// RemoveAllowedUser removes a user from the allowlist
func (b *Bot) RemoveAllowedUser(userID int64) {
b.allowedUsersLock.Lock()
defer b.allowedUsersLock.Unlock()
delete(b.allowedUsers, userID)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// ==================== Rate Limiter ====================
// RateLimiter implements per-user rate limiting
type RateLimiter struct {
maxPerMinute int
users map[int64][]time.Time
mu sync.Mutex
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(maxPerMinute int) *RateLimiter {
return &RateLimiter{
maxPerMinute: maxPerMinute,
users: make(map[int64][]time.Time),
}
}
// Allow checks if a user is allowed to send a message
func (r *RateLimiter) Allow(userID int64) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute)
// Get user's recent messages
timestamps := r.users[userID]
// Filter out old timestamps
var recent []time.Time
for _, t := range timestamps {
if t.After(cutoff) {
recent = append(recent, t)
}
}
// Check if under limit
if len(recent) >= r.maxPerMinute {
return false
}
// Add current timestamp
recent = append(recent, now)
r.users[userID] = recent
return true
}

60
telegram/config.go Normal file
View File

@@ -0,0 +1,60 @@
package telegram
import (
"os"
"strconv"
"strings"
)
// LoadConfigFromEnv loads Telegram bot configuration from environment variables
func LoadConfigFromEnv() BotConfig {
config := DefaultBotConfig()
// Bot token (required)
config.Token = os.Getenv("TELEGRAM_BOT_TOKEN")
// Webhook settings
if webhook := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhook != "" {
config.UseWebhook = true
config.WebhookURL = webhook
}
if port := os.Getenv("TELEGRAM_WEBHOOK_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
config.WebhookPort = p
}
}
// Allowed users (comma-separated list of user IDs)
if allowedStr := os.Getenv("TELEGRAM_ALLOWED_USERS"); allowedStr != "" {
for _, idStr := range strings.Split(allowedStr, ",") {
idStr = strings.TrimSpace(idStr)
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
config.AllowedUserIDs = append(config.AllowedUserIDs, id)
}
}
}
// Admin users
if adminStr := os.Getenv("TELEGRAM_ADMIN_USERS"); adminStr != "" {
for _, idStr := range strings.Split(adminStr, ",") {
idStr = strings.TrimSpace(idStr)
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
config.AdminUserIDs = append(config.AdminUserIDs, id)
}
}
}
// Rate limiting
if rateStr := os.Getenv("TELEGRAM_RATE_LIMIT"); rateStr != "" {
if rate, err := strconv.Atoi(rateStr); err == nil {
config.MaxMessagesPerMinute = rate
}
}
// Language
if lang := os.Getenv("TELEGRAM_LANGUAGE"); lang != "" {
config.DefaultLanguage = lang
}
return config
}

View File

@@ -1,4 +1,4 @@
package aster package trader
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package aster package trader
import ( import (
"context" "context"
@@ -23,7 +23,6 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"nofx/trader/types"
) )
// AsterTrader Aster trading platform implementation // 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 // GetClosedPnL gets recent closing trades from Aster
// Note: Aster does NOT have a position history API, only trade history. // Note: Aster does NOT have a position history API, only trade history.
// This returns individual closing trades for real-time position closure detection. // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) // Filter only closing trades (realizedPnl != 0)
var records []types.ClosedPnLRecord var records []ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue 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, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -1367,7 +1366,7 @@ type AsterTradeRecord struct {
} }
// GetTrades retrieves trade history from Aster // 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 { if limit <= 0 {
limit = 500 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) body, err := t.request("GET", "/fapi/v3/userTrades", params)
if err != nil { if err != nil {
logger.Infof("⚠️ Aster userTrades API error: %v", err) logger.Infof("⚠️ Aster userTrades API error: %v", err)
return []types.TradeRecord{}, nil return []TradeRecord{}, nil
} }
var asterTrades []AsterTradeRecord var asterTrades []AsterTradeRecord
if err := json.Unmarshal(body, &asterTrades); err != nil { if err := json.Unmarshal(body, &asterTrades); err != nil {
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err) logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
return []types.TradeRecord{}, nil return []TradeRecord{}, nil
} }
// Convert to unified TradeRecord format // Convert to unified TradeRecord format
var result []types.TradeRecord var result []TradeRecord
for _, at := range asterTrades { for _, at := range asterTrades {
price, _ := strconv.ParseFloat(at.Price, 64) price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Qty, 64) qty, _ := strconv.ParseFloat(at.Qty, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64) fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := types.TradeRecord{ trade := TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10), TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol, Symbol: at.Symbol,
Side: at.Side, Side: at.Side,
@@ -1417,7 +1416,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRe
} }
// GetOpenOrders gets all open/pending orders for a symbol // GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
params := map[string]interface{}{ params := map[string]interface{}{
"symbol": symbol, "symbol": symbol,
} }
@@ -1443,13 +1442,13 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
return nil, fmt.Errorf("failed to parse open orders: %w", err) return nil, fmt.Errorf("failed to parse open orders: %w", err)
} }
var result []types.OpenOrder var result []OpenOrder
for _, order := range orders { for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64) price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64) stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQty, 64) quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID), OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol, Symbol: order.Symbol,
Side: order.Side, Side: order.Side,
@@ -1467,7 +1466,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
} }
// PlaceLimitOrder places a limit order for grid trading // PlaceLimitOrder places a limit order for grid trading
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format price and quantity to correct precision // Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(req.Symbol, req.Price) formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
if err != nil { if err != nil {
@@ -1533,7 +1532,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Limi
clientOrderID = cid clientOrderID = cid
} }
return &types.LimitOrderResult{ return &LimitOrderResult{
OrderID: orderID, OrderID: orderID,
ClientID: clientOrderID, ClientID: clientOrderID,
Symbol: req.Symbol, Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package aster package trader
import ( import (
"context" "context"
@@ -10,8 +10,6 @@ import (
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
) )
// ============================================================ // ============================================================
@@ -21,7 +19,7 @@ import (
// AsterTraderTestSuite Aster trader test suite // AsterTraderTestSuite Aster trader test suite
// Inherits TraderTestSuite and adds Aster specific mock logic // Inherits TraderTestSuite and adds Aster specific mock logic
type AsterTraderTestSuite struct { type AsterTraderTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite *TraderTestSuite // Embeds base test suite
mockServer *httptest.Server mockServer *httptest.Server
} }
@@ -193,7 +191,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
privateKey, _ := crypto.GenerateKey() privateKey, _ := crypto.GenerateKey()
// Create mock trader using mock server's URL // Create mock trader using mock server's URL
traderInstance := &AsterTrader{ trader := &AsterTrader{
ctx: context.Background(), ctx: context.Background(),
user: "0x1234567890123456789012345678901234567890", user: "0x1234567890123456789012345678901234567890",
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
@@ -204,7 +202,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
} }
// Create base suite // Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance) baseSuite := NewTraderTestSuite(t, trader)
return &AsterTraderTestSuite{ return &AsterTraderTestSuite{
TraderTestSuite: baseSuite, TraderTestSuite: baseSuite,
@@ -226,7 +224,7 @@ func (s *AsterTraderTestSuite) Cleanup() {
// TestAsterTrader_InterfaceCompliance tests interface compliance // TestAsterTrader_InterfaceCompliance tests interface compliance
func TestAsterTrader_InterfaceCompliance(t *testing.T) { 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 // TestAsterTrader_CommonInterface runs all common interface tests using test suite
@@ -279,21 +277,21 @@ func TestNewAsterTrader(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { if tt.wantError {
assert.Error(t, err) assert.Error(t, err)
if tt.errorContains != "" { if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains) assert.Contains(t, err.Error(), tt.errorContains)
} }
assert.Nil(t, at) assert.Nil(t, trader)
} else { } else {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, at) assert.NotNil(t, trader)
if at != nil { if trader != nil {
assert.Equal(t, tt.user, at.user) assert.Equal(t, tt.user, trader.user)
assert.Equal(t, tt.signer, at.signer) assert.Equal(t, tt.signer, trader.signer)
assert.NotNil(t, at.privateKey) assert.NotNil(t, trader.privateKey)
} }
} }
}) })

View File

@@ -4,21 +4,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"nofx/experience"
"nofx/kernel" "nofx/kernel"
"nofx/experience"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/store" "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" "strings"
"sync" "sync"
"time" "time"
@@ -32,7 +23,7 @@ type AutoTraderConfig struct {
AIModel string // AI model: "qwen" or "deepseek" AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection // 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) ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration // Binance API configuration
@@ -53,15 +44,6 @@ type AutoTraderConfig struct {
BitgetSecretKey string BitgetSecretKey string
BitgetPassphrase string BitgetPassphrase string
// Gate API configuration
GateAPIKey string
GateSecretKey string
// KuCoin API configuration
KuCoinAPIKey string
KuCoinSecretKey string
KuCoinPassphrase string
// Hyperliquid configuration // Hyperliquid configuration
HyperliquidPrivateKey string HyperliquidPrivateKey string
HyperliquidWalletAddr string HyperliquidWalletAddr string
@@ -242,31 +224,25 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
switch config.Exchange { switch config.Exchange {
case "binance": case "binance":
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name) 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": case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name) logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx": case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name) 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": case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name) logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase) trader = 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)
case "hyperliquid": case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) 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 { if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err) return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
} }
case "aster": case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name) 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 { if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err) return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
} }
@@ -278,7 +254,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
} }
// Lighter only supports mainnet (testnet disabled) // Lighter only supports mainnet (testnet disabled)
trader, err = lighter.NewLighterTraderV2( trader, err = NewLighterTraderV2(
config.LighterWalletAddr, config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey, config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex, config.LighterAPIKeyIndex,
@@ -387,7 +363,7 @@ func (at *AutoTrader) Run() error {
// Start Lighter order sync if using Lighter exchange // Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" { 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) 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) logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
} }
@@ -395,7 +371,7 @@ func (at *AutoTrader) Run() error {
// Start Hyperliquid order sync if using Hyperliquid exchange // Start Hyperliquid order sync if using Hyperliquid exchange
if at.exchange == "hyperliquid" { 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) 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) logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
} }
@@ -403,7 +379,7 @@ func (at *AutoTrader) Run() error {
// Start Bybit order sync if using Bybit exchange // Start Bybit order sync if using Bybit exchange
if at.exchange == "bybit" { 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) 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) logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
} }
@@ -411,7 +387,7 @@ func (at *AutoTrader) Run() error {
// Start OKX order sync if using OKX exchange // Start OKX order sync if using OKX exchange
if at.exchange == "okx" { 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) 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) logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
} }
@@ -419,7 +395,7 @@ func (at *AutoTrader) Run() error {
// Start Bitget order sync if using Bitget exchange // Start Bitget order sync if using Bitget exchange
if at.exchange == "bitget" { 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) 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) logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
} }
@@ -427,7 +403,7 @@ func (at *AutoTrader) Run() error {
// Start Aster order sync if using Aster exchange // Start Aster order sync if using Aster exchange
if at.exchange == "aster" { 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) 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) logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
} }
@@ -435,28 +411,12 @@ func (at *AutoTrader) Run() error {
// Start Binance order sync if using Binance exchange // Start Binance order sync if using Binance exchange
if at.exchange == "binance" { 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) 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) 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) ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop() defer ticker.Stop()
@@ -574,26 +534,15 @@ func (at *AutoTrader) runCycle() error {
return fmt.Errorf("failed to build trading context: %w", err) return fmt.Errorf("failed to build trading context: %w", err)
} }
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve) // 如果没有候选币种,友好提示并跳过本周期
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
at.saveEquitySnapshot(ctx)
// 如果没有候选币种,记录但不报错
if len(ctx.CandidateCoins) == 0 { if len(ctx.CandidateCoins) == 0 {
logger.Infof(" No candidate coins available, skipping this cycle") 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 return nil
} }
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
at.saveEquitySnapshot(ctx)
logger.Info(strings.Repeat("=", 70)) logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins { for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol) record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
@@ -872,19 +821,14 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
} }
// 3. Use strategy engine to get candidate coins (must have strategy engine) // 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil { if at.strategyEngine == nil {
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name) return nil, fmt.Errorf("trader has no strategy engine configured")
} else { }
coins, err := at.strategyEngine.GetCandidateCoins() candidateCoins, err := at.strategyEngine.GetCandidateCoins()
if err != nil { if err != nil {
// Log warning but don't fail - equity snapshot should still be saved return nil, fmt.Errorf("failed to get candidate coins: %w", err)
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err) }
} else {
candidateCoins = coins
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
}
}
// 4. Calculate total P&L // 4. Calculate total P&L
totalPnL := totalEquity - at.initialBalance totalPnL := totalEquity - at.initialBalance
@@ -1106,7 +1050,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
} }
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.Get(decision.Symbol)
if err != nil { if err != nil {
return err return err
} }
@@ -1223,7 +1167,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
} }
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.Get(decision.Symbol)
if err != nil { if err != nil {
return err return err
} }
@@ -1322,7 +1266,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
logger.Infof(" 🔄 Close long: %s", decision.Symbol) logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.Get(decision.Symbol)
if err != nil { if err != nil {
return err return err
} }
@@ -1386,7 +1330,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
logger.Infof(" 🔄 Close short: %s", decision.Symbol) logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.Get(decision.Symbol)
if err != nil { if err != nil {
return err return err
} }
@@ -1982,7 +1926,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it // Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// This ensures accurate data from GetTrades API and avoids duplicate records // This ensures accurate data from GetTrades API and avoids duplicate records
switch at.exchange { 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) logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return return
} }

View File

@@ -65,11 +65,6 @@ type GridState struct {
// Current regime level // Current regime level
CurrentRegimeLevel string CurrentRegimeLevel string
// Grid direction adjustment
CurrentDirection market.GridDirection
DirectionChangedAt time.Time
DirectionChangeCount int
} }
// NewGridState creates a new grid state // NewGridState creates a new grid state
@@ -78,7 +73,6 @@ func NewGridState(config *store.GridStrategyConfig) *GridState {
Config: config, Config: config,
Levels: make([]kernel.GridLevelInfo, 0), Levels: make([]kernel.GridLevelInfo, 0),
OrderBook: make(map[string]int), OrderBook: make(map[string]int),
CurrentDirection: market.GridDirectionNeutral,
} }
} }
@@ -331,17 +325,7 @@ func (at *AutoTrader) checkBoxBreakout() error {
} }
// Take action based on breakout level // Take action based on breakout level
// Use direction-aware action if enabled action := getBreakoutAction(breakoutLevel)
enableDirectionAdjust := gridConfig.EnableDirectionAdjust
action := getBreakoutActionWithDirection(breakoutLevel, enableDirectionAdjust)
// If direction adjustment action, determine the new direction
if action == BreakoutActionAdjustDirection {
box, _ := market.GetBoxData(gridConfig.Symbol)
newDirection := determineGridDirection(box, at.gridState.CurrentDirection, breakoutLevel, direction)
return at.executeDirectionAdjustment(newDirection)
}
return at.executeBreakoutAction(action) return at.executeBreakoutAction(action)
} }
@@ -374,38 +358,11 @@ func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
logger.Infof("Failed to cancel orders: %v", err) logger.Infof("Failed to cancel orders: %v", err)
} }
return at.closeAllPositions() return at.closeAllPositions()
case BreakoutActionAdjustDirection:
// Direction adjustment is handled separately via executeDirectionAdjustment
// This case should not be reached, but handle gracefully
logger.Infof("Direction adjustment action received via executeBreakoutAction")
return nil
} }
return nil return nil
} }
// executeDirectionAdjustment handles grid direction changes based on box breakout
func (at *AutoTrader) executeDirectionAdjustment(newDirection market.GridDirection) error {
at.gridState.mu.RLock()
oldDirection := at.gridState.CurrentDirection
at.gridState.mu.RUnlock()
if oldDirection == newDirection {
return nil // No change needed
}
logger.Infof("[Grid] Direction adjustment: %s → %s", oldDirection, newDirection)
// Cancel existing orders before adjusting
if err := at.cancelAllGridOrders(); err != nil {
logger.Warnf("[Grid] Failed to cancel orders during direction adjustment: %v", err)
}
// Apply the new direction
return at.adjustGridDirection(newDirection)
}
// closeAllPositions closes all open positions for the grid symbol // closeAllPositions closes all open positions for the grid symbol
func (at *AutoTrader) closeAllPositions() error { func (at *AutoTrader) closeAllPositions() error {
gridConfig := at.config.StrategyConfig.GridConfig gridConfig := at.config.StrategyConfig.GridConfig
@@ -453,16 +410,10 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
breakoutLevel := at.gridState.BreakoutLevel breakoutLevel := at.gridState.BreakoutLevel
isPaused := at.gridState.IsPaused isPaused := at.gridState.IsPaused
positionReduction := at.gridState.PositionReductionPct positionReduction := at.gridState.PositionReductionPct
currentDirection := at.gridState.CurrentDirection
at.gridState.mu.RUnlock() at.gridState.mu.RUnlock()
// Only check if we had a breakout or non-neutral direction // Only check if we had a breakout
needsRecoveryCheck := breakoutLevel != string(market.BreakoutNone) || if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused {
positionReduction != 0 ||
isPaused ||
(gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral)
if !needsRecoveryCheck {
return nil return nil
} }
@@ -485,18 +436,6 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
at.gridState.mu.Unlock() at.gridState.mu.Unlock()
} }
// Check for direction recovery toward neutral (if direction adjustment is enabled)
if gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral {
if shouldRecoverDirection(box, currentDirection) {
newDirection := determineRecoveryDirection(box.CurrentPrice, box, currentDirection)
if newDirection != currentDirection {
logger.Infof("[Grid] Direction recovery: %s → %s (price back in short box)",
currentDirection, newDirection)
at.adjustGridDirection(newDirection)
}
}
}
return nil return nil
} }
@@ -631,128 +570,6 @@ func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.G
} }
at.gridState.Levels = levels at.gridState.Levels = levels
// Apply direction-based side assignment if enabled
if config.EnableDirectionAdjust {
at.applyGridDirection(currentPrice)
}
}
// applyGridDirection adjusts grid level sides based on the current direction
// This redistributes buy/sell levels according to the direction bias ratio
func (at *AutoTrader) applyGridDirection(currentPrice float64) {
config := at.gridState.Config
direction := at.gridState.CurrentDirection
// Get bias ratio from config, default to 0.7 (70%/30%)
biasRatio := config.DirectionBiasRatio
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7
}
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
// Calculate how many levels should be buy vs sell based on direction
totalLevels := len(at.gridState.Levels)
targetBuyLevels := int(float64(totalLevels) * buyRatio)
// For neutral: use price-based assignment (buy below, sell above)
if direction == market.GridDirectionNeutral {
for i := range at.gridState.Levels {
if at.gridState.Levels[i].Price <= currentPrice {
at.gridState.Levels[i].Side = "buy"
} else {
at.gridState.Levels[i].Side = "sell"
}
}
return
}
// For long/long_bias: more buy levels
// For short/short_bias: more sell levels
switch direction {
case market.GridDirectionLong:
// 100% buy - all levels are buy
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "buy"
}
case market.GridDirectionShort:
// 100% sell - all levels are sell
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "sell"
}
case market.GridDirectionLongBias, market.GridDirectionShortBias:
// Assign sides based on position relative to current price
// For long_bias: keep all below as buy, convert some above to buy
// For short_bias: keep all above as sell, convert some below to sell
buyCount := 0
sellCount := 0
for i := range at.gridState.Levels {
needMoreBuys := buyCount < targetBuyLevels
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
if at.gridState.Levels[i].Price <= currentPrice {
// Level below or at current price
if needMoreBuys {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else {
at.gridState.Levels[i].Side = "sell"
sellCount++
}
} else {
// Level above current price
if needMoreSells && direction == market.GridDirectionShortBias {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else if needMoreBuys && direction == market.GridDirectionLongBias {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else if needMoreSells {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else {
at.gridState.Levels[i].Side = "buy"
buyCount++
}
}
}
}
logger.Infof("[Grid] Applied direction %s: buy_ratio=%.0f%%, levels reconfigured",
direction, buyRatio*100)
}
// adjustGridDirection handles runtime direction adjustment when breakout is detected
func (at *AutoTrader) adjustGridDirection(newDirection market.GridDirection) error {
at.gridState.mu.Lock()
defer at.gridState.mu.Unlock()
oldDirection := at.gridState.CurrentDirection
if oldDirection == newDirection {
return nil // No change needed
}
at.gridState.CurrentDirection = newDirection
at.gridState.DirectionChangedAt = time.Now()
at.gridState.DirectionChangeCount++
logger.Infof("[Grid] Direction changed: %s → %s (change count: %d)",
oldDirection, newDirection, at.gridState.DirectionChangeCount)
// Get current price for recalculation
currentPrice, err := at.trader.GetMarketPrice(at.gridState.Config.Symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
// Reapply direction to grid levels
at.applyGridDirection(currentPrice)
return nil
} }
// RunGridCycle executes one grid trading cycle // RunGridCycle executes one grid trading cycle
@@ -1553,85 +1370,6 @@ func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *s
} }
at.gridState.Levels = levels at.gridState.Levels = levels
// Apply direction-based side assignment if enabled (note: caller holds lock)
if config.EnableDirectionAdjust {
at.applyGridDirectionLocked(currentPrice)
}
}
// applyGridDirectionLocked adjusts grid level sides based on the current direction (caller must hold lock)
func (at *AutoTrader) applyGridDirectionLocked(currentPrice float64) {
config := at.gridState.Config
direction := at.gridState.CurrentDirection
// Get bias ratio from config, default to 0.7 (70%/30%)
biasRatio := config.DirectionBiasRatio
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7
}
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
// For neutral: use price-based assignment (buy below, sell above)
if direction == market.GridDirectionNeutral {
for i := range at.gridState.Levels {
if at.gridState.Levels[i].Price <= currentPrice {
at.gridState.Levels[i].Side = "buy"
} else {
at.gridState.Levels[i].Side = "sell"
}
}
return
}
totalLevels := len(at.gridState.Levels)
targetBuyLevels := int(float64(totalLevels) * buyRatio)
switch direction {
case market.GridDirectionLong:
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "buy"
}
case market.GridDirectionShort:
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "sell"
}
case market.GridDirectionLongBias, market.GridDirectionShortBias:
buyCount := 0
sellCount := 0
for i := range at.gridState.Levels {
needMoreBuys := buyCount < targetBuyLevels
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
if at.gridState.Levels[i].Price <= currentPrice {
if needMoreBuys {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else {
at.gridState.Levels[i].Side = "sell"
sellCount++
}
} else {
if needMoreSells && direction == market.GridDirectionShortBias {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else if needMoreBuys && direction == market.GridDirectionLongBias {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else if needMoreSells {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else {
at.gridState.Levels[i].Side = "buy"
buyCount++
}
}
}
}
} }
// GridRiskInfo contains risk information for frontend display // GridRiskInfo contains risk information for frontend display
@@ -1659,11 +1397,6 @@ type GridRiskInfo struct {
BreakoutLevel string `json:"breakout_level"` BreakoutLevel string `json:"breakout_level"`
BreakoutDirection string `json:"breakout_direction"` BreakoutDirection string `json:"breakout_direction"`
// Grid direction
CurrentGridDirection string `json:"current_grid_direction"`
DirectionChangeCount int `json:"direction_change_count"`
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
} }
// GetGridRiskInfo returns current risk information for frontend display // GetGridRiskInfo returns current risk information for frontend display
@@ -1780,10 +1513,6 @@ func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
BreakoutLevel: at.gridState.BreakoutLevel, BreakoutLevel: at.gridState.BreakoutLevel,
BreakoutDirection: at.gridState.BreakoutDirection, BreakoutDirection: at.gridState.BreakoutDirection,
CurrentGridDirection: string(at.gridState.CurrentDirection),
DirectionChangeCount: at.gridState.DirectionChangeCount,
EnableDirectionAdjust: gridConfig.EnableDirectionAdjust,
} }
} }

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"os" "os"

View File

@@ -1,4 +1,4 @@
package binance package trader
import ( import (
"context" "context"
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"nofx/hook" "nofx/hook"
"nofx/logger" "nofx/logger"
"nofx/trader/types"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -719,7 +718,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
// PlaceLimitOrder places a limit order for grid trading // PlaceLimitOrder places a limit order for grid trading
// This implements the GridTrader interface for FuturesTrader // This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format quantity to correct precision // Format quantity to correct precision
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity) quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil { if err != nil {
@@ -771,7 +770,7 @@ func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Li
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d", logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID) req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
return &types.LimitOrderResult{ return &LimitOrderResult{
OrderID: fmt.Sprintf("%d", order.OrderID), OrderID: fmt.Sprintf("%d", order.OrderID),
ClientID: order.ClientOrderID, ClientID: order.ClientOrderID,
Symbol: order.Symbol, Symbol: order.Symbol,
@@ -897,8 +896,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
} }
// GetOpenOrders gets all open/pending orders for a symbol // GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []types.OpenOrder var result []OpenOrder
// 1. Get legacy open orders // 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService(). orders, err := t.client.NewListOpenOrdersService().
@@ -914,7 +913,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64) stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64) quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID), OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol, Symbol: order.Symbol,
Side: string(order.Side), Side: string(order.Side),
@@ -937,7 +936,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error)
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64) triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64) quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId), OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol, Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side), Side: string(algoOrder.Side),
@@ -1248,14 +1247,14 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
// Note: Binance does NOT have a position history API, only trade history. // 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. // 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. // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord // Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
var records []types.ClosedPnLRecord var records []ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue // Skip opening trades continue // Skip opening trades
@@ -1284,7 +1283,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Cl
} }
} }
records = append(records, types.ClosedPnLRecord{ records = append(records, ClosedPnLRecord{
Symbol: trade.Symbol, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -1305,7 +1304,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Cl
// GetTrades retrieves trade history from Binance Futures using Income API // GetTrades retrieves trade history from Binance Futures using Income API
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead // 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 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -1323,7 +1322,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
return nil, fmt.Errorf("failed to get income history: %w", err) return nil, fmt.Errorf("failed to get income history: %w", err)
} }
var trades []types.TradeRecord var trades []TradeRecord
for _, income := range incomes { for _, income := range incomes {
pnl, _ := strconv.ParseFloat(income.Income, 64) pnl, _ := strconv.ParseFloat(income.Income, 64)
if pnl == 0 { if pnl == 0 {
@@ -1332,7 +1331,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
// Income API doesn't provide full trade details, create a minimal record // Income API doesn't provide full trade details, create a minimal record
// This is mainly used for detecting recent closures, not historical reconstruction // This is mainly used for detecting recent closures, not historical reconstruction
trade := types.TradeRecord{ trade := TradeRecord{
TradeID: strconv.FormatInt(income.TranID, 10), TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol, Symbol: income.Symbol,
RealizedPnL: pnl, RealizedPnL: pnl,
@@ -1348,7 +1347,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
// GetTradesForSymbol retrieves trade history for a specific symbol // GetTradesForSymbol retrieves trade history for a specific symbol
// This is more reliable than using Income API which may have delays // 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 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -1365,14 +1364,14 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err) 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 { for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64) price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64) qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64) fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := types.TradeRecord{ trade := TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10), TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol, Symbol: at.Symbol,
Side: string(at.Side), Side: string(at.Side),
@@ -1391,7 +1390,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID // 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 // 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 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -1408,14 +1407,14 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err) 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 { for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64) price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64) qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64) fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := types.TradeRecord{ trade := TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10), TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol, Symbol: at.Symbol,
Side: string(at.Side), Side: string(at.Side),

View File

@@ -1,4 +1,4 @@
package binance package trader
import ( import (
"encoding/json" "encoding/json"
@@ -11,8 +11,6 @@ import (
"github.com/adshao/go-binance/v2/futures" "github.com/adshao/go-binance/v2/futures"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
) )
// ============================================================ // ============================================================
@@ -22,7 +20,7 @@ import (
// BinanceFuturesTestSuite Binance Futures trader test suite // BinanceFuturesTestSuite Binance Futures trader test suite
// Inherits TraderTestSuite and adds Binance Futures specific mock logic // Inherits TraderTestSuite and adds Binance Futures specific mock logic
type BinanceFuturesTestSuite struct { type BinanceFuturesTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite *TraderTestSuite // Embeds base test suite
mockServer *httptest.Server mockServer *httptest.Server
} }
@@ -272,13 +270,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
client.HTTPClient = mockServer.Client() client.HTTPClient = mockServer.Client()
// Create FuturesTrader // Create FuturesTrader
traderInstance := &FuturesTrader{ trader := &FuturesTrader{
client: client, client: client,
cacheDuration: 0, // disable cache for testing cacheDuration: 0, // disable cache for testing
} }
// Create base suite // Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance) baseSuite := NewTraderTestSuite(t, trader)
return &BinanceFuturesTestSuite{ return &BinanceFuturesTestSuite{
TraderTestSuite: baseSuite, TraderTestSuite: baseSuite,
@@ -300,7 +298,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() {
// TestFuturesTrader_InterfaceCompliance tests interface compliance // TestFuturesTrader_InterfaceCompliance tests interface compliance
func TestFuturesTrader_InterfaceCompliance(t *testing.T) { 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 // TestFuturesTrader_CommonInterface runs all common interface tests using test suite
@@ -345,20 +343,20 @@ func TestNewFuturesTrader(t *testing.T) {
defer mockServer.Close() defer mockServer.Close()
// Test successful creation // 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 // Modify client to use mock server
t1.client.BaseURL = mockServer.URL trader.client.BaseURL = mockServer.URL
t1.client.HTTPClient = mockServer.Client() trader.client.HTTPClient = mockServer.Client()
assert.NotNil(t, t1) assert.NotNil(t, trader)
assert.NotNil(t, t1.client) assert.NotNil(t, trader.client)
assert.Equal(t, 15*time.Second, t1.cacheDuration) assert.Equal(t, 15*time.Second, trader.cacheDuration)
} }
// TestCalculatePositionSize tests position size calculation // TestCalculatePositionSize tests position size calculation
func TestCalculatePositionSize(t *testing.T) { func TestCalculatePositionSize(t *testing.T) {
ft := &FuturesTrader{} trader := &FuturesTrader{}
tests := []struct { tests := []struct {
name string name string
@@ -396,7 +394,7 @@ func TestCalculatePositionSize(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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") assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect")
}) })
} }

View File

@@ -1,11 +1,10 @@
package binance package trader
import ( import (
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/store" "nofx/store"
"nofx/trader/types"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -127,11 +126,11 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols) 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) // 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 var failedSymbols []string
apiCalls := 0 apiCalls := 0
for _, symbol := range changedSymbols { for _, symbol := range changedSymbols {
var trades []types.TradeRecord var trades []TradeRecord
var queryErr error var queryErr error
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 { if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {

View File

@@ -1,4 +1,4 @@
package binance package trader
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package binance package trader
import ( import (
"nofx/store" "nofx/store"

View File

@@ -1,4 +1,4 @@
package binance package trader
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package bitget package trader
import ( import (
"encoding/json" "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) return nil, fmt.Errorf("failed to get fill history: %w", err)
} }
var resp struct {
// Bitget fill structure - supports both one-way and hedge mode FillList []struct {
type BitgetFill struct {
TradeID string `json:"tradeId"` TradeID string `json:"tradeId"`
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
OrderID string `json:"orderId"` OrderID string `json:"orderId"`
Side string `json:"side"` // buy, sell Side string `json:"side"` // buy, sell
Price string `json:"price"` // Fill price Price string `json:"price"` // Fill price
BaseVolume string `json:"baseVolume"` // Fill size in base currency 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 Profit string `json:"profit"` // Realized PnL
CTime string `json:"cTime"` // Fill time (ms) CTime string `json:"cTime"` // Fill time (ms)
TradeSide string `json:"tradeSide"` // one-way: buy_single/sell_single, hedge: open/close TradeSide string `json:"tradeSide"` // open, close
FeeDetail []struct { } `json:"fillList"`
FeeCoin string `json:"feeCoin"`
TotalFee string `json:"totalFee"`
} `json:"feeDetail"`
} }
// Try parsing as wrapped response first (fillList field) if err := json.Unmarshal(data, &resp); err != nil {
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))
return nil, fmt.Errorf("failed to parse fills: %w", err) 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) fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64) fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
fee, _ := strconv.ParseFloat(fill.Fee, 64)
profit, _ := strconv.ParseFloat(fill.Profit, 64) profit, _ := strconv.ParseFloat(fill.Profit, 64)
cTime, _ := strconv.ParseInt(fill.CTime, 10, 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 // Determine order action based on side and tradeSide
// Bitget one-way mode: buy_single (open long), sell_single (close long) // Bitget one-way mode:
// Bitget hedge mode: open + buy = open_long, close + sell = close_long // - buy + open = open long
// - sell + open = open short
// - sell + close = close long
// - buy + close = close short
orderAction := "open_long" orderAction := "open_long"
side := strings.ToLower(fill.Side) side := strings.ToLower(fill.Side)
tradeSide := strings.ToLower(fill.TradeSide) tradeSide := strings.ToLower(fill.TradeSide)
// One-way position mode (buy_single/sell_single) if tradeSide == "open" {
if tradeSide == "buy_single" {
orderAction = "open_long"
} else if tradeSide == "sell_single" {
orderAction = "close_long"
} else if tradeSide == "open" {
// Hedge mode: open
if side == "buy" { if side == "buy" {
orderAction = "open_long" orderAction = "open_long"
} else { } else {
orderAction = "open_short" orderAction = "open_short"
} }
} else if tradeSide == "close" { } else if tradeSide == "close" {
// Hedge mode: close
if side == "sell" { if side == "sell" {
orderAction = "close_long" orderAction = "close_long"
} else { } else {
@@ -138,8 +108,8 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
Side: fill.Side, Side: fill.Side,
FillPrice: fillPrice, FillPrice: fillPrice,
FillQty: fillQty, FillQty: fillQty,
Fee: -fee, // Bitget returns negative fee, convert to positive Fee: -fee, // Bitget returns negative fee
FeeAsset: feeAsset, FeeAsset: fill.FeeCcy,
ExecTime: time.UnixMilli(cTime).UTC(), ExecTime: time.UnixMilli(cTime).UTC(),
ProfitLoss: profit, ProfitLoss: profit,
OrderType: "MARKET", OrderType: "MARKET",

View File

@@ -1,4 +1,4 @@
package bitget package trader
import ( import (
"bytes" "bytes"
@@ -14,7 +14,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"nofx/trader/types"
) )
// Bitget API endpoints (V2) // Bitget API endpoints (V2)
@@ -1014,7 +1013,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string
} }
// GetClosedPnL retrieves closed position PnL records // 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 { if limit <= 0 {
limit = 100 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) 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 { for _, pos := range resp.List {
record := types.ClosedPnLRecord{ record := ClosedPnLRecord{
Symbol: pos.Symbol, Symbol: pos.Symbol,
Side: pos.HoldSide, Side: pos.HoldSide,
} }
@@ -1099,9 +1098,9 @@ func genBitgetClientOid() string {
} }
// GetOpenOrders gets all open/pending orders for a symbol // GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
symbol = t.convertSymbol(symbol) symbol = t.convertSymbol(symbol)
var result []types.OpenOrder var result []OpenOrder
// 1. Get pending limit orders // 1. Get pending limit orders
params := map[string]interface{}{ params := map[string]interface{}{
@@ -1136,7 +1135,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
side := strings.ToUpper(order.Side) side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide) positionSide := strings.ToUpper(order.PosSide)
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: order.OrderId, OrderID: order.OrderId,
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
@@ -1152,10 +1151,9 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
} }
// 2. Get pending plan orders (stop-loss/take-profit) // 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{}{ planParams := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES", "productType": "USDT-FUTURES",
"planType": "profit_loss",
} }
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams) planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
@@ -1169,49 +1167,29 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
Side string `json:"side"` Side string `json:"side"`
PosSide string `json:"posSide"` PosSide string `json:"posSide"`
PlanType string `json:"planType"` // pos_loss, pos_profit PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
TriggerPrice string `json:"triggerPrice"` TriggerPrice string `json:"triggerPrice"`
StopLossTriggerPrice string `json:"stopLossTriggerPrice"`
StopSurplusTriggerPrice string `json:"stopSurplusTriggerPrice"`
Size string `json:"size"` Size string `json:"size"`
PlanStatus string `json:"planStatus"` State string `json:"state"`
} `json:"entrustedList"` } `json:"entrustedList"`
} }
if err := json.Unmarshal(planData, &planOrders); err == nil { if err := json.Unmarshal(planData, &planOrders); err == nil {
for _, order := range planOrders.EntrustedList { for _, order := range planOrders.EntrustedList {
// Filter by symbol if specified triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
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) quantity, _ := strconv.ParseFloat(order.Size, 64)
side := strings.ToUpper(order.Side) side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide) positionSide := strings.ToUpper(order.PosSide)
result = append(result, types.OpenOrder{ // Map Bitget plan type to order type
orderType := "STOP_MARKET"
if order.PlanType == "profit_plan" {
orderType = "TAKE_PROFIT_MARKET"
}
result = append(result, OpenOrder{
OrderID: order.OrderId, OrderID: order.OrderId,
Symbol: order.Symbol, Symbol: symbol,
Side: side, Side: side,
PositionSide: positionSide, PositionSide: positionSide,
Type: orderType, Type: orderType,
@@ -1230,7 +1208,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading // PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface // Implements GridTrader interface
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
symbol := t.convertSymbol(req.Symbol) symbol := t.convertSymbol(req.Symbol)
// Set leverage if specified // Set leverage if specified
@@ -1286,7 +1264,7 @@ func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Lim
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s", logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
symbol, side, req.Price, order.OrderId) symbol, side, req.Price, order.OrderId)
return &types.LimitOrderResult{ return &LimitOrderResult{
OrderID: order.OrderId, OrderID: order.OrderId,
ClientID: order.ClientOid, ClientID: order.ClientOid,
Symbol: req.Symbol, Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package bybit package trader
import ( import (
"crypto/hmac" "crypto/hmac"

View File

@@ -1,4 +1,4 @@
package bybit package trader
import ( import (
"context" "context"
@@ -17,7 +17,6 @@ import (
"time" "time"
bybit "github.com/bybit-exchange/bybit.go.api" bybit "github.com/bybit-exchange/bybit.go.api"
"nofx/trader/types"
) )
// BybitTrader Bybit USDT Perpetual Futures Trader // 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 // 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 // The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
return t.getClosedPnLViaHTTP(startTime, limit) return t.getClosedPnLViaHTTP(startTime, limit)
} }
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing // 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 // Build query string
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit) queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams 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 // 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{}) data, ok := resultData.(map[string]interface{})
if !ok { if !ok {
return nil, fmt.Errorf("invalid result format") return nil, fmt.Errorf("invalid result format")
} }
list, _ := data["list"].([]interface{}) list, _ := data["list"].([]interface{})
var records []types.ClosedPnLRecord var records []ClosedPnLRecord
for _, item := range list { for _, item := range list {
pnl, ok := item.(map[string]interface{}) pnl, ok := item.(map[string]interface{})
@@ -1024,7 +1023,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.Clos
normalizedSide = "short" normalizedSide = "short"
} }
record := types.ClosedPnLRecord{ record := ClosedPnLRecord{
Symbol: symbol, Symbol: symbol,
Side: normalizedSide, Side: normalizedSide,
EntryPrice: avgEntryPrice, EntryPrice: avgEntryPrice,
@@ -1047,8 +1046,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.Clos
} }
// GetOpenOrders gets all open/pending orders for a symbol // GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []types.OpenOrder var result []OpenOrder
// Get conditional orders (stop-loss, take-profit) // Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{ params := map[string]interface{}{
@@ -1089,7 +1088,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
displayType = stopOrderType displayType = stopOrderType
} }
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: orderId, OrderID: orderId,
Symbol: sym, Symbol: sym,
Side: side, Side: side,
@@ -1109,7 +1108,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading // PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface // Implements GridTrader interface
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format quantity // Format quantity
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity) qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil { if err != nil {
@@ -1170,7 +1169,7 @@ func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Limi
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s", logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
req.Symbol, side, priceStr, qtyStr, orderID) req.Symbol, side, priceStr, qtyStr, orderID)
return &types.LimitOrderResult{ return &LimitOrderResult{
OrderID: orderID, OrderID: orderID,
ClientID: req.ClientID, ClientID: req.ClientID,
Symbol: req.Symbol, Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package bybit package trader
import ( import (
"encoding/json" "encoding/json"
@@ -9,8 +9,6 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
) )
// ============================================================ // ============================================================
@@ -20,7 +18,7 @@ import (
// BybitTraderTestSuite Bybit trader test suite // BybitTraderTestSuite Bybit trader test suite
// Inherits TraderTestSuite and adds Bybit-specific mock logic // Inherits TraderTestSuite and adds Bybit-specific mock logic
type BybitTraderTestSuite struct { type BybitTraderTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite *TraderTestSuite // Embeds base test suite
mockServer *httptest.Server mockServer *httptest.Server
} }
@@ -68,10 +66,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
})) }))
// Create real Bybit trader (for interface compliance testing) // 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 // Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance) baseSuite := NewTraderTestSuite(t, trader)
return &BybitTraderTestSuite{ return &BybitTraderTestSuite{
TraderTestSuite: baseSuite, TraderTestSuite: baseSuite,
@@ -93,7 +91,7 @@ func (s *BybitTraderTestSuite) Cleanup() {
// TestBybitTrader_InterfaceCompliance Test interface compliance // TestBybitTrader_InterfaceCompliance Test interface compliance
func TestBybitTrader_InterfaceCompliance(t *testing.T) { 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
bt := NewBybitTrader(tt.apiKey, tt.secretKey) trader := NewBybitTrader(tt.apiKey, tt.secretKey)
if tt.wantNil { if tt.wantNil {
assert.Nil(t, bt) assert.Nil(t, trader)
} else { } else {
assert.NotNil(t, bt) assert.NotNil(t, trader)
assert.NotNil(t, bt.client) assert.NotNil(t, trader.client)
} }
}) })
} }
@@ -178,7 +176,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) {
// TestBybitTrader_FormatQuantity Test quantity formatting // TestBybitTrader_FormatQuantity Test quantity formatting
func TestBybitTrader_FormatQuantity(t *testing.T) { func TestBybitTrader_FormatQuantity(t *testing.T) {
bt := NewBybitTrader("test", "test") trader := NewBybitTrader("test", "test")
tests := []struct { tests := []struct {
name string name string
@@ -212,7 +210,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { if tt.hasError {
assert.Error(t, err) assert.Error(t, err)
} else { } else {
@@ -337,19 +335,19 @@ func convertBybitSide(side string) string {
// TestBybitTrader_CategoryLinear Test using only linear category // TestBybitTrader_CategoryLinear Test using only linear category
func TestBybitTrader_CategoryLinear(t *testing.T) { func TestBybitTrader_CategoryLinear(t *testing.T) {
// Bybit trader should only use linear category (USDT perpetual contracts) // Bybit trader should only use linear category (USDT perpetual contracts)
bt := NewBybitTrader("test", "test") trader := NewBybitTrader("test", "test")
assert.NotNil(t, bt) assert.NotNil(t, trader)
// Verify default configuration // Verify default configuration
assert.NotNil(t, bt.client) assert.NotNil(t, trader.client)
} }
// TestBybitTrader_CacheDuration Test cache duration // TestBybitTrader_CacheDuration Test cache duration
func TestBybitTrader_CacheDuration(t *testing.T) { func TestBybitTrader_CacheDuration(t *testing.T) {
bt := NewBybitTrader("test", "test") trader := NewBybitTrader("test", "test")
// Verify default cache time is 15 seconds // Verify default cache time is 15 seconds
assert.Equal(t, 15*time.Second, bt.cacheDuration) assert.Equal(t, 15*time.Second, trader.cacheDuration)
} }
// ============================================================ // ============================================================

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -194,119 +194,3 @@ func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
return BreakoutActionNone 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
}

View File

@@ -120,223 +120,3 @@ func TestGetBreakoutAction(t *testing.T) {
}) })
} }
} }
// ============================================================================
// Grid Direction Tests
// ============================================================================
func TestGetBuySellRatio(t *testing.T) {
tests := []struct {
name string
direction market.GridDirection
biasRatio float64
wantBuy float64
wantSell float64
}{
{"neutral", market.GridDirectionNeutral, 0.7, 0.5, 0.5},
{"long", market.GridDirectionLong, 0.7, 1.0, 0.0},
{"short", market.GridDirectionShort, 0.7, 0.0, 1.0},
{"long_bias_default", market.GridDirectionLongBias, 0.7, 0.7, 0.3},
{"short_bias_default", market.GridDirectionShortBias, 0.7, 0.3, 0.7},
{"long_bias_custom", market.GridDirectionLongBias, 0.8, 0.8, 0.2},
{"short_bias_custom", market.GridDirectionShortBias, 0.8, 0.2, 0.8},
{"invalid_bias_uses_default", market.GridDirectionLongBias, 0, 0.7, 0.3},
{"negative_bias_uses_default", market.GridDirectionLongBias, -1, 0.7, 0.3},
}
const tolerance = 0.0001
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buy, sell := tt.direction.GetBuySellRatio(tt.biasRatio)
buyDiff := buy - tt.wantBuy
sellDiff := sell - tt.wantSell
if buyDiff < -tolerance || buyDiff > tolerance || sellDiff < -tolerance || sellDiff > tolerance {
t.Errorf("GetBuySellRatio(%v, %v) = (%v, %v), want (%v, %v)",
tt.direction, tt.biasRatio, buy, sell, tt.wantBuy, tt.wantSell)
}
})
}
}
func TestDetermineGridDirection(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95,
}
tests := []struct {
name string
currentDirection market.GridDirection
breakoutLevel market.BreakoutLevel
direction string
expected market.GridDirection
}{
// Short box breakouts
{
name: "short_breakout_up_neutral",
currentDirection: market.GridDirectionNeutral,
breakoutLevel: market.BreakoutShort,
direction: "up",
expected: market.GridDirectionLongBias,
},
{
name: "short_breakout_down_neutral",
currentDirection: market.GridDirectionNeutral,
breakoutLevel: market.BreakoutShort,
direction: "down",
expected: market.GridDirectionShortBias,
},
// Mid box breakouts
{
name: "mid_breakout_up",
currentDirection: market.GridDirectionLongBias,
breakoutLevel: market.BreakoutMid,
direction: "up",
expected: market.GridDirectionLong,
},
{
name: "mid_breakout_down",
currentDirection: market.GridDirectionShortBias,
breakoutLevel: market.BreakoutMid,
direction: "down",
expected: market.GridDirectionShort,
},
// Long box breakout - maintains current (emergency handling)
{
name: "long_breakout_maintains",
currentDirection: market.GridDirectionLong,
breakoutLevel: market.BreakoutLong,
direction: "up",
expected: market.GridDirectionLong,
},
// No breakout - tests recovery logic
{
name: "no_breakout_neutral_stays",
currentDirection: market.GridDirectionNeutral,
breakoutLevel: market.BreakoutNone,
direction: "",
expected: market.GridDirectionNeutral,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineGridDirection(box, tt.currentDirection, tt.breakoutLevel, tt.direction)
if result != tt.expected {
t.Errorf("determineGridDirection() = %v, want %v", result, tt.expected)
}
})
}
}
func TestDetermineRecoveryDirection(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95, // Inside short box
}
tests := []struct {
name string
price float64
currentDirection market.GridDirection
expected market.GridDirection
}{
// Inside short box - should recover
{"long_to_long_bias", 95, market.GridDirectionLong, market.GridDirectionLongBias},
{"long_bias_to_neutral", 95, market.GridDirectionLongBias, market.GridDirectionNeutral},
{"short_to_short_bias", 95, market.GridDirectionShort, market.GridDirectionShortBias},
{"short_bias_to_neutral", 95, market.GridDirectionShortBias, market.GridDirectionNeutral},
{"neutral_stays_neutral", 95, market.GridDirectionNeutral, market.GridDirectionNeutral},
// Outside short box - should maintain
{"long_outside_stays", 101, market.GridDirectionLong, market.GridDirectionLong},
{"short_outside_stays", 89, market.GridDirectionShort, market.GridDirectionShort},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineRecoveryDirection(tt.price, box, tt.currentDirection)
if result != tt.expected {
t.Errorf("determineRecoveryDirection(%v, %v) = %v, want %v",
tt.price, tt.currentDirection, result, tt.expected)
}
})
}
}
func TestGetBreakoutActionWithDirection(t *testing.T) {
tests := []struct {
name string
level market.BreakoutLevel
enableDirectionAdjust bool
expected BreakoutAction
}{
// Direction adjustment disabled - original behavior
{"short_disabled", market.BreakoutShort, false, BreakoutActionReducePosition},
{"mid_disabled", market.BreakoutMid, false, BreakoutActionPauseGrid},
{"long_disabled", market.BreakoutLong, false, BreakoutActionCloseAll},
// Direction adjustment enabled
{"short_enabled", market.BreakoutShort, true, BreakoutActionAdjustDirection},
{"mid_enabled", market.BreakoutMid, true, BreakoutActionAdjustDirection},
{"long_enabled", market.BreakoutLong, true, BreakoutActionCloseAll}, // Long always triggers emergency
{"none_enabled", market.BreakoutNone, true, BreakoutActionNone},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
action := getBreakoutActionWithDirection(tt.level, tt.enableDirectionAdjust)
if action != tt.expected {
t.Errorf("getBreakoutActionWithDirection(%v, %v) = %v, want %v",
tt.level, tt.enableDirectionAdjust, action, tt.expected)
}
})
}
}
func TestShouldRecoverDirection(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95,
}
tests := []struct {
name string
price float64
direction market.GridDirection
expected bool
}{
{"neutral_inside_no_recovery", 95, market.GridDirectionNeutral, false},
{"long_inside_should_recover", 95, market.GridDirectionLong, true},
{"long_outside_no_recovery", 101, market.GridDirectionLong, false},
{"short_inside_should_recover", 95, market.GridDirectionShort, true},
{"short_outside_no_recovery", 89, market.GridDirectionShort, false},
{"long_bias_inside_should_recover", 95, market.GridDirectionLongBias, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
box.CurrentPrice = tt.price
result := shouldRecoverDirection(box, tt.direction)
if result != tt.expected {
t.Errorf("shouldRecoverDirection(price=%v, %v) = %v, want %v",
tt.price, tt.direction, result, tt.expected)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"math" "math"

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"bytes" "bytes"
@@ -16,7 +16,6 @@ import (
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid" "github.com/sonirico/go-hyperliquid"
"nofx/trader/types"
) )
// HyperliquidTrader Hyperliquid trader // 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) // AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only) // 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" // Need to return "wallet balance without unrealized PnL"
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
@@ -1951,14 +1950,14 @@ func absFloat(x float64) float64 {
// GetClosedPnL gets recent closing trades from Hyperliquid // GetClosedPnL gets recent closing trades from Hyperliquid
// Note: Hyperliquid does NOT have a position history API, only fill history. // Note: Hyperliquid does NOT have a position history API, only fill history.
// This returns individual closing trades for real-time position closure detection. // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) // Filter only closing trades (realizedPnl != 0)
var records []types.ClosedPnLRecord var records []ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue 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, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -2002,7 +2001,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]type
} }
// GetTrades retrieves trade history from Hyperliquid // 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 // Use UserFillsByTime API
startTimeMs := startTime.UnixMilli() startTimeMs := startTime.UnixMilli()
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil) 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) return nil, fmt.Errorf("failed to get user fills: %w", err)
} }
var trades []types.TradeRecord var trades []TradeRecord
for _, fill := range fills { for _, fill := range fills {
price, _ := strconv.ParseFloat(fill.Price, 64) price, _ := strconv.ParseFloat(fill.Price, 64)
qty, _ := strconv.ParseFloat(fill.Size, 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" // Hyperliquid uses one-way mode, so PositionSide is "BOTH"
trade := types.TradeRecord{ trade := TradeRecord{
TradeID: strconv.FormatInt(fill.Tid, 10), TradeID: strconv.FormatInt(fill.Tid, 10),
Symbol: fill.Coin, Symbol: fill.Coin,
Side: side, Side: side,
@@ -2083,13 +2082,13 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.T
var defaultBuilder *hyperliquid.BuilderInfo = nil var defaultBuilder *hyperliquid.BuilderInfo = nil
// GetOpenOrders gets all open/pending orders for a symbol // 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) openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err) return nil, fmt.Errorf("failed to get open orders: %w", err)
} }
var result []types.OpenOrder var result []OpenOrder
for _, order := range openOrders { for _, order := range openOrders {
if order.Coin != symbol { if order.Coin != symbol {
continue continue
@@ -2100,7 +2099,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err
side = "SELL" side = "SELL"
} }
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid), OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin, Symbol: order.Coin,
Side: side, Side: side,
@@ -2118,7 +2117,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err
// PlaceLimitOrder places a limit order for grid trading // PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface // Implements GridTrader interface
func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
coin := convertSymbolToHyperliquid(req.Symbol) coin := convertSymbolToHyperliquid(req.Symbol)
// Set leverage if specified and not xyz dex // Set leverage if specified and not xyz dex
@@ -2166,7 +2165,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f", logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
coin, req.Side, roundedPrice) coin, req.Side, roundedPrice)
return &types.LimitOrderResult{ return &LimitOrderResult{
OrderID: orderID, OrderID: orderID,
ClientID: req.ClientID, ClientID: req.ClientID,
Symbol: req.Symbol, Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"context" "context"
@@ -11,7 +11,7 @@ import (
// TestMetaConcurrentAccess tests that concurrent access to meta field is safe // TestMetaConcurrentAccess tests that concurrent access to meta field is safe
func TestMetaConcurrentAccess(t *testing.T) { func TestMetaConcurrentAccess(t *testing.T) {
// Create a HyperliquidTrader instance with meta initialized // Create a HyperliquidTrader instance with meta initialized
ht := &HyperliquidTrader{ trader := &HyperliquidTrader{
ctx: context.Background(), ctx: context.Background(),
meta: &hyperliquid.Meta{ meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{ Universe: []hyperliquid.AssetInfo{
@@ -32,7 +32,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
go func() { go func() {
defer wg.Done() defer wg.Done()
// This should not cause race conditions // This should not cause race conditions
decimals := ht.getSzDecimals("BTC") decimals := trader.getSzDecimals("BTC")
if decimals != 5 { if decimals != 5 {
t.Errorf("Expected decimals 5, got %d", decimals) 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 // TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field
func TestMetaConcurrentReadWrite(t *testing.T) { func TestMetaConcurrentReadWrite(t *testing.T) {
ht := &HyperliquidTrader{ trader := &HyperliquidTrader{
ctx: context.Background(), ctx: context.Background(),
meta: &hyperliquid.Meta{ meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{ Universe: []hyperliquid.AssetInfo{
@@ -62,7 +62,7 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
ht.getSzDecimals("BTC") trader.getSzDecimals("BTC")
}() }()
} }
@@ -72,36 +72,36 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
go func(iteration int) { go func(iteration int) {
defer wg.Done() defer wg.Done()
// Simulate meta update // Simulate meta update
ht.metaMutex.Lock() trader.metaMutex.Lock()
ht.meta = &hyperliquid.Meta{ trader.meta = &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{ Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5 + iteration%3}, {Name: "BTC", SzDecimals: 5 + iteration%3},
{Name: "ETH", SzDecimals: 4}, {Name: "ETH", SzDecimals: 4},
}, },
} }
ht.metaMutex.Unlock() trader.metaMutex.Unlock()
}(i) }(i)
} }
wg.Wait() wg.Wait()
// Verify meta is not nil after all operations // Verify meta is not nil after all operations
ht.metaMutex.RLock() trader.metaMutex.RLock()
if ht.meta == nil { if trader.meta == nil {
t.Error("Meta should not be nil after concurrent operations") t.Error("Meta should not be nil after concurrent operations")
} }
ht.metaMutex.RUnlock() trader.metaMutex.RUnlock()
} }
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta // TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
func TestGetSzDecimals_NilMeta(t *testing.T) { func TestGetSzDecimals_NilMeta(t *testing.T) {
ht := &HyperliquidTrader{ trader := &HyperliquidTrader{
meta: nil, meta: nil,
metaMutex: sync.RWMutex{}, metaMutex: sync.RWMutex{},
} }
// Should return default value 4 when meta is nil // Should return default value 4 when meta is nil
decimals := ht.getSzDecimals("BTC") decimals := trader.getSzDecimals("BTC")
expectedDecimals := 4 expectedDecimals := 4
if decimals != expectedDecimals { if decimals != expectedDecimals {
@@ -111,7 +111,7 @@ func TestGetSzDecimals_NilMeta(t *testing.T) {
// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta // TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta
func TestGetSzDecimals_ValidMeta(t *testing.T) { func TestGetSzDecimals_ValidMeta(t *testing.T) {
ht := &HyperliquidTrader{ trader := &HyperliquidTrader{
meta: &hyperliquid.Meta{ meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{ Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5}, {Name: "BTC", SzDecimals: 5},
@@ -133,7 +133,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.coin, func(t *testing.T) { t.Run(tt.coin, func(t *testing.T) {
decimals := ht.getSzDecimals(tt.coin) decimals := trader.getSzDecimals(tt.coin)
if decimals != tt.expectedDecimals { if decimals != tt.expectedDecimals {
t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals) 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 // TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues
// Run with: go test -race -run TestMetaMutex_NoRaceCondition // Run with: go test -race -run TestMetaMutex_NoRaceCondition
func TestMetaMutex_NoRaceCondition(t *testing.T) { func TestMetaMutex_NoRaceCondition(t *testing.T) {
ht := &HyperliquidTrader{ trader := &HyperliquidTrader{
ctx: context.Background(), ctx: context.Background(),
meta: &hyperliquid.Meta{ meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{ Universe: []hyperliquid.AssetInfo{
@@ -163,8 +163,8 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
ht.getSzDecimals("BTC") trader.getSzDecimals("BTC")
ht.getSzDecimals("ETH") trader.getSzDecimals("ETH")
}() }()
} }
@@ -173,15 +173,15 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
wg.Add(1) wg.Add(1)
go func(idx int) { go func(idx int) {
defer wg.Done() defer wg.Done()
ht.metaMutex.Lock() trader.metaMutex.Lock()
ht.meta = &hyperliquid.Meta{ trader.meta = &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{ Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5}, {Name: "BTC", SzDecimals: 5},
{Name: "ETH", SzDecimals: 4}, {Name: "ETH", SzDecimals: 4},
{Name: "SOL", SzDecimals: 3}, {Name: "SOL", SzDecimals: 3},
}, },
} }
ht.metaMutex.Unlock() trader.metaMutex.Unlock()
}(i) }(i)
} }

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"context" "context"
@@ -11,8 +11,6 @@ import (
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid" "github.com/sonirico/go-hyperliquid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
) )
// ============================================================ // ============================================================
@@ -22,7 +20,7 @@ import (
// HyperliquidTestSuite Hyperliquid trader test suite // HyperliquidTestSuite Hyperliquid trader test suite
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic // Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
type HyperliquidTestSuite struct { type HyperliquidTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite *TraderTestSuite // Embeds base test suite
mockServer *httptest.Server mockServer *httptest.Server
privateKey *ecdsa.PrivateKey privateKey *ecdsa.PrivateKey
} }
@@ -218,7 +216,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
}, },
} }
traderInstance := &HyperliquidTrader{ trader := &HyperliquidTrader{
exchange: exchange, exchange: exchange,
ctx: ctx, ctx: ctx,
walletAddr: walletAddr, walletAddr: walletAddr,
@@ -227,7 +225,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
} }
// Create base suite // Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance) baseSuite := NewTraderTestSuite(t, trader)
return &HyperliquidTestSuite{ return &HyperliquidTestSuite{
TraderTestSuite: baseSuite, TraderTestSuite: baseSuite,
@@ -250,7 +248,7 @@ func (s *HyperliquidTestSuite) Cleanup() {
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance // TestHyperliquidTrader_InterfaceCompliance Test interface compliance
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) { 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 // TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
@@ -283,7 +281,7 @@ func TestNewHyperliquidTrader(t *testing.T) {
walletAddr: "0x1234567890123456789012345678901234567890", walletAddr: "0x1234567890123456789012345678901234567890",
testnet: true, testnet: true,
wantError: true, wantError: true,
errorContains: "failed to parse private key", errorContains: "Failed to parse private key",
}, },
{ {
name: "Empty wallet address", name: "Empty wallet address",
@@ -564,8 +562,8 @@ func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ht := &HyperliquidTrader{meta: tt.meta} trader := &HyperliquidTrader{meta: tt.meta}
result := ht.getSzDecimals(tt.coin) result := trader.getSzDecimals(tt.coin)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, result)
}) })
} }

View File

@@ -3,19 +3,161 @@ package trader
import ( import (
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/trader/types" "time"
) )
// Re-export types for backward compatibility // ClosedPnLRecord represents a single closed position record from exchange
type ( type ClosedPnLRecord struct {
ClosedPnLRecord = types.ClosedPnLRecord Symbol string // Trading pair (e.g., "BTCUSDT")
TradeRecord = types.TradeRecord Side string // "long" or "short"
Trader = types.Trader EntryPrice float64 // Entry price
OpenOrder = types.OpenOrder ExitPrice float64 // Exit/close price
LimitOrderRequest = types.LimitOrderRequest Quantity float64 // Position size
LimitOrderResult = types.LimitOrderResult RealizedPnL float64 // Realized profit/loss
GridTrader = types.GridTrader 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 // GridTraderAdapter wraps a basic Trader to provide GridTrader interface
// Uses stop orders as a fallback when limit orders aren't directly available // Uses stop orders as a fallback when limit orders aren't directly available

View File

@@ -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)
}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"fmt" "fmt"
@@ -6,8 +6,6 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
tradertypes "nofx/trader/types"
) )
// Test configuration - uses environment variables for security // Test configuration - uses environment variables for security
@@ -686,7 +684,7 @@ func TestLighterPlaceLimitOrder(t *testing.T) {
limitPrice := marketPrice * 0.75 limitPrice := marketPrice * 0.75
quantity := 0.01 quantity := 0.01
req := &tradertypes.LimitOrderRequest{ req := &LimitOrderRequest{
Symbol: "ETH", Symbol: "ETH",
Side: "BUY", Side: "BUY",
PositionSide: "LONG", PositionSide: "LONG",

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"context" "context"
@@ -16,7 +16,6 @@ import (
lighterClient "github.com/elliottech/lighter-go/client" lighterClient "github.com/elliottech/lighter-go/client"
lighterHTTP "github.com/elliottech/lighter-go/client/http" lighterHTTP "github.com/elliottech/lighter-go/client/http"
"github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/common/hexutil"
tradertypes "nofx/trader/types"
) )
// AccountInfo LIGHTER account information // AccountInfo LIGHTER account information
@@ -399,14 +398,14 @@ func (t *LighterTraderV2) Cleanup() error {
// GetClosedPnL gets closed position PnL records from exchange // GetClosedPnL gets closed position PnL records from exchange
// LIGHTER does not have a direct closed PnL API, returns empty slice // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) // Filter only closing trades (realizedPnl != 0)
var records []tradertypes.ClosedPnLRecord var records []ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue continue
@@ -428,7 +427,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]trader
} }
} }
records = append(records, tradertypes.ClosedPnLRecord{ records = append(records, ClosedPnLRecord{
Symbol: trade.Symbol, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -448,7 +447,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]trader
} }
// GetTrades retrieves trade history from Lighter // 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 // Ensure we have account index
if t.accountIndex == 0 { if t.accountIndex == 0 {
if err := t.initializeAccount(); err != nil { if err := t.initializeAccount(); err != nil {
@@ -491,7 +490,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body)) logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
return []tradertypes.TradeRecord{}, nil return []TradeRecord{}, nil
} }
// Debug: log raw response // Debug: log raw response
@@ -503,14 +502,14 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
var trades []LighterTrade var trades []LighterTrade
if err := json.Unmarshal(body, &trades); err != nil { if err := json.Unmarshal(body, &trades); err != nil {
logger.Infof("⚠️ Failed to parse trades response as array: %v", err) logger.Infof("⚠️ Failed to parse trades response as array: %v", err)
return []tradertypes.TradeRecord{}, nil return []TradeRecord{}, nil
} }
response.Trades = trades response.Trades = trades
} }
if response.Code != 200 && response.Code != 0 { if response.Code != 200 && response.Code != 0 {
logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code) logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code)
return []tradertypes.TradeRecord{}, nil return []TradeRecord{}, nil
} }
// Build market_id -> symbol map // Build market_id -> symbol map
@@ -529,7 +528,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
} }
// Convert to unified TradeRecord format // Convert to unified TradeRecord format
var result []tradertypes.TradeRecord var result []TradeRecord
for _, lt := range response.Trades { for _, lt := range response.Trades {
price, _ := parseFloat(lt.Price) price, _ := parseFloat(lt.Price)
qty, _ := parseFloat(lt.Size) qty, _ := parseFloat(lt.Size)
@@ -616,7 +615,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
openSide, openAction = "LONG", "open_long" openSide, openAction = "LONG", "open_long"
} }
closeTrade := tradertypes.TradeRecord{ closeTrade := TradeRecord{
TradeID: fmt.Sprintf("%d_close", lt.TradeID), TradeID: fmt.Sprintf("%d_close", lt.TradeID),
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
@@ -630,7 +629,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
} }
result = append(result, closeTrade) result = append(result, closeTrade)
openTrade := tradertypes.TradeRecord{ openTrade := TradeRecord{
TradeID: fmt.Sprintf("%d_open", lt.TradeID), TradeID: fmt.Sprintf("%d_open", lt.TradeID),
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
@@ -672,7 +671,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertyp
} }
} }
trade := tradertypes.TradeRecord{ trade := TradeRecord{
TradeID: fmt.Sprintf("%d", lt.TradeID), TradeID: fmt.Sprintf("%d", lt.TradeID),
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"encoding/json" "encoding/json"
@@ -91,7 +91,7 @@ func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) {
// Calculate wallet balance (total equity - unrealized PnL) // Calculate wallet balance (total equity - unrealized PnL)
walletBalance := balance.TotalEquity - balance.UnrealizedPnL 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) // (totalEquity = totalWalletBalance + totalUnrealizedProfit)
return map[string]interface{}{ return map[string]interface{}{
"totalWalletBalance": walletBalance, // Wallet balance (excluding unrealized PnL) "totalWalletBalance": walletBalance, // Wallet balance (excluding unrealized PnL)
@@ -165,7 +165,7 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) {
result := make([]map[string]interface{}, 0, len(positions)) result := make([]map[string]interface{}, 0, len(positions))
for _, pos := range 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{}{ result = append(result, map[string]interface{}{
"symbol": pos.Symbol, "symbol": pos.Symbol,
"side": pos.Side, "side": pos.Side,

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"bytes" "bytes"
@@ -13,7 +13,6 @@ import (
"time" "time"
"github.com/elliottech/lighter-go/types" "github.com/elliottech/lighter-go/types"
tradertypes "nofx/trader/types"
) )
// OpenLong Open long position (implements Trader interface) // OpenLong Open long position (implements Trader interface)
@@ -857,14 +856,14 @@ func pow10(n int) int64 {
} }
// GetOpenOrders gets all open/pending orders for a symbol // GetOpenOrders gets all open/pending orders for a symbol
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder, error) { func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// Get active orders from Lighter API // Get active orders from Lighter API
activeOrders, err := t.GetActiveOrders(symbol) activeOrders, err := t.GetActiveOrders(symbol)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get active orders: %w", err) return nil, fmt.Errorf("failed to get active orders: %w", err)
} }
var result []tradertypes.OpenOrder var result []OpenOrder
for _, order := range activeOrders { for _, order := range activeOrders {
// Convert side: Lighter uses is_ask (true=sell, false=buy) // Convert side: Lighter uses is_ask (true=sell, false=buy)
side := "BUY" side := "BUY"
@@ -906,7 +905,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder,
} }
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64) triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
openOrder := tradertypes.OpenOrder{ openOrder := OpenOrder{
OrderID: order.OrderID, OrderID: order.OrderID,
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
@@ -926,7 +925,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder,
// PlaceLimitOrder implements GridTrader interface for grid trading // PlaceLimitOrder implements GridTrader interface for grid trading
// Places a limit order at the specified price // Places a limit order at the specified price
func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*tradertypes.LimitOrderResult, error) { func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
if t.txClient == nil { if t.txClient == nil {
return nil, fmt.Errorf("TxClient not initialized") return nil, fmt.Errorf("TxClient not initialized")
} }
@@ -961,7 +960,7 @@ func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s", logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
req.Symbol, req.Side, req.Price, orderID) req.Symbol, req.Side, req.Price, orderID)
return &tradertypes.LimitOrderResult{ return &LimitOrderResult{
OrderID: orderID, OrderID: orderID,
ClientID: req.ClientID, ClientID: req.ClientID,
Symbol: req.Symbol, Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package lighter package trader
import ( import (
"fmt" "fmt"
@@ -7,14 +7,6 @@ import (
"golang.org/x/crypto/sha3" "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) // AccountBalance Account balance information (Lighter)
type AccountBalance struct { type AccountBalance struct {
TotalEquity float64 `json:"total_equity"` // Total equity TotalEquity float64 `json:"total_equity"` // Total equity

View File

@@ -1,4 +1,4 @@
package okx package trader
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package okx package trader
import ( import (
"bytes" "bytes"
@@ -16,7 +16,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"nofx/trader/types"
) )
// OKX API endpoints // OKX API endpoints
@@ -1282,7 +1281,7 @@ var okxTag = func() string {
// GetClosedPnL retrieves closed position PnL records from OKX // GetClosedPnL retrieves closed position PnL records from OKX
// OKX API: /api/v5/account/positions-history // 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 { if limit <= 0 {
limit = 100 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) 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 { for _, pos := range resp.Data {
record := types.ClosedPnLRecord{} record := ClosedPnLRecord{}
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT) // Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
parts := strings.Split(pos.InstID, "-") parts := strings.Split(pos.InstID, "-")
@@ -1390,9 +1389,9 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Closed
} }
// GetOpenOrders gets all open/pending orders for a symbol // GetOpenOrders gets all open/pending orders for a symbol
func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
instId := t.convertSymbol(symbol) instId := t.convertSymbol(symbol)
var result []types.OpenOrder var result []OpenOrder
// 1. Get pending limit orders // 1. Get pending limit orders
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId) path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
@@ -1423,7 +1422,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
positionSide = "BOTH" positionSide = "BOTH"
} }
result = append(result, types.OpenOrder{ result = append(result, OpenOrder{
OrderID: order.OrdId, OrderID: order.OrdId,
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
@@ -1439,8 +1438,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
} }
// 2. Get pending algo orders (stop-loss/take-profit) // 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", okxAlgoPendingPath, instId)
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP&ordType=conditional", okxAlgoPendingPath, instId)
algoData, err := t.doRequest("GET", algoPath, nil) algoData, err := t.doRequest("GET", algoPath, nil)
if err != nil { if err != nil {
logger.Warnf("[OKX] Failed to get algo orders: %v", err) logger.Warnf("[OKX] Failed to get algo orders: %v", err)
@@ -1453,13 +1451,12 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
PosSide string `json:"posSide"` PosSide string `json:"posSide"`
OrdType string `json:"ordType"` // conditional/oco/trigger OrdType string `json:"ordType"` // conditional/oco/trigger
TriggerPx string `json:"triggerPx"` TriggerPx string `json:"triggerPx"`
SlTriggerPx string `json:"slTriggerPx"` // Stop loss trigger price
TpTriggerPx string `json:"tpTriggerPx"` // Take profit trigger price
Sz string `json:"sz"` Sz string `json:"sz"`
State string `json:"state"` State string `json:"state"`
} }
if err := json.Unmarshal(algoData, &algoOrders); err == nil { if err := json.Unmarshal(algoData, &algoOrders); err == nil {
for _, order := range algoOrders { for _, order := range algoOrders {
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
quantity, _ := strconv.ParseFloat(order.Sz, 64) quantity, _ := strconv.ParseFloat(order.Sz, 64)
side := strings.ToUpper(order.Side) side := strings.ToUpper(order.Side)
@@ -1468,52 +1465,18 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
positionSide = "BOTH" positionSide = "BOTH"
} }
// Check for stop loss order (slTriggerPx is set) // Map OKX algo order type
if order.SlTriggerPx != "" { orderType := "STOP_MARKET"
slPrice, _ := strconv.ParseFloat(order.SlTriggerPx, 64) if order.OrdType == "oco" {
if slPrice > 0 { orderType = "TAKE_PROFIT_MARKET"
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) result = append(result, OpenOrder{
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, OrderID: order.AlgoId,
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
PositionSide: positionSide, PositionSide: positionSide,
Type: "STOP_MARKET", Type: orderType,
Price: 0, Price: 0,
StopPrice: triggerPrice, StopPrice: triggerPrice,
Quantity: quantity, Quantity: quantity,
@@ -1522,8 +1485,6 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
} }
} }
} }
}
}
logger.Infof("✓ OKX GetOpenOrders: found %d open orders for %s", len(result), symbol) logger.Infof("✓ OKX GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil return result, nil
@@ -1531,7 +1492,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading // PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface // Implements GridTrader interface
func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
instId := t.convertSymbol(req.Symbol) instId := t.convertSymbol(req.Symbol)
// Get instrument info // Get instrument info
@@ -1605,7 +1566,7 @@ func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitO
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s", logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
instId, side, req.Price, orders[0].OrdId) instId, side, req.Price, orders[0].OrdId)
return &types.LimitOrderResult{ return &LimitOrderResult{
OrderID: orders[0].OrdId, OrderID: orders[0].OrdId,
ClientID: orders[0].ClOrdId, ClientID: orders[0].ClOrdId,
Symbol: req.Symbol, Symbol: req.Symbol,

View File

@@ -1,11 +1,10 @@
package testutil package trader
import ( import (
"testing" "testing"
"github.com/agiledragon/gomonkey/v2" "github.com/agiledragon/gomonkey/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"nofx/trader/types"
) )
// TraderTestSuite Generic Trader interface test suite (base suite) // TraderTestSuite Generic Trader interface test suite (base suite)
@@ -17,12 +16,12 @@ import (
// 3. Call RunAllTests() to run all generic tests // 3. Call RunAllTests() to run all generic tests
type TraderTestSuite struct { type TraderTestSuite struct {
T *testing.T T *testing.T
Trader types.Trader Trader Trader
Patches *gomonkey.Patches Patches *gomonkey.Patches
} }
// NewTraderTestSuite Create new base test suite // 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{ return &TraderTestSuite{
T: t, T: t,
Trader: trader, Trader: trader,

View File

@@ -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
}

View File

@@ -1,4 +1,4 @@
package hyperliquid package trader
import ( import (
"bytes" "bytes"

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Anthropic</title> <title>Claude</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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 301 B

View File

@@ -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 // Model Configuration Modal Component
function ModelConfigModal({ function ModelConfigModal({
allModels, allModels,
@@ -1500,16 +1407,17 @@ function ModelConfigModal({
onClose: () => void onClose: () => void
language: Language language: Language
}) { }) {
const [currentStep, setCurrentStep] = useState(editingModelId ? 1 : 0)
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '') const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('') const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('') const [modelName, setModelName] = useState('')
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找
const selectedModel = editingModelId const selectedModel = editingModelId
? configuredModels?.find((m) => m.id === selectedModelId) ? configuredModels?.find((m) => m.id === selectedModelId)
: allModels?.find((m) => m.id === selectedModelId) : allModels?.find((m) => m.id === selectedModelId)
// 如果是编辑现有模型初始化API Key、Base URL和Model Name
useEffect(() => { useEffect(() => {
if (editingModelId && selectedModel) { if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '') setApiKey(selectedModel.apiKey || '')
@@ -1518,170 +1426,175 @@ function ModelConfigModal({
} }
}, [editingModelId, selectedModel]) }, [editingModelId, selectedModel])
const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId)
setCurrentStep(1)
}
const handleBack = () => {
if (editingModelId) {
onClose()
} else {
setCurrentStep(0)
setSelectedModelId('')
}
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!selectedModelId || !apiKey.trim()) return 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 availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const stepLabels = language === 'zh' ? ['选择模型', '配置 API'] : ['Select Model', 'Configure API']
return ( 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 <div
className="rounded-2xl w-full max-w-2xl relative my-8 shadow-2xl" className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)', maxHeight: 'calc(100vh - 4rem)' }} 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' }}> <h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{editingModelId ? t('editAIModel', language) : t('addAIModel', language)} {editingModelId
? t('editAIModel', language)
: t('addAIModel', language)}
</h3> </h3>
</div>
<div className="flex items-center gap-2">
{editingModelId && ( {editingModelId && (
<button <button
type="button" type="button"
onClick={() => onDelete(editingModelId)} onClick={() => onDelete(editingModelId)}
className="p-2 rounded-lg hover:bg-red-500/20 transition-colors" className="p-2 rounded hover:bg-red-100 transition-colors"
style={{ color: '#F6465D' }} style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
title={t('delete', language)}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
)} )}
<button type="button" onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors" style={{ color: '#848E9C' }}>
</button>
</div>
</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 && ( {!editingModelId && (
<div className="px-6"> <div>
<ModelStepIndicator currentStep={currentStep} labels={stepLabels} /> <label
</div> className="block text-sm font-semibold mb-2"
)} style={{ color: '#EAECEF' }}
>
{/* Content */} {t('selectModel', language)}
<div className="px-6 pb-6 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 16rem)' }}> </label>
{/* Step 0: Select Model */} <select
{currentStep === 0 && !editingModelId && ( value={selectedModelId}
<div className="space-y-4"> onChange={(e) => setSelectedModelId(e.target.value)}
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}> className="w-full px-3 py-2 rounded"
{language === 'zh' ? '选择 AI 模型提供商' : 'Choose Your AI Provider'} style={{
</div> background: '#0B0E11',
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3"> border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
>
<option value="">{t('pleaseSelectModel', language)}</option>
{availableModels.map((model) => ( {availableModels.map((model) => (
<ModelCard <option key={model.id} value={model.id}>
key={model.id} {getShortName(model.name)} ({model.provider})
model={model} </option>
selected={selectedModelId === model.id}
onClick={() => handleSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))} ))}
</div> </select>
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
{language === 'zh' ? '带金色标记的模型已配置' : 'Models with gold badge are already configured'}
</div>
</div> </div>
)} )}
{/* Step 1: Configure */} {selectedModel && (
{(currentStep === 1 || editingModelId) && selectedModel && ( <div
<form onSubmit={handleSubmit} className="space-y-5"> className="p-4 rounded"
{/* Selected Model Header */} style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
<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"> <div className="flex items-center gap-3 mb-3">
{getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || ( <div className="w-8 h-8 flex items-center justify-center">
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{selectedModel.name[0]}</span> {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>
<div className="flex-1"> <div className="flex-1">
<div className="font-semibold text-lg" style={{ color: '#EAECEF' }}> <div className="font-semibold" style={{ color: '#EAECEF' }}>
{getShortName(selectedModel.name)} {getShortName(selectedModel.name)}
</div> </div>
<div className="text-xs" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{selectedModel.provider} {AI_PROVIDER_CONFIG[selectedModel.provider]?.defaultModel || selectedModel.id} {selectedModel.provider} {selectedModel.id}
</div> </div>
</div> </div>
</div>
{/* Default model info and API link */}
{AI_PROVIDER_CONFIG[selectedModel.provider] && ( {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 <a
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl} href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all hover:scale-105" className="inline-flex items-center gap-1.5 text-xs hover:underline"
style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.3)' }} style={{ color: '#F0B90B' }}
> >
<ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} /> <ExternalLink className="w-3 h-3" />
<span className="text-sm font-medium" style={{ color: '#A78BFA' }}> {t('applyApiKey', language)} {AI_PROVIDER_CONFIG[selectedModel.provider].apiName}
{language === 'zh' ? '获取 API Key' : 'Get API Key'}
</span>
</a> </a>
)}
</div>
{/* Kimi Warning */}
{selectedModel.provider === 'kimi' && ( {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="mt-2 text-xs p-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
<div className="flex items-start gap-2"> {t('kimiApiNote', language)}
<span style={{ fontSize: '16px' }}></span>
<div className="text-sm" style={{ color: '#F6465D' }}>
{t('kimiApiNote', language)}
</div> </div>
)}
</div> </div>
)}
</div> </div>
)} )}
{/* API Key */} {selectedModel && (
<div className="space-y-2"> <>
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> <div>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <label
<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" /> className="block text-sm font-semibold mb-2"
</svg> style={{ color: '#EAECEF' }}
API Key * >
API Key
</label> </label>
<input <input
type="password" type="password"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder={t('enterAPIKey', language)} placeholder={t('enterAPIKey', language)}
className="w-full px-4 py-3 rounded-xl" className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required required
/> />
</div> </div>
{/* Custom Base URL */} <div>
<div className="space-y-2"> <label
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> className="block text-sm font-semibold mb-2"
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> style={{ color: '#EAECEF' }}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> >
</svg>
{t('customBaseURL', language)} {t('customBaseURL', language)}
</label> </label>
<input <input
@@ -1689,20 +1602,23 @@ function ModelConfigModal({
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
placeholder={t('customBaseURLPlaceholder', language)} placeholder={t('customBaseURLPlaceholder', language)}
className="w-full px-4 py-3 rounded-xl" className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} 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)} {t('leaveBlankForDefault', language)}
</div> </div>
</div> </div>
{/* Custom Model Name */} <div>
<div className="space-y-2"> <label
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}> className="block text-sm font-semibold mb-2"
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"> style={{ color: '#EAECEF' }}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> >
</svg>
{t('customModelName', language)} {t('customModelName', language)}
</label> </label>
<input <input
@@ -1710,47 +1626,66 @@ function ModelConfigModal({
value={modelName} value={modelName}
onChange={(e) => setModelName(e.target.value)} onChange={(e) => setModelName(e.target.value)}
placeholder={t('customModelNamePlaceholder', language)} placeholder={t('customModelNamePlaceholder', language)}
className="w-full px-4 py-3 rounded-xl" className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} 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)} {t('leaveBlankForDefaultModel', language)}
</div> </div>
</div> </div>
{/* Info Box */} <div
<div className="p-4 rounded-xl" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}> className="p-4 rounded"
<div className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: '#A78BFA' }}> style={{
<Brain className="w-4 h-4" /> background: 'rgba(240, 185, 11, 0.1)',
{t('information', language)} 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>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}> <div
<div> {t('modelConfigInfo1', language)}</div> className="text-xs space-y-1"
<div> {t('modelConfigInfo2', language)}</div> style={{ color: '#848E9C' }}
<div> {t('modelConfigInfo3', language)}</div> >
<div>{t('modelConfigInfo1', language)}</div>
<div>{t('modelConfigInfo2', language)}</div>
<div>{t('modelConfigInfo3', language)}</div>
</div> </div>
</div> </div>
</>
)}
</div>
{/* Buttons */} <div
<div className="flex gap-3 pt-4"> className="flex gap-3 mt-6 pt-4 sticky bottom-0"
<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' }}> style={{ background: '#1E2329' }}
{editingModelId ? t('cancel', language) : (language === 'zh' ? '返回' : 'Back')} >
<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>
<button <button
type="submit" type="submit"
disabled={!selectedModel || !apiKey.trim()} 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" className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
style={{ background: '#8B5CF6', color: '#fff' }} style={{ background: '#F0B90B', color: '#000' }}
> >
{t('saveConfig', language)} {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> </button>
</div> </div>
</form> </form>
)}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -12,8 +12,6 @@ const ICON_PATHS: Record<string, string> = {
bybit: '/exchange-icons/bybit.png', bybit: '/exchange-icons/bybit.png',
okx: '/exchange-icons/okx.svg', okx: '/exchange-icons/okx.svg',
bitget: '/exchange-icons/bitget.svg', bitget: '/exchange-icons/bitget.svg',
gate: '/exchange-icons/gate.svg',
kucoin: '/exchange-icons/kucoin.svg',
hyperliquid: '/exchange-icons/hyperliquid.png', hyperliquid: '/exchange-icons/hyperliquid.png',
aster: '/exchange-icons/aster.svg', aster: '/exchange-icons/aster.svg',
lighter: '/exchange-icons/lighter.png', lighter: '/exchange-icons/lighter.png',
@@ -91,10 +89,6 @@ export const getExchangeIcon = (
? 'okx' ? 'okx'
: lowerType.includes('bitget') : lowerType.includes('bitget')
? 'bitget' ? 'bitget'
: lowerType.includes('gate')
? 'gate'
: lowerType.includes('kucoin')
? 'kucoin'
: lowerType.includes('hyperliquid') : lowerType.includes('hyperliquid')
? 'hyperliquid' ? 'hyperliquid'
: lowerType.includes('aster') : lowerType.includes('aster')

View File

@@ -334,8 +334,6 @@ export function LoginPage() {
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors" 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} />} {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>

View File

@@ -34,8 +34,6 @@ export default function FooterSection({ language }: FooterSectionProps) {
{ name: 'Bybit', href: 'https://partner.bybit.com/b/83856' }, { name: 'Bybit', href: 'https://partner.bybit.com/b/83856' },
{ name: 'OKX', href: 'https://www.okx.com/join/1865360' }, { name: 'OKX', href: 'https://www.okx.com/join/1865360' },
{ name: 'Bitget', href: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172' }, { 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: 'Hyperliquid', href: 'https://app.hyperliquid.xyz/join/AITRADING' },
{ name: 'Aster DEX', href: 'https://www.asterdex.com/en/referral/fdfc0e' }, { name: 'Aster DEX', href: 'https://www.asterdex.com/en/referral/fdfc0e' },
{ name: 'Lighter', href: 'https://app.lighter.xyz/?referral=68151432' }, { name: 'Lighter', href: 'https://app.lighter.xyz/?referral=68151432' },

View File

@@ -1,4 +1,4 @@
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react' import { Grid, DollarSign, TrendingUp, Shield } from 'lucide-react'
import type { GridStrategyConfig } from '../../types' import type { GridStrategyConfig } from '../../types'
interface GridConfigEditorProps { interface GridConfigEditorProps {
@@ -23,8 +23,6 @@ export const defaultGridConfig: GridStrategyConfig = {
stop_loss_pct: 5, stop_loss_pct: 5,
daily_loss_limit_pct: 10, daily_loss_limit_pct: 10,
use_maker_only: true, use_maker_only: true,
enable_direction_adjust: false,
direction_bias_ratio: 0.7,
} }
export function GridConfigEditor({ export function GridConfigEditor({
@@ -79,14 +77,6 @@ export function GridConfigEditor({
dailyLossLimitDesc: { zh: '每日最大亏损百分比', en: 'Maximum daily loss percentage' }, dailyLossLimitDesc: { zh: '每日最大亏损百分比', en: 'Maximum daily loss percentage' },
useMakerOnly: { zh: '仅使用 Maker 订单', en: 'Maker Only Orders' }, useMakerOnly: { zh: '仅使用 Maker 订单', en: 'Maker Only Orders' },
useMakerOnlyDesc: { zh: '使用限价单以降低手续费', en: 'Use limit orders for lower fees' }, 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 return translations[key]?.[language] || key
} }
@@ -429,77 +419,6 @@ export function GridConfigEditor({
</div> </div>
</div> </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> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -667,8 +667,6 @@ export const translations = {
passwordRequired: 'Password is required', passwordRequired: 'Password is required',
invalidEmail: 'Invalid email format', invalidEmail: 'Invalid email format',
passwordTooShort: 'Password must be at least 6 characters', passwordTooShort: 'Password must be at least 6 characters',
showPassword: 'Show password',
hidePassword: 'Hide password',
// Landing Page // Landing Page
features: 'Features', features: 'Features',
@@ -1836,8 +1834,6 @@ export const translations = {
passwordRequired: '请输入密码', passwordRequired: '请输入密码',
invalidEmail: '邮箱格式不正确', invalidEmail: '邮箱格式不正确',
passwordTooShort: '密码至少需要6个字符', passwordTooShort: '密码至少需要6个字符',
showPassword: '显示密码',
hidePassword: '隐藏密码',
// Landing Page // Landing Page
features: '功能', features: '功能',

View File

@@ -506,10 +506,6 @@ export interface GridStrategyConfig {
daily_loss_limit_pct: number; daily_loss_limit_pct: number;
// Use maker-only orders for lower fees // Use maker-only orders for lower fees
use_maker_only: boolean; use_maker_only: boolean;
// Enable automatic grid direction adjustment based on box breakouts
enable_direction_adjust?: boolean;
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
direction_bias_ratio?: number;
} }
export interface CoinSourceConfig { export interface CoinSourceConfig {