mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
9c5c976d9a
* feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and ⏳ placeholder
- Account state injection at conversation start (models, exchanges,
strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
* fix(telegram): eliminate narration, add full-setup workflow and tests
- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
- No narration leaking to user (5 narration variants tested)
- api_call tag never leaks to user
- Full setup workflow: POST strategy → verify → POST trader → start
- Start existing trader workflow
- Max iterations safety, tag stripping, parser edge cases
* refactor(agent): replace XML api_call with native function calling
Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.
Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
define api_request tool with JSON Schema; implement tool-call loop
with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
TestToolCallIDPropagated; remove XML-specific tests
Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.
All 11 agent tests pass. mcp package tests pass.
* refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format
Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).
Changes:
mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why
mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up
mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
* system prompt → top-level "system" field
* tools: parameters → input_schema, no "type:function" wrapper
* tool_choice "auto" → {"type":"auto"} object
* assistant tool calls → content[{type:tool_use, id, name, input}]
* role=tool results → role=user content[{type:tool_result,...}]
* consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull
All mcp and agent tests pass.
* fix(telegram): fix claude client dispatch + strategy creation workflow
- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
'claude' provider (was incorrectly falling back to DeepSeekClient which
uses OpenAI wire format, breaking Anthropic API calls)
- api/server.go: fix scan_interval_minutes schema default (3, not 60);
POST /api/strategies now clearly states config is OPTIONAL with complete
working defaults; POST /api/traders removes redundant GET workflow note
- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
without config (backend applies full working defaults automatically);
only include config when user requests custom settings
* test(mcp): add ClaudeClient wire format tests
Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL
* fix(telegram): clientForProvider returns correct client for all 7 providers
Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini
* fix(telegram): newLLMClient uses bound user's model, not any user's model
GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.
Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.
* fix(auth): single-user deployment by default, no open registration
Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env
Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)
This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.
* feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands
start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch
telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
- No AI model → explains how to configure, lists all providers
- AI model OK but no exchange → guides to configure exchange via chat
- All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)
telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat
* feat(i18n): bilingual EN/ZH setup guide with language selection
store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)
telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
Step 1: configure AI model (no model yet)
Step 2: configure exchange (model OK, no exchange)
Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual
Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.
* fix(telegram): use Markdown rendering + simplify language selection condition
- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
(lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)
* fix(start.sh): translate all user-facing text to English
Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English
* fix(telegram): remove 'default' user fallback — resolve user dynamically
- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
* called on every /start (handles: registered after bot launch)
* called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)
* refactor(telegram): clean onboarding — web UI for setup, Telegram for operations
- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI
* refactor: single-user web-based setup — replace env config with Settings UI
Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup
* feat: add blockRun (x402 USDC) support to all AI model consumers
- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient
* feat: add Claw402 (claw402.ai) x402 USDC payment provider
Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.
- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
api/server.go, api/strategy.go, trader/auto_trader.go,
telegram/bot.go, debate/engine.go, backtest/ai_client.go
* feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html
* refactor: centralize x402 payment flow into shared mcp/x402.go
Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.
* fix: stop PR template bot from overwriting user-written descriptions
The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.
Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md
* chore: simplify PR template (OpenClaw-style)
2355 lines
79 KiB
Go
2355 lines
79 KiB
Go
package trader
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"nofx/experience"
|
||
"nofx/kernel"
|
||
"nofx/logger"
|
||
"nofx/market"
|
||
"nofx/mcp"
|
||
"nofx/store"
|
||
"nofx/trader/aster"
|
||
"nofx/trader/binance"
|
||
"nofx/trader/bitget"
|
||
"nofx/trader/bybit"
|
||
"nofx/trader/gate"
|
||
"nofx/trader/hyperliquid"
|
||
"nofx/trader/indodax"
|
||
"nofx/trader/kucoin"
|
||
"nofx/trader/lighter"
|
||
"nofx/trader/okx"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
|
||
type AutoTraderConfig struct {
|
||
// Trader identification
|
||
ID string // Trader unique identifier (for log directory, etc.)
|
||
Name string // Trader display name
|
||
AIModel string // AI model: "qwen" or "deepseek"
|
||
|
||
// Trading platform selection
|
||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
|
||
ExchangeID string // Exchange account UUID (for multi-account support)
|
||
|
||
// Binance API configuration
|
||
BinanceAPIKey string
|
||
BinanceSecretKey string
|
||
|
||
// Bybit API configuration
|
||
BybitAPIKey string
|
||
BybitSecretKey string
|
||
|
||
// OKX API configuration
|
||
OKXAPIKey string
|
||
OKXSecretKey string
|
||
OKXPassphrase string
|
||
|
||
// Bitget API configuration
|
||
BitgetAPIKey string
|
||
BitgetSecretKey string
|
||
BitgetPassphrase string
|
||
|
||
// Gate API configuration
|
||
GateAPIKey string
|
||
GateSecretKey string
|
||
|
||
// KuCoin API configuration
|
||
KuCoinAPIKey string
|
||
KuCoinSecretKey string
|
||
KuCoinPassphrase string
|
||
|
||
// Indodax API configuration
|
||
IndodaxAPIKey string
|
||
IndodaxSecretKey string
|
||
|
||
// Hyperliquid configuration
|
||
HyperliquidPrivateKey string
|
||
HyperliquidWalletAddr string
|
||
HyperliquidTestnet bool
|
||
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
|
||
|
||
// Aster configuration
|
||
AsterUser string // Aster main wallet address
|
||
AsterSigner string // Aster API wallet address
|
||
AsterPrivateKey string // Aster API wallet private key
|
||
|
||
// LIGHTER configuration
|
||
LighterWalletAddr string // LIGHTER wallet address (L1 wallet)
|
||
LighterPrivateKey string // LIGHTER L1 private key (for account identification)
|
||
LighterAPIKeyPrivateKey string // LIGHTER API Key private key (40 bytes, for transaction signing)
|
||
LighterAPIKeyIndex int // LIGHTER API Key index (0-255)
|
||
LighterTestnet bool // Whether to use testnet
|
||
|
||
// AI configuration
|
||
UseQwen bool
|
||
DeepSeekKey string
|
||
QwenKey string
|
||
|
||
// Custom AI API configuration
|
||
CustomAPIURL string
|
||
CustomAPIKey string
|
||
CustomModelName string
|
||
|
||
// Scan configuration
|
||
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
|
||
|
||
// Account configuration
|
||
InitialBalance float64 // Initial balance (for P&L calculation, must be set manually)
|
||
|
||
// Risk control (only as hints, AI can make autonomous decisions)
|
||
MaxDailyLoss float64 // Maximum daily loss percentage (hint)
|
||
MaxDrawdown float64 // Maximum drawdown percentage (hint)
|
||
StopTradingTime time.Duration // Pause duration after risk control triggers
|
||
|
||
// Position mode
|
||
IsCrossMargin bool // true=cross margin mode, false=isolated margin mode
|
||
|
||
// Competition visibility
|
||
ShowInCompetition bool // Whether to show in competition page
|
||
|
||
// Strategy configuration (use complete strategy config)
|
||
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
|
||
}
|
||
|
||
// AutoTrader automatic trader
|
||
type AutoTrader struct {
|
||
id string // Trader unique identifier
|
||
name string // Trader display name
|
||
aiModel string // AI model name
|
||
exchange string // Trading platform type (binance/bybit/etc)
|
||
exchangeID string // Exchange account UUID
|
||
showInCompetition bool // Whether to show in competition page
|
||
config AutoTraderConfig
|
||
trader Trader // Use Trader interface (supports multiple platforms)
|
||
mcpClient mcp.AIClient
|
||
store *store.Store // Data storage (decision records, etc.)
|
||
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
||
cycleNumber int // Current cycle number
|
||
initialBalance float64
|
||
dailyPnL float64
|
||
customPrompt string // Custom trading strategy prompt
|
||
overrideBasePrompt bool // Whether to override base prompt
|
||
lastResetTime time.Time
|
||
stopUntil time.Time
|
||
isRunning bool
|
||
isRunningMutex sync.RWMutex // Mutex to protect isRunning flag
|
||
startTime time.Time // System start time
|
||
callCount int // AI call count
|
||
positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds)
|
||
stopMonitorCh chan struct{} // Used to stop monitoring goroutine
|
||
monitorWg sync.WaitGroup // Used to wait for monitoring goroutine to finish
|
||
peakPnLCache map[string]float64 // Peak profit cache (symbol -> peak P&L percentage)
|
||
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
||
lastBalanceSyncTime time.Time // Last balance sync time
|
||
userID string // User ID
|
||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||
}
|
||
|
||
// NewAutoTrader creates an automatic trader
|
||
// st parameter is used to store decision records to database
|
||
func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*AutoTrader, error) {
|
||
// Set default values
|
||
if config.ID == "" {
|
||
config.ID = "default_trader"
|
||
}
|
||
if config.Name == "" {
|
||
config.Name = "Default Trader"
|
||
}
|
||
if config.AIModel == "" {
|
||
if config.UseQwen {
|
||
config.AIModel = "qwen"
|
||
} else {
|
||
config.AIModel = "deepseek"
|
||
}
|
||
}
|
||
|
||
// Initialize AI client based on provider
|
||
var mcpClient mcp.AIClient
|
||
aiModel := config.AIModel
|
||
if config.UseQwen && aiModel == "" {
|
||
aiModel = "qwen"
|
||
}
|
||
|
||
switch aiModel {
|
||
case "claude":
|
||
mcpClient = mcp.NewClaudeClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using Claude AI", config.Name)
|
||
|
||
case "kimi":
|
||
mcpClient = mcp.NewKimiClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using Kimi (Moonshot) AI", config.Name)
|
||
|
||
case "gemini":
|
||
mcpClient = mcp.NewGeminiClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using Google Gemini AI", config.Name)
|
||
|
||
case "grok":
|
||
mcpClient = mcp.NewGrokClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using xAI Grok AI", config.Name)
|
||
|
||
case "openai":
|
||
mcpClient = mcp.NewOpenAIClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
|
||
|
||
case "minimax":
|
||
mcpClient = mcp.NewMiniMaxClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using MiniMax AI", config.Name)
|
||
|
||
case "blockrun-base":
|
||
mcpClient = mcp.NewBlockRunBaseClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using BlockRun (Base Wallet) AI", config.Name)
|
||
|
||
case "blockrun-sol":
|
||
mcpClient = mcp.NewBlockRunSolClient()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using BlockRun (Solana Wallet) AI", config.Name)
|
||
|
||
case "claw402":
|
||
mcpClient = mcp.NewClaw402Client()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using Claw402 (Base USDC) AI", config.Name)
|
||
|
||
case "qwen":
|
||
mcpClient = mcp.NewQwenClient()
|
||
apiKey := config.QwenKey
|
||
if apiKey == "" {
|
||
apiKey = config.CustomAPIKey
|
||
}
|
||
mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using Alibaba Cloud Qwen AI", config.Name)
|
||
|
||
case "custom":
|
||
mcpClient = mcp.New()
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using custom AI API: %s (model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
|
||
default: // deepseek or empty
|
||
mcpClient = mcp.NewDeepSeekClient()
|
||
apiKey := config.DeepSeekKey
|
||
if apiKey == "" {
|
||
apiKey = config.CustomAPIKey
|
||
}
|
||
mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] Using DeepSeek AI", config.Name)
|
||
}
|
||
|
||
if config.CustomAPIURL != "" || config.CustomModelName != "" {
|
||
logger.Infof("🔧 [%s] Custom config - URL: %s, Model: %s", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
}
|
||
|
||
// Set default trading platform
|
||
if config.Exchange == "" {
|
||
config.Exchange = "binance"
|
||
}
|
||
|
||
// Create corresponding trader based on configuration
|
||
var trader Trader
|
||
var err error
|
||
|
||
// Record position mode (general)
|
||
marginModeStr := "Cross Margin"
|
||
if !config.IsCrossMargin {
|
||
marginModeStr = "Isolated Margin"
|
||
}
|
||
logger.Infof("📊 [%s] Position mode: %s", config.Name, marginModeStr)
|
||
|
||
switch config.Exchange {
|
||
case "binance":
|
||
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
|
||
trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||
case "bybit":
|
||
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
|
||
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||
case "okx":
|
||
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
|
||
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
||
case "bitget":
|
||
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
|
||
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
|
||
case "gate":
|
||
logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name)
|
||
trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey)
|
||
case "kucoin":
|
||
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
|
||
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
|
||
case "hyperliquid":
|
||
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
|
||
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
|
||
}
|
||
case "aster":
|
||
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
|
||
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
|
||
}
|
||
case "lighter":
|
||
logger.Infof("🏦 [%s] Using LIGHTER trading", config.Name)
|
||
|
||
if config.LighterWalletAddr == "" || config.LighterAPIKeyPrivateKey == "" {
|
||
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||
}
|
||
|
||
// Lighter only supports mainnet (testnet disabled)
|
||
trader, err = lighter.NewLighterTraderV2(
|
||
config.LighterWalletAddr,
|
||
config.LighterAPIKeyPrivateKey,
|
||
config.LighterAPIKeyIndex,
|
||
false, // Always use mainnet for Lighter
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
|
||
}
|
||
logger.Infof("✓ LIGHTER trader initialized successfully")
|
||
case "indodax":
|
||
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
|
||
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
|
||
default:
|
||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||
}
|
||
|
||
// Validate initial balance configuration, auto-fetch from exchange if 0
|
||
if config.InitialBalance <= 0 {
|
||
logger.Infof("📊 [%s] Initial balance not set, attempting to fetch current balance from exchange...", config.Name)
|
||
account, err := trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("initial balance not set and unable to fetch balance from exchange: %w", err)
|
||
}
|
||
// Try multiple balance field names (different exchanges return different formats)
|
||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||
var foundBalance float64
|
||
for _, key := range balanceKeys {
|
||
if balance, ok := account[key].(float64); ok && balance > 0 {
|
||
foundBalance = balance
|
||
break
|
||
}
|
||
}
|
||
if foundBalance > 0 {
|
||
config.InitialBalance = foundBalance
|
||
logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, foundBalance)
|
||
// Save to database so it persists across restarts
|
||
if st != nil {
|
||
if err := st.Trader().UpdateInitialBalance(userID, config.ID, foundBalance); err != nil {
|
||
logger.Infof("⚠️ [%s] Failed to save initial balance to database: %v", config.Name, err)
|
||
} else {
|
||
logger.Infof("✓ [%s] Initial balance saved to database", config.Name)
|
||
}
|
||
}
|
||
} else {
|
||
return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance")
|
||
}
|
||
}
|
||
|
||
// Get last cycle number (for recovery)
|
||
var cycleNumber int
|
||
if st != nil {
|
||
cycleNumber, _ = st.Decision().GetLastCycleNumber(config.ID)
|
||
logger.Infof("📊 [%s] Decision records will be stored to database", config.Name)
|
||
}
|
||
|
||
// Create strategy engine (must have strategy config)
|
||
if config.StrategyConfig == nil {
|
||
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
|
||
}
|
||
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig)
|
||
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
|
||
|
||
return &AutoTrader{
|
||
id: config.ID,
|
||
name: config.Name,
|
||
aiModel: config.AIModel,
|
||
exchange: config.Exchange,
|
||
exchangeID: config.ExchangeID,
|
||
showInCompetition: config.ShowInCompetition,
|
||
config: config,
|
||
trader: trader,
|
||
mcpClient: mcpClient,
|
||
store: st,
|
||
strategyEngine: strategyEngine,
|
||
cycleNumber: cycleNumber,
|
||
initialBalance: config.InitialBalance,
|
||
lastResetTime: time.Now(),
|
||
startTime: time.Now(),
|
||
callCount: 0,
|
||
isRunning: false,
|
||
positionFirstSeenTime: make(map[string]int64),
|
||
stopMonitorCh: make(chan struct{}),
|
||
monitorWg: sync.WaitGroup{},
|
||
peakPnLCache: make(map[string]float64),
|
||
peakPnLCacheMutex: sync.RWMutex{},
|
||
lastBalanceSyncTime: time.Now(),
|
||
userID: userID,
|
||
}, nil
|
||
}
|
||
|
||
// Run runs the automatic trading main loop
|
||
func (at *AutoTrader) Run() error {
|
||
at.isRunningMutex.Lock()
|
||
at.isRunning = true
|
||
at.isRunningMutex.Unlock()
|
||
|
||
at.stopMonitorCh = make(chan struct{})
|
||
at.startTime = time.Now()
|
||
|
||
logger.Info("🚀 AI-driven automatic trading system started")
|
||
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
|
||
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
|
||
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
|
||
at.monitorWg.Add(1)
|
||
defer at.monitorWg.Done()
|
||
|
||
// Start drawdown monitoring
|
||
at.startDrawdownMonitor()
|
||
|
||
// Start Lighter order sync if using Lighter exchange
|
||
if at.exchange == "lighter" {
|
||
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
|
||
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Hyperliquid order sync if using Hyperliquid exchange
|
||
if at.exchange == "hyperliquid" {
|
||
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
|
||
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Bybit order sync if using Bybit exchange
|
||
if at.exchange == "bybit" {
|
||
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
|
||
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start OKX order sync if using OKX exchange
|
||
if at.exchange == "okx" {
|
||
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
|
||
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Bitget order sync if using Bitget exchange
|
||
if at.exchange == "bitget" {
|
||
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
|
||
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Aster order sync if using Aster exchange
|
||
if at.exchange == "aster" {
|
||
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
|
||
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Binance order sync if using Binance exchange
|
||
if at.exchange == "binance" {
|
||
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
|
||
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Gate order sync if using Gate exchange
|
||
if at.exchange == "gate" {
|
||
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
|
||
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start KuCoin order sync if using KuCoin exchange
|
||
if at.exchange == "kucoin" {
|
||
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
|
||
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
ticker := time.NewTicker(at.config.ScanInterval)
|
||
defer ticker.Stop()
|
||
|
||
// Check if this is a grid trading strategy
|
||
isGridStrategy := at.IsGridStrategy()
|
||
if isGridStrategy {
|
||
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
|
||
if err := at.InitializeGrid(); err != nil {
|
||
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
|
||
return fmt.Errorf("grid initialization failed: %w", err)
|
||
}
|
||
}
|
||
|
||
// Execute immediately on first run
|
||
if isGridStrategy {
|
||
if err := at.RunGridCycle(); err != nil {
|
||
logger.Infof("❌ Grid execution failed: %v", err)
|
||
}
|
||
} else {
|
||
if err := at.runCycle(); err != nil {
|
||
logger.Infof("❌ Execution failed: %v", err)
|
||
}
|
||
}
|
||
|
||
for {
|
||
at.isRunningMutex.RLock()
|
||
running := at.isRunning
|
||
at.isRunningMutex.RUnlock()
|
||
|
||
if !running {
|
||
break
|
||
}
|
||
|
||
select {
|
||
case <-ticker.C:
|
||
if isGridStrategy {
|
||
if err := at.RunGridCycle(); err != nil {
|
||
logger.Infof("❌ Grid execution failed: %v", err)
|
||
}
|
||
} else {
|
||
if err := at.runCycle(); err != nil {
|
||
logger.Infof("❌ Execution failed: %v", err)
|
||
}
|
||
}
|
||
case <-at.stopMonitorCh:
|
||
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Stop stops the automatic trading
|
||
func (at *AutoTrader) Stop() {
|
||
at.isRunningMutex.Lock()
|
||
if !at.isRunning {
|
||
at.isRunningMutex.Unlock()
|
||
return
|
||
}
|
||
at.isRunning = false
|
||
at.isRunningMutex.Unlock()
|
||
|
||
close(at.stopMonitorCh) // Notify monitoring goroutine to stop
|
||
at.monitorWg.Wait() // Wait for monitoring goroutine to finish
|
||
logger.Info("⏹ Automatic trading system stopped")
|
||
}
|
||
|
||
// runCycle runs one trading cycle (using AI full decision-making)
|
||
func (at *AutoTrader) runCycle() error {
|
||
at.callCount++
|
||
|
||
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
|
||
logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
||
logger.Info(strings.Repeat("=", 70))
|
||
|
||
// 0. Check if trader is stopped (early exit to prevent trades after Stop() is called)
|
||
at.isRunningMutex.RLock()
|
||
running := at.isRunning
|
||
at.isRunningMutex.RUnlock()
|
||
if !running {
|
||
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
|
||
return nil
|
||
}
|
||
|
||
// Create decision record
|
||
record := &store.DecisionRecord{
|
||
ExecutionLog: []string{},
|
||
Success: true,
|
||
}
|
||
|
||
// 1. Check if trading needs to be stopped
|
||
if time.Now().Before(at.stopUntil) {
|
||
remaining := at.stopUntil.Sub(time.Now())
|
||
logger.Infof("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes())
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes())
|
||
at.saveDecision(record)
|
||
return nil
|
||
}
|
||
|
||
// 2. Reset daily P&L (reset every day)
|
||
if time.Since(at.lastResetTime) > 24*time.Hour {
|
||
at.dailyPnL = 0
|
||
at.lastResetTime = time.Now()
|
||
logger.Info("📅 Daily P&L reset")
|
||
}
|
||
|
||
// 4. Collect trading context
|
||
ctx, err := at.buildTradingContext()
|
||
if err != nil {
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err)
|
||
at.saveDecision(record)
|
||
return fmt.Errorf("failed to build trading context: %w", err)
|
||
}
|
||
|
||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
|
||
at.saveEquitySnapshot(ctx)
|
||
|
||
// 如果没有候选币种,记录但不报错
|
||
if len(ctx.CandidateCoins) == 0 {
|
||
logger.Infof("ℹ️ No candidate coins available, skipping this cycle")
|
||
record.Success = true // 不是错误,只是没有候选币
|
||
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
|
||
record.AccountState = store.AccountSnapshot{
|
||
TotalBalance: ctx.Account.TotalEquity,
|
||
AvailableBalance: ctx.Account.AvailableBalance,
|
||
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
|
||
PositionCount: ctx.Account.PositionCount,
|
||
InitialBalance: at.initialBalance,
|
||
}
|
||
at.saveDecision(record)
|
||
return nil
|
||
}
|
||
|
||
logger.Info(strings.Repeat("=", 70))
|
||
for _, coin := range ctx.CandidateCoins {
|
||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||
}
|
||
|
||
logger.Infof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d",
|
||
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
|
||
|
||
// 5. Use strategy engine to call AI for decision
|
||
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
|
||
aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
|
||
|
||
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
|
||
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
|
||
logger.Infof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000)
|
||
record.ExecutionLog = append(record.ExecutionLog,
|
||
fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs))
|
||
}
|
||
|
||
// Save chain of thought, decisions, and input prompt even if there's an error (for debugging)
|
||
if aiDecision != nil {
|
||
record.SystemPrompt = aiDecision.SystemPrompt // Save system prompt
|
||
record.InputPrompt = aiDecision.UserPrompt
|
||
record.CoTTrace = aiDecision.CoTTrace
|
||
record.RawResponse = aiDecision.RawResponse // Save raw AI response for debugging
|
||
if len(aiDecision.Decisions) > 0 {
|
||
decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ")
|
||
record.DecisionJSON = string(decisionJSON)
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
|
||
|
||
// Print system prompt and AI chain of thought (output even with errors for debugging)
|
||
if aiDecision != nil {
|
||
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
|
||
logger.Infof("📋 System prompt (error case)")
|
||
logger.Info(strings.Repeat("=", 70))
|
||
logger.Info(aiDecision.SystemPrompt)
|
||
logger.Info(strings.Repeat("=", 70))
|
||
|
||
if aiDecision.CoTTrace != "" {
|
||
logger.Info("\n" + strings.Repeat("-", 70) + "\n")
|
||
logger.Info("💭 AI chain of thought analysis (error case):")
|
||
logger.Info(strings.Repeat("-", 70))
|
||
logger.Info(aiDecision.CoTTrace)
|
||
logger.Info(strings.Repeat("-", 70))
|
||
}
|
||
}
|
||
|
||
at.saveDecision(record)
|
||
return fmt.Errorf("failed to get AI decision: %w", err)
|
||
}
|
||
|
||
// // 5. Print system prompt
|
||
// logger.Infof("\n" + strings.Repeat("=", 70))
|
||
// logger.Infof("📋 System prompt [template: %s]", at.systemPromptTemplate)
|
||
// logger.Info(strings.Repeat("=", 70))
|
||
// logger.Info(decision.SystemPrompt)
|
||
// logger.Infof(strings.Repeat("=", 70) + "\n")
|
||
|
||
// 6. Print AI chain of thought
|
||
// logger.Infof("\n" + strings.Repeat("-", 70))
|
||
// logger.Info("💭 AI chain of thought analysis:")
|
||
// logger.Info(strings.Repeat("-", 70))
|
||
// logger.Info(decision.CoTTrace)
|
||
// logger.Infof(strings.Repeat("-", 70) + "\n")
|
||
|
||
// 7. Print AI decisions
|
||
// logger.Infof("📋 AI decision list (%d items):\n", len(kernel.Decisions))
|
||
// for i, d := range kernel.Decisions {
|
||
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
||
// if d.Action == "open_long" || d.Action == "open_short" {
|
||
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f",
|
||
// d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit)
|
||
// }
|
||
// }
|
||
logger.Info()
|
||
logger.Info(strings.Repeat("-", 70))
|
||
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
|
||
logger.Info(strings.Repeat("-", 70))
|
||
|
||
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
|
||
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
|
||
|
||
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
|
||
for i, d := range sortedDecisions {
|
||
logger.Infof(" [%d] %s %s", i+1, d.Symbol, d.Action)
|
||
}
|
||
logger.Info()
|
||
|
||
// Check if trader is stopped before executing any decisions (prevent trades after Stop())
|
||
at.isRunningMutex.RLock()
|
||
running = at.isRunning
|
||
at.isRunningMutex.RUnlock()
|
||
if !running {
|
||
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
|
||
return nil
|
||
}
|
||
|
||
// Execute decisions and record results
|
||
for _, d := range sortedDecisions {
|
||
// Check if trader is stopped before each decision (allow immediate stop during execution)
|
||
at.isRunningMutex.RLock()
|
||
running = at.isRunning
|
||
at.isRunningMutex.RUnlock()
|
||
if !running {
|
||
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
|
||
break
|
||
}
|
||
|
||
actionRecord := store.DecisionAction{
|
||
Action: d.Action,
|
||
Symbol: d.Symbol,
|
||
Quantity: 0,
|
||
Leverage: d.Leverage,
|
||
Price: 0,
|
||
StopLoss: d.StopLoss,
|
||
TakeProfit: d.TakeProfit,
|
||
Confidence: d.Confidence,
|
||
Reasoning: d.Reasoning,
|
||
Timestamp: time.Now().UTC(),
|
||
Success: false,
|
||
}
|
||
|
||
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
|
||
logger.Infof("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
|
||
actionRecord.Error = err.Error()
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err))
|
||
} else {
|
||
actionRecord.Success = true
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("✓ %s %s succeeded", d.Symbol, d.Action))
|
||
// Brief delay after successful execution
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
|
||
record.Decisions = append(record.Decisions, actionRecord)
|
||
}
|
||
|
||
// 9. Save decision record
|
||
if err := at.saveDecision(record); err != nil {
|
||
logger.Infof("⚠ Failed to save decision record: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// buildTradingContext builds trading context
|
||
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||
// 1. Get account information
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get account balance: %w", err)
|
||
}
|
||
|
||
// Get account fields
|
||
totalWalletBalance := 0.0
|
||
totalUnrealizedProfit := 0.0
|
||
availableBalance := 0.0
|
||
totalEquity := 0.0
|
||
|
||
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
||
totalWalletBalance = wallet
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
totalUnrealizedProfit = unrealized
|
||
}
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Use totalEquity directly if provided by trader (more accurate)
|
||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||
totalEquity = eq
|
||
} else {
|
||
// Fallback: Total Equity = Wallet balance + Unrealized profit
|
||
totalEquity = totalWalletBalance + totalUnrealizedProfit
|
||
}
|
||
|
||
// 2. Get position information
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||
}
|
||
|
||
var positionInfos []kernel.PositionInfo
|
||
totalMarginUsed := 0.0
|
||
|
||
// Current position key set (for cleaning up closed position records)
|
||
currentPositionKeys := make(map[string]bool)
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // Short position quantity is negative, convert to positive
|
||
}
|
||
|
||
// Skip closed positions (quantity = 0), prevent "ghost positions" from being passed to AI
|
||
if quantity == 0 {
|
||
continue
|
||
}
|
||
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||
|
||
// Calculate margin used (estimated)
|
||
leverage := 10 // Default value, should actually be fetched from position info
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
totalMarginUsed += marginUsed
|
||
|
||
// Calculate P&L percentage (based on margin, considering leverage)
|
||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||
|
||
// Get position open time from exchange (preferred) or fallback to local tracking
|
||
posKey := symbol + "_" + side
|
||
currentPositionKeys[posKey] = true
|
||
|
||
var updateTime int64
|
||
// Priority 1: Get from database (trader_positions table) - most accurate
|
||
if at.store != nil {
|
||
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
||
if dbPos.EntryTime > 0 {
|
||
updateTime = dbPos.EntryTime
|
||
}
|
||
}
|
||
}
|
||
// Priority 2: Get from exchange API (Bybit: createdTime, OKX: createdTime)
|
||
if updateTime == 0 {
|
||
if createdTime, ok := pos["createdTime"].(int64); ok && createdTime > 0 {
|
||
updateTime = createdTime
|
||
}
|
||
}
|
||
// Priority 3: Fallback to local tracking
|
||
if updateTime == 0 {
|
||
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
}
|
||
updateTime = at.positionFirstSeenTime[posKey]
|
||
}
|
||
|
||
// Get peak profit rate for this position
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnlPct := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
positionInfos = append(positionInfos, kernel.PositionInfo{
|
||
Symbol: symbol,
|
||
Side: side,
|
||
EntryPrice: entryPrice,
|
||
MarkPrice: markPrice,
|
||
Quantity: quantity,
|
||
Leverage: leverage,
|
||
UnrealizedPnL: unrealizedPnl,
|
||
UnrealizedPnLPct: pnlPct,
|
||
PeakPnLPct: peakPnlPct,
|
||
LiquidationPrice: liquidationPrice,
|
||
MarginUsed: marginUsed,
|
||
UpdateTime: updateTime,
|
||
})
|
||
}
|
||
|
||
// Clean up closed position records
|
||
for key := range at.positionFirstSeenTime {
|
||
if !currentPositionKeys[key] {
|
||
delete(at.positionFirstSeenTime, key)
|
||
}
|
||
}
|
||
|
||
// 3. Use strategy engine to get candidate coins (must have strategy engine)
|
||
var candidateCoins []kernel.CandidateCoin
|
||
if at.strategyEngine == nil {
|
||
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
|
||
} else {
|
||
coins, err := at.strategyEngine.GetCandidateCoins()
|
||
if err != nil {
|
||
// Log warning but don't fail - equity snapshot should still be saved
|
||
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
|
||
} else {
|
||
candidateCoins = coins
|
||
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
|
||
}
|
||
}
|
||
|
||
// 4. Calculate total P&L
|
||
totalPnL := totalEquity - at.initialBalance
|
||
totalPnLPct := 0.0
|
||
if at.initialBalance > 0 {
|
||
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
||
}
|
||
|
||
marginUsedPct := 0.0
|
||
if totalEquity > 0 {
|
||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||
}
|
||
|
||
// 5. Get leverage from strategy config
|
||
strategyConfig := at.strategyEngine.GetConfig()
|
||
btcEthLeverage := strategyConfig.RiskControl.BTCETHMaxLeverage
|
||
altcoinLeverage := strategyConfig.RiskControl.AltcoinMaxLeverage
|
||
logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
|
||
|
||
// 6. Build context
|
||
ctx := &kernel.Context{
|
||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||
CallCount: at.callCount,
|
||
BTCETHLeverage: btcEthLeverage,
|
||
AltcoinLeverage: altcoinLeverage,
|
||
Account: kernel.AccountInfo{
|
||
TotalEquity: totalEquity,
|
||
AvailableBalance: availableBalance,
|
||
UnrealizedPnL: totalUnrealizedProfit,
|
||
TotalPnL: totalPnL,
|
||
TotalPnLPct: totalPnLPct,
|
||
MarginUsed: totalMarginUsed,
|
||
MarginUsedPct: marginUsedPct,
|
||
PositionCount: len(positionInfos),
|
||
},
|
||
Positions: positionInfos,
|
||
CandidateCoins: candidateCoins,
|
||
}
|
||
|
||
// 7. Add recent closed trades (if store is available)
|
||
if at.store != nil {
|
||
// Get recent 10 closed trades for AI context
|
||
recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
|
||
if err != nil {
|
||
logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err)
|
||
} else {
|
||
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
|
||
for _, trade := range recentTrades {
|
||
// Convert Unix timestamps to formatted strings for AI readability
|
||
entryTimeStr := ""
|
||
if trade.EntryTime > 0 {
|
||
entryTimeStr = time.Unix(trade.EntryTime, 0).UTC().Format("01-02 15:04 UTC")
|
||
}
|
||
exitTimeStr := ""
|
||
if trade.ExitTime > 0 {
|
||
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
|
||
}
|
||
|
||
ctx.RecentOrders = append(ctx.RecentOrders, kernel.RecentOrder{
|
||
Symbol: trade.Symbol,
|
||
Side: trade.Side,
|
||
EntryPrice: trade.EntryPrice,
|
||
ExitPrice: trade.ExitPrice,
|
||
RealizedPnL: trade.RealizedPnL,
|
||
PnLPct: trade.PnLPct,
|
||
EntryTime: entryTimeStr,
|
||
ExitTime: exitTimeStr,
|
||
HoldDuration: trade.HoldDuration,
|
||
})
|
||
}
|
||
}
|
||
// Get trading statistics for AI context
|
||
stats, err := at.store.Position().GetFullStats(at.id)
|
||
if err != nil {
|
||
logger.Infof("⚠️ [%s] Failed to get trading stats: %v", at.name, err)
|
||
} else if stats == nil {
|
||
logger.Infof("⚠️ [%s] GetFullStats returned nil", at.name)
|
||
} else if stats.TotalTrades == 0 {
|
||
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
|
||
} else {
|
||
ctx.TradingStats = &kernel.TradingStats{
|
||
TotalTrades: stats.TotalTrades,
|
||
WinRate: stats.WinRate,
|
||
ProfitFactor: stats.ProfitFactor,
|
||
SharpeRatio: stats.SharpeRatio,
|
||
TotalPnL: stats.TotalPnL,
|
||
AvgWin: stats.AvgWin,
|
||
AvgLoss: stats.AvgLoss,
|
||
MaxDrawdownPct: stats.MaxDrawdownPct,
|
||
}
|
||
logger.Infof("📈 [%s] Trading stats: %d trades, %.1f%% win rate, PF=%.2f, Sharpe=%.2f, DD=%.1f%%",
|
||
at.name, stats.TotalTrades, stats.WinRate, stats.ProfitFactor, stats.SharpeRatio, stats.MaxDrawdownPct)
|
||
}
|
||
} else {
|
||
logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name)
|
||
}
|
||
|
||
// 8. Get quantitative data (if enabled in strategy config)
|
||
if strategyConfig.Indicators.EnableQuantData {
|
||
// Collect symbols to query (candidate coins + position coins)
|
||
symbolsToQuery := make(map[string]bool)
|
||
for _, coin := range candidateCoins {
|
||
symbolsToQuery[coin.Symbol] = true
|
||
}
|
||
for _, pos := range positionInfos {
|
||
symbolsToQuery[pos.Symbol] = true
|
||
}
|
||
|
||
symbols := make([]string, 0, len(symbolsToQuery))
|
||
for sym := range symbolsToQuery {
|
||
symbols = append(symbols, sym)
|
||
}
|
||
|
||
logger.Infof("📊 [%s] Fetching quantitative data for %d symbols...", at.name, len(symbols))
|
||
ctx.QuantDataMap = at.strategyEngine.FetchQuantDataBatch(symbols)
|
||
logger.Infof("📊 [%s] Successfully fetched quantitative data for %d symbols", at.name, len(ctx.QuantDataMap))
|
||
}
|
||
|
||
// 9. Get OI ranking data (market-wide position changes)
|
||
if strategyConfig.Indicators.EnableOIRanking {
|
||
logger.Infof("📊 [%s] Fetching OI ranking data...", at.name)
|
||
ctx.OIRankingData = at.strategyEngine.FetchOIRankingData()
|
||
if ctx.OIRankingData != nil {
|
||
logger.Infof("📊 [%s] OI ranking data ready: %d top, %d low positions",
|
||
at.name, len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
|
||
}
|
||
}
|
||
|
||
// 10. Get NetFlow ranking data (market-wide fund flow)
|
||
if strategyConfig.Indicators.EnableNetFlowRanking {
|
||
logger.Infof("💰 [%s] Fetching NetFlow ranking data...", at.name)
|
||
ctx.NetFlowRankingData = at.strategyEngine.FetchNetFlowRankingData()
|
||
if ctx.NetFlowRankingData != nil {
|
||
logger.Infof("💰 [%s] NetFlow ranking data ready: inst_in=%d, inst_out=%d",
|
||
at.name, len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
|
||
}
|
||
}
|
||
|
||
// 11. Get Price ranking data (market-wide gainers/losers)
|
||
if strategyConfig.Indicators.EnablePriceRanking {
|
||
logger.Infof("📈 [%s] Fetching Price ranking data...", at.name)
|
||
ctx.PriceRankingData = at.strategyEngine.FetchPriceRankingData()
|
||
if ctx.PriceRankingData != nil {
|
||
logger.Infof("📈 [%s] Price ranking data ready for %d durations",
|
||
at.name, len(ctx.PriceRankingData.Durations))
|
||
}
|
||
}
|
||
|
||
return ctx, nil
|
||
}
|
||
|
||
// executeDecisionWithRecord executes AI decision and records detailed information
|
||
func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
switch decision.Action {
|
||
case "open_long":
|
||
return at.executeOpenLongWithRecord(decision, actionRecord)
|
||
case "open_short":
|
||
return at.executeOpenShortWithRecord(decision, actionRecord)
|
||
case "close_long":
|
||
return at.executeCloseLongWithRecord(decision, actionRecord)
|
||
case "close_short":
|
||
return at.executeCloseShortWithRecord(decision, actionRecord)
|
||
case "hold", "wait":
|
||
// No execution needed, just record
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("unknown action: %s", decision.Action)
|
||
}
|
||
}
|
||
|
||
// ExecuteDecision executes a trading decision from external sources (e.g., debate consensus)
|
||
// This is a public method that can be called by other modules
|
||
func (at *AutoTrader) ExecuteDecision(d *kernel.Decision) error {
|
||
logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol)
|
||
|
||
// Create a minimal action record for tracking
|
||
actionRecord := &store.DecisionAction{
|
||
Symbol: d.Symbol,
|
||
Action: d.Action,
|
||
Leverage: d.Leverage,
|
||
StopLoss: d.StopLoss,
|
||
TakeProfit: d.TakeProfit,
|
||
Confidence: d.Confidence,
|
||
Reasoning: d.Reasoning,
|
||
}
|
||
|
||
// Execute the decision
|
||
err := at.executeDecisionWithRecord(d, actionRecord)
|
||
if err != nil {
|
||
logger.Errorf("[%s] External decision execution failed: %v", at.name, err)
|
||
return err
|
||
}
|
||
|
||
logger.Infof("[%s] External decision executed successfully: %s %s", at.name, d.Action, d.Symbol)
|
||
return nil
|
||
}
|
||
|
||
// executeOpenLongWithRecord executes open long position and records detailed information
|
||
func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
||
|
||
// ⚠️ Get current positions for multiple checks
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get positions: %w", err)
|
||
}
|
||
|
||
// [CODE ENFORCED] Check max positions limit
|
||
if err := at.enforceMaxPositions(len(positions)); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check if there's already a position in the same symbol and direction
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||
return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol)
|
||
}
|
||
}
|
||
|
||
// Get current price
|
||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Get balance (needed for multiple checks)
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get account balance: %w", err)
|
||
}
|
||
availableBalance := 0.0
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Get equity for position value ratio check
|
||
equity := 0.0
|
||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||
equity = eq
|
||
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
|
||
equity = eq
|
||
} else {
|
||
equity = availableBalance // Fallback to available balance
|
||
}
|
||
|
||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||
if wasCapped {
|
||
decision.PositionSizeUSD = adjustedPositionSize
|
||
}
|
||
|
||
// ⚠️ Auto-adjust position size if insufficient margin
|
||
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
|
||
// = positionSize * (1.01/leverage + 0.001)
|
||
marginFactor := 1.01/float64(decision.Leverage) + 0.001
|
||
maxAffordablePositionSize := availableBalance / marginFactor
|
||
|
||
actualPositionSize := decision.PositionSizeUSD
|
||
if actualPositionSize > maxAffordablePositionSize {
|
||
// Use 98% of max to leave buffer for price fluctuation
|
||
adjustedSize := maxAffordablePositionSize * 0.98
|
||
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
|
||
actualPositionSize, maxAffordablePositionSize, adjustedSize)
|
||
actualPositionSize = adjustedSize
|
||
decision.PositionSizeUSD = actualPositionSize
|
||
}
|
||
|
||
// [CODE ENFORCED] Minimum position size check
|
||
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Calculate quantity with adjusted position size
|
||
quantity := actualPositionSize / marketData.CurrentPrice
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// Set margin mode
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
|
||
// Continue execution, doesn't affect trading
|
||
}
|
||
|
||
// Open position
|
||
order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Record order ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity)
|
||
|
||
// Record order to database and poll for confirmation
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0)
|
||
|
||
// Record position opening time
|
||
posKey := decision.Symbol + "_long"
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
|
||
// Set stop loss and take profit
|
||
if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil {
|
||
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
|
||
}
|
||
if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil {
|
||
logger.Infof(" ⚠ Failed to set take profit: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// executeOpenShortWithRecord executes open short position and records detailed information
|
||
func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
||
|
||
// ⚠️ Get current positions for multiple checks
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get positions: %w", err)
|
||
}
|
||
|
||
// [CODE ENFORCED] Check max positions limit
|
||
if err := at.enforceMaxPositions(len(positions)); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check if there's already a position in the same symbol and direction
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||
return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol)
|
||
}
|
||
}
|
||
|
||
// Get current price
|
||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Get balance (needed for multiple checks)
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get account balance: %w", err)
|
||
}
|
||
availableBalance := 0.0
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Get equity for position value ratio check
|
||
equity := 0.0
|
||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||
equity = eq
|
||
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
|
||
equity = eq
|
||
} else {
|
||
equity = availableBalance // Fallback to available balance
|
||
}
|
||
|
||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||
if wasCapped {
|
||
decision.PositionSizeUSD = adjustedPositionSize
|
||
}
|
||
|
||
// ⚠️ Auto-adjust position size if insufficient margin
|
||
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
|
||
// = positionSize * (1.01/leverage + 0.001)
|
||
marginFactor := 1.01/float64(decision.Leverage) + 0.001
|
||
maxAffordablePositionSize := availableBalance / marginFactor
|
||
|
||
actualPositionSize := decision.PositionSizeUSD
|
||
if actualPositionSize > maxAffordablePositionSize {
|
||
// Use 98% of max to leave buffer for price fluctuation
|
||
adjustedSize := maxAffordablePositionSize * 0.98
|
||
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
|
||
actualPositionSize, maxAffordablePositionSize, adjustedSize)
|
||
actualPositionSize = adjustedSize
|
||
decision.PositionSizeUSD = actualPositionSize
|
||
}
|
||
|
||
// [CODE ENFORCED] Minimum position size check
|
||
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Calculate quantity with adjusted position size
|
||
quantity := actualPositionSize / marketData.CurrentPrice
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// Set margin mode
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
|
||
// Continue execution, doesn't affect trading
|
||
}
|
||
|
||
// Open position
|
||
order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Record order ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity)
|
||
|
||
// Record order to database and poll for confirmation
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0)
|
||
|
||
// Record position opening time
|
||
posKey := decision.Symbol + "_short"
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
|
||
// Set stop loss and take profit
|
||
if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil {
|
||
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
|
||
}
|
||
if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil {
|
||
logger.Infof(" ⚠ Failed to set take profit: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// executeCloseLongWithRecord executes close long position and records detailed information
|
||
func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||
|
||
// Get current price
|
||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// Normalize symbol for database lookup
|
||
normalizedSymbol := market.Normalize(decision.Symbol)
|
||
|
||
// Get entry price and quantity - prioritize local database for accurate quantity
|
||
var entryPrice float64
|
||
var quantity float64
|
||
|
||
// First try to get from local database (more accurate for quantity)
|
||
if at.store != nil {
|
||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "LONG"); err == nil && openPos != nil {
|
||
quantity = openPos.Quantity
|
||
entryPrice = openPos.EntryPrice
|
||
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
}
|
||
|
||
// Fallback to exchange API if local data not found
|
||
if quantity == 0 {
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||
if ep, ok := pos["entryPrice"].(float64); ok {
|
||
entryPrice = ep
|
||
}
|
||
if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 {
|
||
quantity = amt
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
|
||
// Close position
|
||
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Record order ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
// Record order to database and poll for confirmation
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice)
|
||
|
||
logger.Infof(" ✓ Position closed successfully")
|
||
return nil
|
||
}
|
||
|
||
// executeCloseShortWithRecord executes close short position and records detailed information
|
||
func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||
|
||
// Get current price
|
||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// Normalize symbol for database lookup
|
||
normalizedSymbol := market.Normalize(decision.Symbol)
|
||
|
||
// Get entry price and quantity - prioritize local database for accurate quantity
|
||
var entryPrice float64
|
||
var quantity float64
|
||
|
||
// First try to get from local database (more accurate for quantity)
|
||
if at.store != nil {
|
||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "SHORT"); err == nil && openPos != nil {
|
||
quantity = openPos.Quantity
|
||
entryPrice = openPos.EntryPrice
|
||
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
}
|
||
|
||
// Fallback to exchange API if local data not found
|
||
if quantity == 0 {
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||
if ep, ok := pos["entryPrice"].(float64); ok {
|
||
entryPrice = ep
|
||
}
|
||
if amt, ok := pos["positionAmt"].(float64); ok {
|
||
quantity = -amt // positionAmt is negative for short
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
|
||
// Close position
|
||
order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Record order ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
// Record order to database and poll for confirmation
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice)
|
||
|
||
logger.Infof(" ✓ Position closed successfully")
|
||
return nil
|
||
}
|
||
|
||
// GetID gets trader ID
|
||
func (at *AutoTrader) GetID() string {
|
||
return at.id
|
||
}
|
||
|
||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||
// This is used by grid trading and other components that need direct exchange access
|
||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||
return at.trader
|
||
}
|
||
|
||
// GetName gets trader name
|
||
func (at *AutoTrader) GetName() string {
|
||
return at.name
|
||
}
|
||
|
||
// GetAIModel gets AI model
|
||
func (at *AutoTrader) GetAIModel() string {
|
||
return at.aiModel
|
||
}
|
||
|
||
// GetExchange gets exchange
|
||
func (at *AutoTrader) GetExchange() string {
|
||
return at.exchange
|
||
}
|
||
|
||
// GetShowInCompetition returns whether trader should be shown in competition
|
||
func (at *AutoTrader) GetShowInCompetition() bool {
|
||
return at.showInCompetition
|
||
}
|
||
|
||
// SetShowInCompetition sets whether trader should be shown in competition
|
||
func (at *AutoTrader) SetShowInCompetition(show bool) {
|
||
at.showInCompetition = show
|
||
}
|
||
|
||
// SetCustomPrompt sets custom trading strategy prompt
|
||
func (at *AutoTrader) SetCustomPrompt(prompt string) {
|
||
at.customPrompt = prompt
|
||
}
|
||
|
||
// SetOverrideBasePrompt sets whether to override base prompt
|
||
func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
|
||
at.overrideBasePrompt = override
|
||
}
|
||
|
||
// GetSystemPromptTemplate gets current system prompt template name (from strategy config)
|
||
func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||
if at.strategyEngine != nil {
|
||
config := at.strategyEngine.GetConfig()
|
||
if config.CustomPrompt != "" {
|
||
return "custom"
|
||
}
|
||
}
|
||
return "strategy"
|
||
}
|
||
|
||
// saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision)
|
||
func (at *AutoTrader) saveEquitySnapshot(ctx *kernel.Context) {
|
||
if at.store == nil || ctx == nil {
|
||
return
|
||
}
|
||
|
||
snapshot := &store.EquitySnapshot{
|
||
TraderID: at.id,
|
||
Timestamp: time.Now().UTC(),
|
||
TotalEquity: ctx.Account.TotalEquity,
|
||
Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
|
||
UnrealizedPnL: ctx.Account.UnrealizedPnL,
|
||
PositionCount: ctx.Account.PositionCount,
|
||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||
}
|
||
|
||
if err := at.store.Equity().Save(snapshot); err != nil {
|
||
logger.Infof("⚠️ Failed to save equity snapshot: %v", err)
|
||
}
|
||
}
|
||
|
||
// saveDecision saves AI decision log to database (only records AI input/output, for debugging)
|
||
func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error {
|
||
if at.store == nil {
|
||
return nil
|
||
}
|
||
|
||
at.cycleNumber++
|
||
record.CycleNumber = at.cycleNumber
|
||
record.TraderID = at.id
|
||
|
||
if record.Timestamp.IsZero() {
|
||
record.Timestamp = time.Now().UTC()
|
||
}
|
||
|
||
if err := at.store.Decision().LogDecision(record); err != nil {
|
||
logger.Infof("⚠️ Failed to save decision record: %v", err)
|
||
return err
|
||
}
|
||
|
||
logger.Infof("📝 Decision record saved: trader=%s, cycle=%d", at.id, at.cycleNumber)
|
||
return nil
|
||
}
|
||
|
||
// GetStore gets data store (for external access to decision records, etc.)
|
||
func (at *AutoTrader) GetStore() *store.Store {
|
||
return at.store
|
||
}
|
||
|
||
// GetStatus gets system status (for API)
|
||
func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||
aiProvider := "DeepSeek"
|
||
if at.config.UseQwen {
|
||
aiProvider = "Qwen"
|
||
}
|
||
|
||
at.isRunningMutex.RLock()
|
||
isRunning := at.isRunning
|
||
at.isRunningMutex.RUnlock()
|
||
|
||
result := map[string]interface{}{
|
||
"trader_id": at.id,
|
||
"trader_name": at.name,
|
||
"ai_model": at.aiModel,
|
||
"exchange": at.exchange,
|
||
"is_running": isRunning,
|
||
"start_time": at.startTime.Format(time.RFC3339),
|
||
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
||
"call_count": at.callCount,
|
||
"initial_balance": at.initialBalance,
|
||
"scan_interval": at.config.ScanInterval.String(),
|
||
"stop_until": at.stopUntil.Format(time.RFC3339),
|
||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||
"ai_provider": aiProvider,
|
||
}
|
||
|
||
// Add strategy info
|
||
if at.config.StrategyConfig != nil {
|
||
result["strategy_type"] = at.config.StrategyConfig.StrategyType
|
||
if at.config.StrategyConfig.GridConfig != nil {
|
||
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// GetAccountInfo gets account information (for API)
|
||
func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get balance: %w", err)
|
||
}
|
||
|
||
// Get account fields
|
||
totalWalletBalance := 0.0
|
||
totalUnrealizedProfit := 0.0
|
||
availableBalance := 0.0
|
||
totalEquity := 0.0
|
||
|
||
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
||
totalWalletBalance = wallet
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
totalUnrealizedProfit = unrealized
|
||
}
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Use totalEquity directly if provided by trader (more accurate)
|
||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||
totalEquity = eq
|
||
} else {
|
||
// Fallback: Total Equity = Wallet balance + Unrealized profit
|
||
totalEquity = totalWalletBalance + totalUnrealizedProfit
|
||
}
|
||
|
||
// Get positions to calculate total margin
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||
}
|
||
|
||
totalMarginUsed := 0.0
|
||
totalUnrealizedPnLCalculated := 0.0
|
||
for _, pos := range positions {
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity
|
||
}
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
totalUnrealizedPnLCalculated += unrealizedPnl
|
||
|
||
leverage := 10
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
totalMarginUsed += marginUsed
|
||
}
|
||
|
||
// Verify unrealized P&L consistency (API value vs calculated from positions)
|
||
// Note: Lighter API may return 0 for unrealized PnL, this is a known limitation
|
||
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
||
if diff > 5.0 { // Only warn if difference is significant (> 5 USDT)
|
||
logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f",
|
||
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
||
}
|
||
|
||
totalPnL := totalEquity - at.initialBalance
|
||
totalPnLPct := 0.0
|
||
if at.initialBalance > 0 {
|
||
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
||
} else {
|
||
logger.Infof("⚠️ Initial Balance abnormal: %.2f, cannot calculate P&L percentage", at.initialBalance)
|
||
}
|
||
|
||
marginUsedPct := 0.0
|
||
if totalEquity > 0 {
|
||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
// Core fields
|
||
"total_equity": totalEquity, // Account equity = wallet + unrealized
|
||
"wallet_balance": totalWalletBalance, // Wallet balance (excluding unrealized P&L)
|
||
"unrealized_profit": totalUnrealizedProfit, // Unrealized P&L (official value from exchange API)
|
||
"available_balance": availableBalance, // Available balance
|
||
|
||
// P&L statistics
|
||
"total_pnl": totalPnL, // Total P&L = equity - initial
|
||
"total_pnl_pct": totalPnLPct, // Total P&L percentage
|
||
"initial_balance": at.initialBalance, // Initial balance
|
||
"daily_pnl": at.dailyPnL, // Daily P&L
|
||
|
||
// Position information
|
||
"position_count": len(positions), // Position count
|
||
"margin_used": totalMarginUsed, // Margin used
|
||
"margin_used_pct": marginUsedPct, // Margin usage rate
|
||
}, nil
|
||
}
|
||
|
||
// GetPositions gets position list (for API)
|
||
func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||
}
|
||
|
||
var result []map[string]interface{}
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity
|
||
}
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||
|
||
leverage := 10
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
// Calculate margin used
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
|
||
// Calculate P&L percentage (based on margin)
|
||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||
|
||
result = append(result, map[string]interface{}{
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"entry_price": entryPrice,
|
||
"mark_price": markPrice,
|
||
"quantity": quantity,
|
||
"leverage": leverage,
|
||
"unrealized_pnl": unrealizedPnl,
|
||
"unrealized_pnl_pct": pnlPct,
|
||
"liquidation_price": liquidationPrice,
|
||
"margin_used": marginUsed,
|
||
})
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// calculatePnLPercentage calculates P&L percentage (based on margin, automatically considers leverage)
|
||
// Return rate = Unrealized P&L / Margin × 100%
|
||
func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
|
||
if marginUsed > 0 {
|
||
return (unrealizedPnl / marginUsed) * 100
|
||
}
|
||
return 0.0
|
||
}
|
||
|
||
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
|
||
// This avoids position stacking overflow when changing positions
|
||
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||
if len(decisions) <= 1 {
|
||
return decisions
|
||
}
|
||
|
||
// Define priority
|
||
getActionPriority := func(action string) int {
|
||
switch action {
|
||
case "close_long", "close_short":
|
||
return 1 // Highest priority: close positions first
|
||
case "open_long", "open_short":
|
||
return 2 // Second priority: open positions later
|
||
case "hold", "wait":
|
||
return 3 // Lowest priority: wait
|
||
default:
|
||
return 999 // Unknown actions at the end
|
||
}
|
||
}
|
||
|
||
// Copy decision list
|
||
sorted := make([]kernel.Decision, len(decisions))
|
||
copy(sorted, decisions)
|
||
|
||
// Sort by priority
|
||
for i := 0; i < len(sorted)-1; i++ {
|
||
for j := i + 1; j < len(sorted); j++ {
|
||
if getActionPriority(sorted[i].Action) > getActionPriority(sorted[j].Action) {
|
||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
return sorted
|
||
}
|
||
|
||
// startDrawdownMonitor starts drawdown monitoring
|
||
func (at *AutoTrader) startDrawdownMonitor() {
|
||
at.monitorWg.Add(1)
|
||
go func() {
|
||
defer at.monitorWg.Done()
|
||
|
||
ticker := time.NewTicker(1 * time.Minute) // Check every minute
|
||
defer ticker.Stop()
|
||
|
||
logger.Info("📊 Started position drawdown monitoring (check every minute)")
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
at.checkPositionDrawdown()
|
||
case <-at.stopMonitorCh:
|
||
logger.Info("⏹ Stopped position drawdown monitoring")
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// checkPositionDrawdown checks position drawdown situation
|
||
func (at *AutoTrader) checkPositionDrawdown() {
|
||
// Get current positions
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err)
|
||
return
|
||
}
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // Short position quantity is negative, convert to positive
|
||
}
|
||
|
||
// Calculate current P&L percentage
|
||
leverage := 10 // Default value
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
var currentPnLPct float64
|
||
if side == "long" {
|
||
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
|
||
} else {
|
||
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
|
||
}
|
||
|
||
// Construct unique position identifier (distinguish long/short)
|
||
posKey := symbol + "_" + side
|
||
|
||
// Get historical peak profit for this position
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnLPct, exists := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
if !exists {
|
||
// If no historical peak record, use current P&L as initial value
|
||
peakPnLPct = currentPnLPct
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
} else {
|
||
// Update peak cache
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
}
|
||
|
||
// Calculate drawdown (magnitude of decline from peak)
|
||
var drawdownPct float64
|
||
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
|
||
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
||
}
|
||
|
||
// Check close position condition: profit > 5% and drawdown >= 40%
|
||
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
|
||
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
|
||
// Execute close position
|
||
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
||
logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err)
|
||
} else {
|
||
logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side)
|
||
// Clear cache for this position after closing
|
||
at.ClearPeakPnLCache(symbol, side)
|
||
}
|
||
} else if currentPnLPct > 5.0 {
|
||
// Record situations close to close position condition (for debugging)
|
||
logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
}
|
||
}
|
||
}
|
||
|
||
// emergencyClosePosition emergency close position function
|
||
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
|
||
switch side {
|
||
case "long":
|
||
order, err := at.trader.CloseLong(symbol, 0) // 0 = close all
|
||
if err != nil {
|
||
return err
|
||
}
|
||
logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"])
|
||
case "short":
|
||
order, err := at.trader.CloseShort(symbol, 0) // 0 = close all
|
||
if err != nil {
|
||
return err
|
||
}
|
||
logger.Infof("✅ Emergency close short position succeeded, order ID: %v", order["orderId"])
|
||
default:
|
||
return fmt.Errorf("unknown position direction: %s", side)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetPeakPnLCache gets peak profit cache
|
||
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
|
||
at.peakPnLCacheMutex.RLock()
|
||
defer at.peakPnLCacheMutex.RUnlock()
|
||
|
||
// Return a copy of the cache
|
||
cache := make(map[string]float64)
|
||
for k, v := range at.peakPnLCache {
|
||
cache[k] = v
|
||
}
|
||
return cache
|
||
}
|
||
|
||
// UpdatePeakPnL updates peak profit cache
|
||
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
if peak, exists := at.peakPnLCache[posKey]; exists {
|
||
// Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare)
|
||
if currentPnLPct > peak {
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
} else {
|
||
// First time recording
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
}
|
||
|
||
// ClearPeakPnLCache clears peak cache for specified position
|
||
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
delete(at.peakPnLCache, posKey)
|
||
}
|
||
|
||
// recordAndConfirmOrder polls order status for actual fill data and records position
|
||
// action: open_long, open_short, close_long, close_short
|
||
// entryPrice: entry price when closing (0 when opening)
|
||
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
|
||
if at.store == nil {
|
||
return
|
||
}
|
||
|
||
// Get order ID (supports multiple types)
|
||
var orderID string
|
||
switch v := orderResult["orderId"].(type) {
|
||
case int64:
|
||
orderID = fmt.Sprintf("%d", v)
|
||
case float64:
|
||
orderID = fmt.Sprintf("%.0f", v)
|
||
case string:
|
||
orderID = v
|
||
default:
|
||
orderID = fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
if orderID == "" || orderID == "0" {
|
||
logger.Infof(" ⚠️ Order ID is empty, skipping record")
|
||
return
|
||
}
|
||
|
||
// Determine positionSide
|
||
var positionSide string
|
||
switch action {
|
||
case "open_long", "close_long":
|
||
positionSide = "LONG"
|
||
case "open_short", "close_short":
|
||
positionSide = "SHORT"
|
||
}
|
||
|
||
var actualPrice = price
|
||
var actualQty = quantity
|
||
var fee float64
|
||
|
||
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
||
// This ensures accurate data from GetTrades API and avoids duplicate records
|
||
switch at.exchange {
|
||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
|
||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||
return
|
||
}
|
||
|
||
// For exchanges without OrderSync (e.g., Binance): record immediately and poll for fill data
|
||
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
|
||
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||
} else {
|
||
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
|
||
}
|
||
|
||
// Wait for order to be filled and get actual fill data
|
||
time.Sleep(500 * time.Millisecond)
|
||
for i := 0; i < 5; i++ {
|
||
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
||
if err == nil {
|
||
statusStr, _ := status["status"].(string)
|
||
if statusStr == "FILLED" {
|
||
// Get actual fill price
|
||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||
actualPrice = avgPrice
|
||
}
|
||
// Get actual executed quantity
|
||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||
actualQty = execQty
|
||
}
|
||
// Get commission/fee
|
||
if commission, ok := status["commission"].(float64); ok {
|
||
fee = commission
|
||
}
|
||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||
|
||
// Update order status to FILLED
|
||
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||
}
|
||
|
||
// Record fill details
|
||
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
|
||
break
|
||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
||
|
||
// Update order status
|
||
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
|
||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
|
||
// Normalize symbol for position record consistency
|
||
normalizedSymbolForPosition := market.Normalize(symbol)
|
||
|
||
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
|
||
orderID, action, actualPrice, actualQty, fee)
|
||
|
||
// Record position change with actual fill data (use normalized symbol)
|
||
at.recordPositionChange(orderID, normalizedSymbolForPosition, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
|
||
|
||
// Send anonymous trade statistics for experience improvement (async, non-blocking)
|
||
// This helps us understand overall product usage across all deployments
|
||
experience.TrackTrade(experience.TradeEvent{
|
||
Exchange: at.exchange,
|
||
TradeType: action,
|
||
Symbol: symbol,
|
||
AmountUSD: actualPrice * actualQty,
|
||
Leverage: leverage,
|
||
UserID: at.userID,
|
||
TraderID: at.id,
|
||
})
|
||
}
|
||
|
||
// recordPositionChange records position change (create record on open, update record on close)
|
||
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) {
|
||
if at.store == nil {
|
||
return
|
||
}
|
||
|
||
switch action {
|
||
case "open_long", "open_short":
|
||
// Open position: create new position record
|
||
nowMs := time.Now().UTC().UnixMilli()
|
||
pos := &store.TraderPosition{
|
||
TraderID: at.id,
|
||
ExchangeID: at.exchangeID, // Exchange account UUID
|
||
ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc
|
||
Symbol: symbol,
|
||
Side: side, // LONG or SHORT
|
||
Quantity: quantity,
|
||
EntryPrice: price,
|
||
EntryOrderID: orderID,
|
||
EntryTime: nowMs,
|
||
Leverage: leverage,
|
||
Status: "OPEN",
|
||
CreatedAt: nowMs,
|
||
UpdatedAt: nowMs,
|
||
}
|
||
if err := at.store.Position().Create(pos); err != nil {
|
||
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
||
} else {
|
||
logger.Infof(" 📊 Position recorded [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
|
||
}
|
||
|
||
case "close_long", "close_short":
|
||
// Close position using PositionBuilder for consistent handling
|
||
// PositionBuilder will handle both cases:
|
||
// 1. If open position exists: close it properly
|
||
// 2. If no open position (e.g., table cleared): create a closed position record
|
||
posBuilder := store.NewPositionBuilder(at.store.Position())
|
||
if err := posBuilder.ProcessTrade(
|
||
at.id, at.exchangeID, at.exchange,
|
||
symbol, side, action,
|
||
quantity, price, fee, 0, // realizedPnL will be calculated
|
||
time.Now().UTC().UnixMilli(), orderID,
|
||
); err != nil {
|
||
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
||
} else {
|
||
logger.Infof(" ✅ Position closed [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
|
||
}
|
||
}
|
||
}
|
||
|
||
// createOrderRecord creates an order record struct from order details
|
||
func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide string, quantity, price float64, leverage int) *store.TraderOrder {
|
||
// Determine order type (market for auto trader)
|
||
orderType := "MARKET"
|
||
|
||
// Determine side (BUY/SELL)
|
||
var side string
|
||
switch action {
|
||
case "open_long", "close_short":
|
||
side = "BUY"
|
||
case "open_short", "close_long":
|
||
side = "SELL"
|
||
}
|
||
|
||
// Use action as orderAction directly (keep lowercase format)
|
||
orderAction := action
|
||
|
||
// Determine if it's a reduce only order
|
||
reduceOnly := (action == "close_long" || action == "close_short")
|
||
|
||
// Normalize symbol for consistency
|
||
normalizedSymbol := market.Normalize(symbol)
|
||
|
||
return &store.TraderOrder{
|
||
TraderID: at.id,
|
||
ExchangeID: at.exchangeID,
|
||
ExchangeType: at.exchange,
|
||
ExchangeOrderID: orderID,
|
||
Symbol: normalizedSymbol,
|
||
Side: side,
|
||
PositionSide: positionSide,
|
||
Type: orderType,
|
||
TimeInForce: "GTC",
|
||
Quantity: quantity,
|
||
Price: price,
|
||
Status: "NEW",
|
||
FilledQuantity: 0,
|
||
AvgFillPrice: 0,
|
||
Commission: 0,
|
||
CommissionAsset: "USDT",
|
||
Leverage: leverage,
|
||
ReduceOnly: reduceOnly,
|
||
ClosePosition: reduceOnly,
|
||
OrderAction: orderAction,
|
||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||
}
|
||
}
|
||
|
||
// recordOrderFill records order fill/trade details
|
||
func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symbol, action string, price, quantity, fee float64) {
|
||
if at.store == nil {
|
||
return
|
||
}
|
||
|
||
// Determine side (BUY/SELL)
|
||
var side string
|
||
switch action {
|
||
case "open_long", "close_short":
|
||
side = "BUY"
|
||
case "open_short", "close_long":
|
||
side = "SELL"
|
||
}
|
||
|
||
// Generate a simple trade ID (exchange doesn't always provide one)
|
||
tradeID := fmt.Sprintf("%s-%d", exchangeOrderID, time.Now().UnixNano())
|
||
|
||
// Normalize symbol for consistency
|
||
normalizedSymbol := market.Normalize(symbol)
|
||
|
||
fill := &store.TraderFill{
|
||
TraderID: at.id,
|
||
ExchangeID: at.exchangeID,
|
||
ExchangeType: at.exchange,
|
||
OrderID: orderRecordID,
|
||
ExchangeOrderID: exchangeOrderID,
|
||
ExchangeTradeID: tradeID,
|
||
Symbol: normalizedSymbol,
|
||
Side: side,
|
||
Price: price,
|
||
Quantity: quantity,
|
||
QuoteQuantity: price * quantity,
|
||
Commission: fee,
|
||
CommissionAsset: "USDT",
|
||
RealizedPnL: 0, // Will be calculated for close orders
|
||
IsMaker: false, // Market orders are usually taker
|
||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||
}
|
||
|
||
// Calculate realized PnL for close orders
|
||
if action == "close_long" || action == "close_short" {
|
||
// Try to get the entry price from the open position
|
||
var positionSide string
|
||
if action == "close_long" {
|
||
positionSide = "LONG"
|
||
} else {
|
||
positionSide = "SHORT"
|
||
}
|
||
|
||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, positionSide); err == nil && openPos != nil {
|
||
if positionSide == "LONG" {
|
||
fill.RealizedPnL = (price - openPos.EntryPrice) * quantity
|
||
} else {
|
||
fill.RealizedPnL = (openPos.EntryPrice - price) * quantity
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := at.store.Order().CreateFill(fill); err != nil {
|
||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||
} else {
|
||
logger.Infof(" 📋 Fill recorded: %.4f @ %.6f, fee: %.4f", quantity, price, fee)
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Risk Control Helpers
|
||
// ============================================================================
|
||
|
||
// isBTCETH checks if a symbol is BTC or ETH
|
||
func isBTCETH(symbol string) bool {
|
||
symbol = strings.ToUpper(symbol)
|
||
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
|
||
}
|
||
|
||
// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED)
|
||
// Returns the adjusted position size (capped if necessary) and whether the position was capped
|
||
// positionSizeUSD: the original position size in USD
|
||
// equity: the account equity
|
||
// symbol: the trading symbol
|
||
func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) {
|
||
if at.config.StrategyConfig == nil {
|
||
return positionSizeUSD, false
|
||
}
|
||
|
||
riskControl := at.config.StrategyConfig.RiskControl
|
||
|
||
// Get the appropriate position value ratio limit
|
||
var maxPositionValueRatio float64
|
||
if isBTCETH(symbol) {
|
||
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
|
||
if maxPositionValueRatio <= 0 {
|
||
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH
|
||
}
|
||
} else {
|
||
maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio
|
||
if maxPositionValueRatio <= 0 {
|
||
maxPositionValueRatio = 1.0 // Default: 1x for altcoins
|
||
}
|
||
}
|
||
|
||
// Calculate max allowed position value = equity × ratio
|
||
maxPositionValue := equity * maxPositionValueRatio
|
||
|
||
// Check if position size exceeds limit
|
||
if positionSizeUSD > maxPositionValue {
|
||
logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping",
|
||
positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol)
|
||
return maxPositionValue, true
|
||
}
|
||
|
||
return positionSizeUSD, false
|
||
}
|
||
|
||
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
|
||
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
|
||
if at.config.StrategyConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
minSize := at.config.StrategyConfig.RiskControl.MinPositionSize
|
||
if minSize <= 0 {
|
||
minSize = 12 // Default: 12 USDT
|
||
}
|
||
|
||
if positionSizeUSD < minSize {
|
||
return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// enforceMaxPositions checks maximum positions count (CODE ENFORCED)
|
||
func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
|
||
if at.config.StrategyConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions
|
||
if maxPositions <= 0 {
|
||
maxPositions = 3 // Default: 3 positions
|
||
}
|
||
|
||
if currentPositionCount >= maxPositions {
|
||
return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// getSideFromAction converts order action to side (BUY/SELL)
|
||
func getSideFromAction(action string) string {
|
||
switch action {
|
||
case "open_long", "close_short":
|
||
return "BUY"
|
||
case "open_short", "close_long":
|
||
return "SELL"
|
||
default:
|
||
return "BUY"
|
||
}
|
||
}
|
||
|
||
// GetOpenOrders returns open orders (pending SL/TP) from exchange
|
||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||
return at.trader.GetOpenOrders(symbol)
|
||
}
|