mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-29 17:11:22 +08:00
Compare commits
40 Commits
moltbot-no
...
ai-grid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b56a98bf | ||
|
|
e5f69bfea6 | ||
|
|
e198498f3a | ||
|
|
993db33466 | ||
|
|
7f24a90851 | ||
|
|
19698529b8 | ||
|
|
35fcf17df4 | ||
|
|
2b1012b85b | ||
|
|
826276f58c | ||
|
|
587efba52c | ||
|
|
4642671e77 | ||
|
|
bd8cc9c176 | ||
|
|
7d7493b576 | ||
|
|
cbe753b9e6 | ||
|
|
5c79aa451e | ||
|
|
0a2c62885b | ||
|
|
ac25dd334e | ||
|
|
f4cdf2e532 | ||
|
|
f6411f05ba | ||
|
|
38be361eca | ||
|
|
584bfae699 | ||
|
|
73789f7fb7 | ||
|
|
65f333e73c | ||
|
|
1454ad3112 | ||
|
|
ec81384b7a | ||
|
|
c161632e2b | ||
|
|
8ef6045f9d | ||
|
|
d7d9dc5c42 | ||
|
|
90509ae783 | ||
|
|
937527281e | ||
|
|
2bc45827f3 | ||
|
|
68e8a6e4b0 | ||
|
|
aa7aa94275 | ||
|
|
13189fa3aa | ||
|
|
33cf09e7fe | ||
|
|
ef91bec2dd | ||
|
|
2fcbdbab36 | ||
|
|
1786f0ff53 | ||
|
|
1b47249d57 | ||
|
|
5fb26c17dc |
47
.env.example
47
.env.example
@@ -49,51 +49,12 @@ RSA_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END RSA PRI
|
||||
TRANSPORT_ENCRYPTION=false
|
||||
|
||||
# ===========================================
|
||||
# Telegram AI Assistant (NEW - moltbot-nofx)
|
||||
# Optional: External Services
|
||||
# ===========================================
|
||||
|
||||
# Telegram Bot Token (get from @BotFather)
|
||||
# This enables the AI trading assistant via Telegram
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Allowed users (comma-separated Telegram user IDs)
|
||||
# Leave empty to allow all users (not recommended for production)
|
||||
# Get your ID from @userinfobot
|
||||
TELEGRAM_ALLOWED_USERS=
|
||||
|
||||
# Admin users (comma-separated Telegram user IDs)
|
||||
# Admins can manage bot settings
|
||||
TELEGRAM_ADMIN_USERS=
|
||||
|
||||
# Rate limit (messages per minute per user)
|
||||
TELEGRAM_RATE_LIMIT=30
|
||||
|
||||
# Default language: "en" or "zh"
|
||||
TELEGRAM_LANGUAGE=zh
|
||||
|
||||
# ===========================================
|
||||
# AI Model Configuration (for Assistant)
|
||||
# ===========================================
|
||||
|
||||
# DeepSeek (recommended - cost-effective)
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_API_URL=
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
|
||||
# Claude (optional alternative)
|
||||
CLAUDE_API_KEY=
|
||||
CLAUDE_API_URL=
|
||||
CLAUDE_MODEL=
|
||||
|
||||
# OpenAI (optional alternative)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_API_URL=
|
||||
OPENAI_MODEL=
|
||||
|
||||
# Qwen (optional alternative)
|
||||
QWEN_API_KEY=
|
||||
QWEN_API_URL=
|
||||
QWEN_MODEL=
|
||||
# Telegram notifications (optional)
|
||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
# TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=10.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -124,4 +124,3 @@ dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
PR_DESCRIPTION.md
|
||||
nofx-moltbot
|
||||
|
||||
20
README.md
20
README.md
@@ -488,26 +488,6 @@ All contributions are tracked on GitHub. When NOFX generates revenue, contributo
|
||||
|
||||
---
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to all our sponsors!
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="60" height="60" style="border-radius:50%" alt="pjl914335852-ux" /></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="60" height="60" style="border-radius:50%" alt="cat9999aaa" /></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="60" height="60" style="border-radius:50%" alt="1733055465" /></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="60" height="60" style="border-radius:50%" alt="kolal2020" /></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="60" height="60" style="border-radius:50%" alt="CyberFFarm" /></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="60" height="60" style="border-radius:50%" alt="vip3001003" /></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="60" height="60" style="border-radius:50%" alt="mrtluh" /></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="60" height="60" style="border-radius:50%" alt="cpcp1117-source" /></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="60" height="60" style="border-radius:50%" alt="match-007" /></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="60" height="60" style="border-radius:50%" alt="leiwuhen1715" /></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="60" height="60" style="border-radius:50%" alt="SHAOXIA1991" /></a>
|
||||
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
// Package assistant implements the AI Agent runtime with tool calling capabilities
|
||||
// Inspired by moltbot's agent architecture, specialized for trading
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Agent represents an AI assistant with tool-calling capabilities
|
||||
type Agent struct {
|
||||
// AI client for LLM calls
|
||||
aiClient mcp.AIClient
|
||||
|
||||
// Tool registry
|
||||
tools map[string]Tool
|
||||
toolsLock sync.RWMutex
|
||||
|
||||
// Session/memory management
|
||||
sessions map[string]*Session
|
||||
sessionsLock sync.RWMutex
|
||||
|
||||
// Configuration
|
||||
config AgentConfig
|
||||
|
||||
// System prompt
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
// AgentConfig holds agent configuration
|
||||
type AgentConfig struct {
|
||||
// Max tool calls per turn (prevent infinite loops)
|
||||
MaxToolCalls int `json:"max_tool_calls"`
|
||||
|
||||
// Max conversation history to keep
|
||||
MaxHistoryMessages int `json:"max_history_messages"`
|
||||
|
||||
// Timeout for single AI call
|
||||
AITimeout time.Duration `json:"ai_timeout"`
|
||||
|
||||
// Model to use
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// DefaultAgentConfig returns sensible defaults
|
||||
func DefaultAgentConfig() AgentConfig {
|
||||
return AgentConfig{
|
||||
MaxToolCalls: 10,
|
||||
MaxHistoryMessages: 50,
|
||||
AITimeout: 120 * time.Second,
|
||||
Model: "deepseek-chat",
|
||||
}
|
||||
}
|
||||
|
||||
// NewAgent creates a new AI agent
|
||||
func NewAgent(aiClient mcp.AIClient, config AgentConfig) *Agent {
|
||||
agent := &Agent{
|
||||
aiClient: aiClient,
|
||||
tools: make(map[string]Tool),
|
||||
sessions: make(map[string]*Session),
|
||||
config: config,
|
||||
}
|
||||
|
||||
// Set default system prompt
|
||||
agent.systemPrompt = DefaultTradingSystemPrompt()
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// RegisterTool adds a tool to the agent's toolkit
|
||||
func (a *Agent) RegisterTool(tool Tool) {
|
||||
a.toolsLock.Lock()
|
||||
defer a.toolsLock.Unlock()
|
||||
a.tools[tool.Name()] = tool
|
||||
logger.Infof("🔧 Registered tool: %s", tool.Name())
|
||||
}
|
||||
|
||||
// RegisterTools adds multiple tools
|
||||
func (a *Agent) RegisterTools(tools ...Tool) {
|
||||
for _, tool := range tools {
|
||||
a.RegisterTool(tool)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSystemPrompt sets the agent's system prompt
|
||||
func (a *Agent) SetSystemPrompt(prompt string) {
|
||||
a.systemPrompt = prompt
|
||||
}
|
||||
|
||||
// GetSession returns or creates a session for the given ID
|
||||
func (a *Agent) GetSession(sessionID string) *Session {
|
||||
a.sessionsLock.Lock()
|
||||
defer a.sessionsLock.Unlock()
|
||||
|
||||
if session, ok := a.sessions[sessionID]; ok {
|
||||
return session
|
||||
}
|
||||
|
||||
session := NewSession(sessionID, a.config.MaxHistoryMessages)
|
||||
a.sessions[sessionID] = session
|
||||
return session
|
||||
}
|
||||
|
||||
// Chat processes a user message and returns the agent's response
|
||||
// This is the main entry point for the agent loop
|
||||
func (a *Agent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
|
||||
session := a.GetSession(sessionID)
|
||||
|
||||
// Add user message to history
|
||||
session.AddMessage(Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
// Build the full prompt with tools
|
||||
systemPrompt := a.buildSystemPromptWithTools()
|
||||
conversationPrompt := a.buildConversationPrompt(session)
|
||||
|
||||
// Agent loop - keep calling AI until it's done or max iterations
|
||||
var finalResponse string
|
||||
toolCallCount := 0
|
||||
|
||||
for {
|
||||
// Check context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Check max tool calls
|
||||
if toolCallCount >= a.config.MaxToolCalls {
|
||||
logger.Warnf("⚠️ Max tool calls reached (%d), stopping agent loop", a.config.MaxToolCalls)
|
||||
break
|
||||
}
|
||||
|
||||
// Call AI
|
||||
response, err := a.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse response for tool calls
|
||||
toolCalls, textResponse, err := a.parseResponse(response)
|
||||
if err != nil {
|
||||
// If parsing fails, treat entire response as text
|
||||
finalResponse = response
|
||||
break
|
||||
}
|
||||
|
||||
// If no tool calls, we're done
|
||||
if len(toolCalls) == 0 {
|
||||
finalResponse = textResponse
|
||||
break
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
toolResults := a.executeToolCalls(ctx, toolCalls)
|
||||
toolCallCount += len(toolCalls)
|
||||
|
||||
// Add tool calls and results to conversation for next iteration
|
||||
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, please provide your response to the user:",
|
||||
formatToolCalls(toolCalls),
|
||||
formatToolResults(toolResults))
|
||||
|
||||
// If there's also a text response, capture it
|
||||
if textResponse != "" {
|
||||
finalResponse = textResponse
|
||||
}
|
||||
}
|
||||
|
||||
// Add assistant response to history
|
||||
session.AddMessage(Message{
|
||||
Role: "assistant",
|
||||
Content: finalResponse,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
return &AgentResponse{
|
||||
Text: finalResponse,
|
||||
SessionID: sessionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildSystemPromptWithTools creates the system prompt including tool definitions
|
||||
func (a *Agent) buildSystemPromptWithTools() string {
|
||||
a.toolsLock.RLock()
|
||||
defer a.toolsLock.RUnlock()
|
||||
|
||||
var toolDefs []string
|
||||
for _, tool := range a.tools {
|
||||
toolDef := fmt.Sprintf(`- **%s**: %s
|
||||
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
|
||||
toolDefs = append(toolDefs, toolDef)
|
||||
}
|
||||
|
||||
toolsSection := ""
|
||||
if len(toolDefs) > 0 {
|
||||
toolsSection = fmt.Sprintf(`
|
||||
|
||||
## Available Tools
|
||||
|
||||
You can call tools by responding with JSON in this format:
|
||||
{"tool_calls": [{"name": "tool_name", "arguments": {"param": "value"}}]}
|
||||
|
||||
After receiving tool results, provide a natural language response to the user.
|
||||
|
||||
Tools:
|
||||
%s
|
||||
`, strings.Join(toolDefs, "\n"))
|
||||
}
|
||||
|
||||
return a.systemPrompt + toolsSection
|
||||
}
|
||||
|
||||
// buildConversationPrompt builds the conversation history as a prompt
|
||||
func (a *Agent) buildConversationPrompt(session *Session) string {
|
||||
messages := session.GetMessages()
|
||||
var parts []string
|
||||
|
||||
for _, msg := range messages {
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", strings.Title(msg.Role), msg.Content))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// parseResponse extracts tool calls and text from AI response
|
||||
func (a *Agent) parseResponse(response string) ([]ToolCall, string, error) {
|
||||
// Try to find JSON tool calls in response
|
||||
// Look for {"tool_calls": [...]} pattern
|
||||
|
||||
var toolCalls []ToolCall
|
||||
textResponse := response
|
||||
|
||||
// Try to parse as JSON
|
||||
if strings.Contains(response, "tool_calls") {
|
||||
// Find JSON block
|
||||
start := strings.Index(response, "{")
|
||||
end := strings.LastIndex(response, "}")
|
||||
|
||||
if start >= 0 && end > start {
|
||||
jsonStr := response[start : end+1]
|
||||
|
||||
var parsed struct {
|
||||
ToolCalls []struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err == nil {
|
||||
for _, tc := range parsed.ToolCalls {
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract text before/after JSON
|
||||
textResponse = strings.TrimSpace(response[:start] + response[end+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls, textResponse, nil
|
||||
}
|
||||
|
||||
// executeToolCalls runs the requested tools
|
||||
func (a *Agent) executeToolCalls(ctx context.Context, calls []ToolCall) []ToolResult {
|
||||
a.toolsLock.RLock()
|
||||
defer a.toolsLock.RUnlock()
|
||||
|
||||
var results []ToolResult
|
||||
|
||||
for _, call := range calls {
|
||||
tool, ok := a.tools[call.Name]
|
||||
if !ok {
|
||||
results = append(results, ToolResult{
|
||||
Name: call.Name,
|
||||
Error: fmt.Sprintf("unknown tool: %s", call.Name),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("🔧 Executing tool: %s", call.Name)
|
||||
|
||||
result, err := tool.Execute(ctx, call.Arguments)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ Tool %s failed: %v", call.Name, err)
|
||||
results = append(results, ToolResult{
|
||||
Name: call.Name,
|
||||
Error: err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.Infof("✅ Tool %s completed", call.Name)
|
||||
results = append(results, ToolResult{
|
||||
Name: call.Name,
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ToolCall represents a tool invocation request from the AI
|
||||
type ToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
Name string `json:"name"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AgentResponse is the final response from the agent
|
||||
type AgentResponse struct {
|
||||
Text string `json:"text"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func formatToolCalls(calls []ToolCall) string {
|
||||
var parts []string
|
||||
for _, c := range calls {
|
||||
parts = append(parts, fmt.Sprintf("- %s(%s)", c.Name, string(c.Arguments)))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func formatToolResults(results []ToolResult) string {
|
||||
var parts []string
|
||||
for _, r := range results {
|
||||
if r.Error != "" {
|
||||
parts = append(parts, fmt.Sprintf("- %s: ERROR: %s", r.Name, r.Error))
|
||||
} else {
|
||||
resultJSON, _ := json.Marshal(r.Result)
|
||||
parts = append(parts, fmt.Sprintf("- %s: %s", r.Name, string(resultJSON)))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// Package assistant - Trading Context Builder
|
||||
// Automatically enriches AI prompts with real-time market and portfolio data
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/manager"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TradingContext holds real-time trading context for AI decision making
|
||||
type TradingContext struct {
|
||||
// Portfolio state
|
||||
TotalEquity float64 `json:"total_equity"`
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
Positions []PositionSummary `json:"positions"`
|
||||
|
||||
// Market data
|
||||
MarketPrices map[string]float64 `json:"market_prices"`
|
||||
PriceChanges24h map[string]float64 `json:"price_changes_24h"`
|
||||
|
||||
// Trader states
|
||||
ActiveTraders []TraderSummary `json:"active_traders"`
|
||||
|
||||
// Alerts
|
||||
Alerts []Alert `json:"alerts"`
|
||||
|
||||
// Timestamp
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PositionSummary summarizes a position
|
||||
type PositionSummary struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "long" or "short"
|
||||
Size float64 `json:"size"`
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
PnLPercent float64 `json:"pnl_percent"`
|
||||
Leverage int `json:"leverage"`
|
||||
LiquidationPrice float64 `json:"liquidation_price,omitempty"`
|
||||
TraderID string `json:"trader_id"`
|
||||
TraderName string `json:"trader_name"`
|
||||
}
|
||||
|
||||
// TraderSummary summarizes a trader's state
|
||||
type TraderSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Exchange string `json:"exchange"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
Equity float64 `json:"equity"`
|
||||
PositionCount int `json:"position_count"`
|
||||
TodayPnL float64 `json:"today_pnl,omitempty"`
|
||||
}
|
||||
|
||||
// Alert represents a trading alert
|
||||
type Alert struct {
|
||||
Level string `json:"level"` // "info", "warning", "danger"
|
||||
Type string `json:"type"` // "liquidation_risk", "large_loss", "price_alert", etc.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ContextBuilder builds trading context for AI
|
||||
type ContextBuilder struct {
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// NewContextBuilder creates a context builder
|
||||
func NewContextBuilder(tm *manager.TraderManager, st *store.Store) *ContextBuilder {
|
||||
return &ContextBuilder{
|
||||
traderManager: tm,
|
||||
store: st,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext builds current trading context
|
||||
func (cb *ContextBuilder) BuildContext() *TradingContext {
|
||||
ctx := &TradingContext{
|
||||
MarketPrices: make(map[string]float64),
|
||||
PriceChanges24h: make(map[string]float64),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Get all traders
|
||||
allTraders := cb.traderManager.GetAllTraders()
|
||||
|
||||
for id, trader := range allTraders {
|
||||
summary := TraderSummary{
|
||||
ID: id,
|
||||
Name: trader.GetName(),
|
||||
Exchange: trader.GetExchange(),
|
||||
IsRunning: true, // If in map, it's running
|
||||
}
|
||||
|
||||
// Get account info
|
||||
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
||||
if equity, ok := accountInfo["total_equity"].(float64); ok {
|
||||
summary.Equity = equity
|
||||
ctx.TotalEquity += equity
|
||||
}
|
||||
if available, ok := accountInfo["available_balance"].(float64); ok {
|
||||
ctx.AvailableBalance += available
|
||||
}
|
||||
}
|
||||
|
||||
// Get positions
|
||||
if positions, err := trader.GetPositions(); err == nil {
|
||||
summary.PositionCount = len(positions)
|
||||
|
||||
for _, pos := range positions {
|
||||
posSummary := cb.parsePosition(pos, id, trader.GetName())
|
||||
if posSummary != nil {
|
||||
ctx.Positions = append(ctx.Positions, *posSummary)
|
||||
ctx.UnrealizedPnL += posSummary.UnrealizedPnL
|
||||
|
||||
// Track market prices
|
||||
ctx.MarketPrices[posSummary.Symbol] = posSummary.MarkPrice
|
||||
|
||||
// Check for alerts
|
||||
cb.checkPositionAlerts(ctx, posSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.ActiveTraders = append(ctx.ActiveTraders, summary)
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// parsePosition parses position data into summary
|
||||
func (cb *ContextBuilder) parsePosition(pos map[string]interface{}, traderID, traderName string) *PositionSummary {
|
||||
summary := &PositionSummary{
|
||||
TraderID: traderID,
|
||||
TraderName: traderName,
|
||||
}
|
||||
|
||||
if symbol, ok := pos["symbol"].(string); ok {
|
||||
summary.Symbol = symbol
|
||||
}
|
||||
if side, ok := pos["side"].(string); ok {
|
||||
summary.Side = strings.ToLower(side)
|
||||
}
|
||||
if size, ok := pos["size"].(float64); ok {
|
||||
summary.Size = size
|
||||
}
|
||||
if entry, ok := pos["entry_price"].(float64); ok {
|
||||
summary.EntryPrice = entry
|
||||
}
|
||||
if mark, ok := pos["mark_price"].(float64); ok {
|
||||
summary.MarkPrice = mark
|
||||
}
|
||||
if pnl, ok := pos["unrealized_pnl"].(float64); ok {
|
||||
summary.UnrealizedPnL = pnl
|
||||
}
|
||||
if lev, ok := pos["leverage"].(int); ok {
|
||||
summary.Leverage = lev
|
||||
}
|
||||
if liq, ok := pos["liquidation_price"].(float64); ok {
|
||||
summary.LiquidationPrice = liq
|
||||
}
|
||||
|
||||
// Calculate PnL percent
|
||||
if summary.EntryPrice > 0 && summary.Size > 0 {
|
||||
if summary.Side == "long" {
|
||||
summary.PnLPercent = ((summary.MarkPrice - summary.EntryPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
|
||||
} else {
|
||||
summary.PnLPercent = ((summary.EntryPrice - summary.MarkPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// checkPositionAlerts checks for position-related alerts
|
||||
func (cb *ContextBuilder) checkPositionAlerts(ctx *TradingContext, pos *PositionSummary) {
|
||||
// Liquidation risk alert
|
||||
if pos.LiquidationPrice > 0 && pos.MarkPrice > 0 {
|
||||
var distancePercent float64
|
||||
if pos.Side == "long" {
|
||||
distancePercent = ((pos.MarkPrice - pos.LiquidationPrice) / pos.MarkPrice) * 100
|
||||
} else {
|
||||
distancePercent = ((pos.LiquidationPrice - pos.MarkPrice) / pos.MarkPrice) * 100
|
||||
}
|
||||
|
||||
if distancePercent < 5 {
|
||||
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||
Level: "danger",
|
||||
Type: "liquidation_risk",
|
||||
Message: fmt.Sprintf("⚠️ %s %s仓位距离强平仅 %.1f%%!", pos.Symbol, pos.Side, distancePercent),
|
||||
})
|
||||
} else if distancePercent < 10 {
|
||||
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||
Level: "warning",
|
||||
Type: "liquidation_risk",
|
||||
Message: fmt.Sprintf("⚡ %s %s仓位距离强平 %.1f%%,注意风险", pos.Symbol, pos.Side, distancePercent),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Large loss alert
|
||||
if pos.PnLPercent < -20 {
|
||||
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||
Level: "danger",
|
||||
Type: "large_loss",
|
||||
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%,考虑止损", pos.Symbol, pos.Side, pos.PnLPercent),
|
||||
})
|
||||
} else if pos.PnLPercent < -10 {
|
||||
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||
Level: "warning",
|
||||
Type: "large_loss",
|
||||
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%", pos.Symbol, pos.Side, pos.PnLPercent),
|
||||
})
|
||||
}
|
||||
|
||||
// Large profit - consider taking profit
|
||||
if pos.PnLPercent > 50 {
|
||||
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||
Level: "info",
|
||||
Type: "large_profit",
|
||||
Message: fmt.Sprintf("📈 %s %s仓位盈利 %.1f%%,考虑部分止盈", pos.Symbol, pos.Side, pos.PnLPercent),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FormatContextForPrompt formats context as text for AI prompt injection
|
||||
func (ctx *TradingContext) FormatContextForPrompt() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("\n\n---\n## 📊 当前交易状态 (实时)\n\n")
|
||||
|
||||
// Portfolio summary
|
||||
sb.WriteString(fmt.Sprintf("**总权益:** $%.2f | **可用余额:** $%.2f | **未实现盈亏:** $%.2f\n\n",
|
||||
ctx.TotalEquity, ctx.AvailableBalance, ctx.UnrealizedPnL))
|
||||
|
||||
// Alerts (high priority)
|
||||
if len(ctx.Alerts) > 0 {
|
||||
sb.WriteString("### ⚠️ 警报\n")
|
||||
for _, alert := range ctx.Alerts {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Active positions
|
||||
if len(ctx.Positions) > 0 {
|
||||
sb.WriteString("### 📈 持仓\n")
|
||||
sb.WriteString("| 交易对 | 方向 | 数量 | 入场价 | 现价 | 盈亏 | 盈亏% | 杠杆 | 交易员 |\n")
|
||||
sb.WriteString("|--------|------|------|--------|------|------|-------|------|--------|\n")
|
||||
for _, pos := range ctx.Positions {
|
||||
pnlEmoji := "🟢"
|
||||
if pos.UnrealizedPnL < 0 {
|
||||
pnlEmoji = "🔴"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("| %s | %s | %.4f | %.2f | %.2f | %s$%.2f | %.1f%% | %dx | %s |\n",
|
||||
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.MarkPrice,
|
||||
pnlEmoji, pos.UnrealizedPnL, pos.PnLPercent, pos.Leverage, pos.TraderName))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("### 📈 持仓\n无持仓\n\n")
|
||||
}
|
||||
|
||||
// Active traders
|
||||
if len(ctx.ActiveTraders) > 0 {
|
||||
sb.WriteString("### 🤖 运行中的交易员\n")
|
||||
for _, t := range ctx.ActiveTraders {
|
||||
status := "✅ 运行中"
|
||||
if !t.IsRunning {
|
||||
status = "❌ 已停止"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- **%s** (%s) %s | 权益: $%.2f | 持仓: %d\n",
|
||||
t.Name, t.Exchange, status, t.Equity, t.PositionCount))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("*数据更新时间: %s*\n---\n", ctx.UpdatedAt.Format("2006-01-02 15:04:05")))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetTopSymbols returns symbols with positions for market data queries
|
||||
func (ctx *TradingContext) GetTopSymbols() []string {
|
||||
symbolSet := make(map[string]bool)
|
||||
for _, pos := range ctx.Positions {
|
||||
symbolSet[pos.Symbol] = true
|
||||
}
|
||||
|
||||
// Always include major pairs
|
||||
symbolSet["BTCUSDT"] = true
|
||||
symbolSet["ETHUSDT"] = true
|
||||
|
||||
symbols := make([]string, 0, len(symbolSet))
|
||||
for s := range symbolSet {
|
||||
symbols = append(symbols, s)
|
||||
}
|
||||
return symbols
|
||||
}
|
||||
|
||||
// EnrichWithMarketData adds market data to context
|
||||
// Note: Market prices are already populated from position data
|
||||
func (cb *ContextBuilder) EnrichWithMarketData(ctx *TradingContext, symbols []string) {
|
||||
// Market prices are populated from position mark prices
|
||||
// Additional market data enrichment can be added here in the future
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/store"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Monitor provides proactive monitoring and alerts
|
||||
type Monitor struct {
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
contextBuilder *ContextBuilder
|
||||
|
||||
// Alert callbacks
|
||||
alertCallbacks []func(Alert)
|
||||
callbackMu sync.RWMutex
|
||||
|
||||
// State
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
interval time.Duration
|
||||
|
||||
// Last known state for change detection
|
||||
lastPositions map[string]PositionSummary
|
||||
lastAlerts map[string]time.Time // Prevent alert spam
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMonitor creates a new trading monitor
|
||||
func NewMonitor(tm *manager.TraderManager, st *store.Store) *Monitor {
|
||||
return &Monitor{
|
||||
traderManager: tm,
|
||||
store: st,
|
||||
contextBuilder: NewContextBuilder(tm, st),
|
||||
stopChan: make(chan struct{}),
|
||||
interval: 30 * time.Second, // Check every 30 seconds
|
||||
lastPositions: make(map[string]PositionSummary),
|
||||
lastAlerts: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// OnAlert registers an alert callback
|
||||
func (m *Monitor) OnAlert(callback func(Alert)) {
|
||||
m.callbackMu.Lock()
|
||||
defer m.callbackMu.Unlock()
|
||||
m.alertCallbacks = append(m.alertCallbacks, callback)
|
||||
}
|
||||
|
||||
// Start starts the monitor
|
||||
func (m *Monitor) Start() {
|
||||
m.mu.Lock()
|
||||
if m.running {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.running = true
|
||||
m.stopChan = make(chan struct{})
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Info("🔍 Starting trading monitor...")
|
||||
|
||||
go m.monitorLoop()
|
||||
}
|
||||
|
||||
// Stop stops the monitor
|
||||
func (m *Monitor) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.running {
|
||||
return
|
||||
}
|
||||
|
||||
m.running = false
|
||||
close(m.stopChan)
|
||||
logger.Info("🔍 Trading monitor stopped")
|
||||
}
|
||||
|
||||
// monitorLoop is the main monitoring loop
|
||||
func (m *Monitor) monitorLoop() {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial check
|
||||
m.checkAndAlert()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.checkAndAlert()
|
||||
case <-m.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndAlert checks positions and sends alerts
|
||||
func (m *Monitor) checkAndAlert() {
|
||||
ctx := m.contextBuilder.BuildContext()
|
||||
|
||||
// Process built-in alerts from context
|
||||
for _, alert := range ctx.Alerts {
|
||||
m.sendAlertIfNew(alert)
|
||||
}
|
||||
|
||||
// Check for position changes
|
||||
m.checkPositionChanges(ctx)
|
||||
|
||||
// Check for new large movements
|
||||
m.checkMarketMovements(ctx)
|
||||
}
|
||||
|
||||
// checkPositionChanges detects significant position changes
|
||||
func (m *Monitor) checkPositionChanges(ctx *TradingContext) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
currentPositions := make(map[string]PositionSummary)
|
||||
|
||||
for _, pos := range ctx.Positions {
|
||||
key := fmt.Sprintf("%s_%s_%s", pos.TraderID, pos.Symbol, pos.Side)
|
||||
currentPositions[key] = pos
|
||||
|
||||
// Check if this is a new position
|
||||
if _, existed := m.lastPositions[key]; !existed {
|
||||
m.sendAlert(Alert{
|
||||
Level: "info",
|
||||
Type: "new_position",
|
||||
Message: fmt.Sprintf("📍 新开仓位: %s %s %.4f @ %.2f (%dx)",
|
||||
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.Leverage),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check for closed positions
|
||||
for key, oldPos := range m.lastPositions {
|
||||
if _, exists := currentPositions[key]; !exists {
|
||||
m.sendAlert(Alert{
|
||||
Level: "info",
|
||||
Type: "position_closed",
|
||||
Message: fmt.Sprintf("📍 仓位已平: %s %s (入场价: %.2f)",
|
||||
oldPos.Symbol, oldPos.Side, oldPos.EntryPrice),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.lastPositions = currentPositions
|
||||
}
|
||||
|
||||
// checkMarketMovements checks for significant market movements
|
||||
func (m *Monitor) checkMarketMovements(ctx *TradingContext) {
|
||||
// This could be expanded to check price movements
|
||||
// For now, we rely on the context builder's alerts
|
||||
}
|
||||
|
||||
// sendAlertIfNew sends an alert only if it's new (avoid spam)
|
||||
func (m *Monitor) sendAlertIfNew(alert Alert) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s_%s", alert.Type, alert.Message)
|
||||
|
||||
// Check if we sent this alert recently (within 5 minutes)
|
||||
if lastSent, ok := m.lastAlerts[key]; ok {
|
||||
if time.Since(lastSent) < 5*time.Minute {
|
||||
return // Skip, already sent recently
|
||||
}
|
||||
}
|
||||
|
||||
m.lastAlerts[key] = time.Now()
|
||||
m.sendAlert(alert)
|
||||
}
|
||||
|
||||
// sendAlert sends alert to all registered callbacks
|
||||
func (m *Monitor) sendAlert(alert Alert) {
|
||||
m.callbackMu.RLock()
|
||||
callbacks := make([]func(Alert), len(m.alertCallbacks))
|
||||
copy(callbacks, m.alertCallbacks)
|
||||
m.callbackMu.RUnlock()
|
||||
|
||||
for _, cb := range callbacks {
|
||||
go cb(alert)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentContext returns the current trading context
|
||||
func (m *Monitor) GetCurrentContext() *TradingContext {
|
||||
return m.contextBuilder.BuildContext()
|
||||
}
|
||||
|
||||
// SetInterval sets the monitoring interval
|
||||
func (m *Monitor) SetInterval(d time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.interval = d
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package assistant
|
||||
|
||||
// DefaultTradingSystemPrompt returns the default system prompt for trading assistant
|
||||
func DefaultTradingSystemPrompt() string {
|
||||
return `# NOFX Trading Assistant
|
||||
|
||||
You are an expert AI trading assistant powered by NOFX - an advanced AI-powered trading system.
|
||||
|
||||
## Your Capabilities
|
||||
|
||||
1. **Account Management**
|
||||
- Check balances across multiple exchanges
|
||||
- View current positions and P&L
|
||||
- Monitor portfolio performance
|
||||
|
||||
2. **Trading Operations**
|
||||
- Execute trades (open/close positions)
|
||||
- Manage stop-loss and take-profit orders
|
||||
- Adjust leverage and margin settings
|
||||
|
||||
3. **AI Traders Management**
|
||||
- Start/stop AI traders
|
||||
- Monitor AI trader performance
|
||||
- Configure trading strategies
|
||||
|
||||
4. **Strategy & Analysis**
|
||||
- Create and modify trading strategies
|
||||
- Initiate AI debate sessions for market analysis
|
||||
- Backtest strategies on historical data
|
||||
|
||||
5. **Market Intelligence**
|
||||
- Get real-time prices and market data
|
||||
- Analyze market conditions
|
||||
- Track open interest and funding rates
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Safety First**: Always confirm with the user before executing trades or making significant changes
|
||||
2. **Be Precise**: When dealing with numbers, be exact - trading involves real money
|
||||
3. **Explain Reasoning**: Help users understand your analysis and recommendations
|
||||
4. **Risk Awareness**: Always remind users about the risks involved in trading
|
||||
5. **Proactive Monitoring**: Alert users to important position changes or market movements
|
||||
|
||||
## Response Style
|
||||
|
||||
- Be concise but thorough
|
||||
- Use tables for data when appropriate
|
||||
- Include relevant metrics (P&L, ROI, etc.)
|
||||
- Provide actionable insights, not just data dumps
|
||||
- Support both English and Chinese (respond in the user's language)
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never share API keys or sensitive credentials
|
||||
- Always use proper position sizing based on user's risk tolerance
|
||||
- Warn users about high-risk operations (high leverage, large positions)
|
||||
|
||||
Remember: You are a professional trading assistant. Users trust you with their trading operations. Be accurate, be helpful, and be responsible.`
|
||||
}
|
||||
|
||||
// ChineseSystemPrompt returns Chinese version of the system prompt
|
||||
func ChineseSystemPrompt() string {
|
||||
return `# NOFX 交易助手
|
||||
|
||||
你是一个由 NOFX 驱动的专业 AI 交易助手 - 一个先进的 AI 驱动交易系统。
|
||||
|
||||
## 你的能力
|
||||
|
||||
1. **账户管理**
|
||||
- 查询多交易所余额
|
||||
- 查看当前持仓和盈亏
|
||||
- 监控投资组合表现
|
||||
|
||||
2. **交易操作**
|
||||
- 执行交易(开仓/平仓)
|
||||
- 管理止损止盈订单
|
||||
- 调整杠杆和保证金设置
|
||||
|
||||
3. **AI 交易员管理**
|
||||
- 启动/停止 AI 交易员
|
||||
- 监控 AI 交易员表现
|
||||
- 配置交易策略
|
||||
|
||||
4. **策略与分析**
|
||||
- 创建和修改交易策略
|
||||
- 发起 AI 辩论会议进行市场分析
|
||||
- 回测历史数据
|
||||
|
||||
5. **市场情报**
|
||||
- 获取实时价格和市场数据
|
||||
- 分析市场状况
|
||||
- 跟踪持仓量和资金费率
|
||||
|
||||
## 行为准则
|
||||
|
||||
1. **安全第一**:执行交易或重大操作前,务必与用户确认
|
||||
2. **精确无误**:涉及数字时必须精确 - 交易涉及真金白银
|
||||
3. **解释逻辑**:帮助用户理解你的分析和建议
|
||||
4. **风险意识**:始终提醒用户交易风险
|
||||
5. **主动监控**:及时提醒用户重要的仓位变化或市场波动
|
||||
|
||||
## 回复风格
|
||||
|
||||
- 简洁但全面
|
||||
- 适当使用表格展示数据
|
||||
- 包含相关指标(盈亏、收益率等)
|
||||
- 提供可操作的见解,而非单纯的数据罗列
|
||||
- 支持中英文(根据用户使用的语言回复)
|
||||
|
||||
## 重要提示
|
||||
|
||||
- 永远不要分享 API 密钥或敏感凭证
|
||||
- 根据用户的风险承受能力进行合理的仓位管理
|
||||
- 对高风险操作(高杠杆、大仓位)发出警告
|
||||
|
||||
记住:你是专业的交易助手。用户将交易操作托付于你。准确、有用、负责。`
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents a single message in conversation
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "user", "assistant", "system", "tool"
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// For tool messages
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolResult interface{} `json:"tool_result,omitempty"`
|
||||
}
|
||||
|
||||
// Session represents a conversation session with memory
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// User info
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
Platform string `json:"platform"` // "telegram", "web", etc.
|
||||
|
||||
// Conversation history
|
||||
messages []Message
|
||||
maxMessages int
|
||||
mu sync.RWMutex
|
||||
|
||||
// Custom metadata
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// NewSession creates a new conversation session
|
||||
func NewSession(id string, maxMessages int) *Session {
|
||||
return &Session{
|
||||
ID: id,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
messages: make([]Message, 0),
|
||||
maxMessages: maxMessages,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage adds a message to the session
|
||||
func (s *Session) AddMessage(msg Message) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.messages = append(s.messages, msg)
|
||||
s.UpdatedAt = time.Now()
|
||||
|
||||
// Trim old messages if exceeding max
|
||||
if len(s.messages) > s.maxMessages {
|
||||
// Keep the most recent messages
|
||||
s.messages = s.messages[len(s.messages)-s.maxMessages:]
|
||||
}
|
||||
}
|
||||
|
||||
// GetMessages returns a copy of all messages
|
||||
func (s *Session) GetMessages() []Message {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]Message, len(s.messages))
|
||||
copy(result, s.messages)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetRecentMessages returns the N most recent messages
|
||||
func (s *Session) GetRecentMessages(n int) []Message {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if n >= len(s.messages) {
|
||||
result := make([]Message, len(s.messages))
|
||||
copy(result, s.messages)
|
||||
return result
|
||||
}
|
||||
|
||||
result := make([]Message, n)
|
||||
copy(result, s.messages[len(s.messages)-n:])
|
||||
return result
|
||||
}
|
||||
|
||||
// Clear removes all messages from the session
|
||||
func (s *Session) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.messages = make([]Message, 0)
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetUserInfo sets user information
|
||||
func (s *Session) SetUserInfo(userID, userName, platform string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.UserID = userID
|
||||
s.UserName = userName
|
||||
s.Platform = platform
|
||||
}
|
||||
|
||||
// SetMetadata sets a metadata value
|
||||
func (s *Session) SetMetadata(key string, value interface{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Metadata[key] = value
|
||||
}
|
||||
|
||||
// GetMetadata gets a metadata value
|
||||
func (s *Session) GetMetadata(key string) (interface{}, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
v, ok := s.Metadata[key]
|
||||
return v, ok
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SmartAgent is an enhanced AI agent with trading context awareness
|
||||
type SmartAgent struct {
|
||||
*Agent
|
||||
contextBuilder *ContextBuilder
|
||||
monitor *Monitor
|
||||
|
||||
// Auto-inject context into prompts
|
||||
autoInjectContext bool
|
||||
}
|
||||
|
||||
// NewSmartAgent creates a new smart trading agent
|
||||
func NewSmartAgent(aiClient mcp.AIClient, config AgentConfig, tm *manager.TraderManager, st *store.Store) *SmartAgent {
|
||||
baseAgent := NewAgent(aiClient, config)
|
||||
baseAgent.SetSystemPrompt(SmartTradingPrompt())
|
||||
|
||||
contextBuilder := NewContextBuilder(tm, st)
|
||||
monitor := NewMonitor(tm, st)
|
||||
|
||||
return &SmartAgent{
|
||||
Agent: baseAgent,
|
||||
contextBuilder: contextBuilder,
|
||||
monitor: monitor,
|
||||
autoInjectContext: true,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAutoInjectContext enables/disables automatic context injection
|
||||
func (sa *SmartAgent) SetAutoInjectContext(enabled bool) {
|
||||
sa.autoInjectContext = enabled
|
||||
}
|
||||
|
||||
// StartMonitor starts the background monitor
|
||||
func (sa *SmartAgent) StartMonitor() {
|
||||
sa.monitor.Start()
|
||||
}
|
||||
|
||||
// StopMonitor stops the background monitor
|
||||
func (sa *SmartAgent) StopMonitor() {
|
||||
sa.monitor.Stop()
|
||||
}
|
||||
|
||||
// OnAlert registers an alert callback
|
||||
func (sa *SmartAgent) OnAlert(callback func(Alert)) {
|
||||
sa.monitor.OnAlert(callback)
|
||||
}
|
||||
|
||||
// Chat processes a message with smart context injection
|
||||
func (sa *SmartAgent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
|
||||
session := sa.GetSession(sessionID)
|
||||
|
||||
// Add user message to history
|
||||
session.AddMessage(Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
// Build system prompt with tools
|
||||
systemPrompt := sa.buildSmartSystemPrompt()
|
||||
|
||||
// Build conversation prompt with context injection
|
||||
conversationPrompt := sa.buildSmartConversationPrompt(session, userMessage)
|
||||
|
||||
// Agent loop
|
||||
var finalResponse string
|
||||
toolCallCount := 0
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
if toolCallCount >= sa.config.MaxToolCalls {
|
||||
logger.Warnf("⚠️ Max tool calls reached (%d)", sa.config.MaxToolCalls)
|
||||
break
|
||||
}
|
||||
|
||||
response, err := sa.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||
}
|
||||
|
||||
toolCalls, textResponse, err := sa.parseResponse(response)
|
||||
if err != nil {
|
||||
finalResponse = response
|
||||
break
|
||||
}
|
||||
|
||||
if len(toolCalls) == 0 {
|
||||
finalResponse = textResponse
|
||||
break
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
toolResults := sa.executeToolCalls(ctx, toolCalls)
|
||||
toolCallCount += len(toolCalls)
|
||||
|
||||
// Add results to conversation
|
||||
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, provide a helpful response:",
|
||||
formatToolCalls(toolCalls),
|
||||
formatToolResults(toolResults))
|
||||
|
||||
if textResponse != "" {
|
||||
finalResponse = textResponse
|
||||
}
|
||||
}
|
||||
|
||||
// Add response to history
|
||||
session.AddMessage(Message{
|
||||
Role: "assistant",
|
||||
Content: finalResponse,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
return &AgentResponse{
|
||||
Text: finalResponse,
|
||||
SessionID: sessionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildSmartSystemPrompt builds system prompt with tools
|
||||
func (sa *SmartAgent) buildSmartSystemPrompt() string {
|
||||
sa.toolsLock.RLock()
|
||||
defer sa.toolsLock.RUnlock()
|
||||
|
||||
var toolDefs []string
|
||||
for _, tool := range sa.tools {
|
||||
toolDef := fmt.Sprintf(`- **%s**: %s
|
||||
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
|
||||
toolDefs = append(toolDefs, toolDef)
|
||||
}
|
||||
|
||||
toolsSection := ""
|
||||
if len(toolDefs) > 0 {
|
||||
toolsSection = fmt.Sprintf(`
|
||||
|
||||
## 可用工具
|
||||
|
||||
调用工具时,使用以下 JSON 格式:
|
||||
{"tool_calls": [{"name": "工具名", "arguments": {"参数": "值"}}]}
|
||||
|
||||
收到工具结果后,用自然语言回复用户。
|
||||
|
||||
可用工具:
|
||||
%s
|
||||
`, strings.Join(toolDefs, "\n"))
|
||||
}
|
||||
|
||||
return sa.systemPrompt + toolsSection
|
||||
}
|
||||
|
||||
// buildSmartConversationPrompt builds conversation with context injection
|
||||
func (sa *SmartAgent) buildSmartConversationPrompt(session *Session, currentMessage string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Inject current trading context if enabled
|
||||
if sa.autoInjectContext {
|
||||
tradingCtx := sa.contextBuilder.BuildContext()
|
||||
sb.WriteString(tradingCtx.FormatContextForPrompt())
|
||||
}
|
||||
|
||||
// Add conversation history
|
||||
messages := session.GetMessages()
|
||||
for _, msg := range messages {
|
||||
sb.WriteString(fmt.Sprintf("\n%s: %s\n", strings.Title(msg.Role), msg.Content))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// QuickStatus returns a quick status summary
|
||||
func (sa *SmartAgent) QuickStatus() string {
|
||||
ctx := sa.contextBuilder.BuildContext()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📊 **交易状态概览**\n\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("💰 总权益: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("💵 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||
|
||||
if ctx.UnrealizedPnL >= 0 {
|
||||
sb.WriteString(fmt.Sprintf("📈 未实现盈亏: 🟢 +$%.2f\n", ctx.UnrealizedPnL))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("📉 未实现盈亏: 🔴 $%.2f\n", ctx.UnrealizedPnL))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("📍 持仓数: %d\n", len(ctx.Positions)))
|
||||
sb.WriteString(fmt.Sprintf("🤖 运行交易员: %d\n", len(ctx.ActiveTraders)))
|
||||
|
||||
if len(ctx.Alerts) > 0 {
|
||||
sb.WriteString("\n⚠️ **警报**\n")
|
||||
for _, alert := range ctx.Alerts {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetTradingContext returns current trading context
|
||||
func (sa *SmartAgent) GetTradingContext() *TradingContext {
|
||||
return sa.contextBuilder.BuildContext()
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SmartTradingPrompt returns an enhanced system prompt with trading intelligence
|
||||
func SmartTradingPrompt() string {
|
||||
return `# 🧠 NOFX 智能交易助手
|
||||
|
||||
你是一个专业的 AI 交易助手,具备以下能力:
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 1. 智能分析
|
||||
- 分析用户意图,理解交易需求
|
||||
- 在执行交易前,主动评估风险
|
||||
- 结合市场数据给出建议
|
||||
|
||||
### 2. 主动提醒
|
||||
- 发现持仓风险时主动警告
|
||||
- 大额亏损时建议止损
|
||||
- 接近强平时紧急提醒
|
||||
|
||||
### 3. 专业建议
|
||||
- 根据仓位情况建议操作
|
||||
- 评估杠杆和仓位大小是否合理
|
||||
- 提供入场/出场时机建议
|
||||
|
||||
## 交易原则
|
||||
|
||||
1. **安全第一**:任何交易操作前必须确认,高风险操作要多次确认
|
||||
2. **风险控制**:
|
||||
- 单笔交易不超过总资金的 10%
|
||||
- 杠杆建议:BTC/ETH ≤10x,山寨币 ≤5x
|
||||
- 发现强平风险立即警告
|
||||
3. **理性决策**:不鼓励情绪化交易,亏损时建议冷静
|
||||
|
||||
## 回复风格
|
||||
|
||||
- 简洁专业,像交易员一样说话
|
||||
- 数据说话,给出具体数字
|
||||
- 风险提示放在显眼位置
|
||||
- 支持中英文,根据用户语言回复
|
||||
|
||||
## 工具使用策略
|
||||
|
||||
当用户问到持仓、余额时:
|
||||
1. 先调用 list_traders 获取交易员列表
|
||||
2. 对运行中的交易员调用 get_balance 和 get_positions
|
||||
3. 汇总数据后清晰展示
|
||||
|
||||
当用户想交易时:
|
||||
1. 先获取当前持仓和余额
|
||||
2. 评估这笔交易的风险
|
||||
3. 明确告知风险后请求确认
|
||||
4. 确认后执行交易
|
||||
|
||||
当用户问市场行情时:
|
||||
1. 获取相关币种价格
|
||||
2. 结合持仓情况分析
|
||||
3. 给出操作建议(但声明不构成投资建议)
|
||||
|
||||
## 重要:响应格式
|
||||
|
||||
- 持仓展示用表格
|
||||
- 重要警告用 ⚠️ 标注
|
||||
- 盈利用 🟢,亏损用 🔴
|
||||
- 操作建议用列表
|
||||
|
||||
记住:你的目标是帮助用户更好地管理交易,而不是鼓励频繁交易。稳健盈利比追求高收益更重要。`
|
||||
}
|
||||
|
||||
// RiskAssessmentPrompt returns a prompt for risk assessment before trades
|
||||
func RiskAssessmentPrompt(action, symbol string, quantity, leverage float64, currentBalance, currentPositions string) string {
|
||||
return fmt.Sprintf(`## 交易风险评估
|
||||
|
||||
请评估以下交易的风险:
|
||||
|
||||
**操作**: %s %s
|
||||
**数量**: %.4f
|
||||
**杠杆**: %.0fx
|
||||
|
||||
**当前账户状态**:
|
||||
%s
|
||||
|
||||
**当前持仓**:
|
||||
%s
|
||||
|
||||
请分析:
|
||||
1. 这笔交易是否合理?
|
||||
2. 仓位大小是否过大?
|
||||
3. 杠杆是否过高?
|
||||
4. 有什么潜在风险?
|
||||
5. 你的建议是什么?
|
||||
|
||||
如果风险过高,请明确警告用户。`, action, symbol, quantity, leverage, currentBalance, currentPositions)
|
||||
}
|
||||
|
||||
// MarketAnalysisPrompt returns a prompt for market analysis
|
||||
func MarketAnalysisPrompt(symbol string, priceData, positionData string) string {
|
||||
return fmt.Sprintf(`## %s 市场分析
|
||||
|
||||
**价格数据**:
|
||||
%s
|
||||
|
||||
**相关持仓**:
|
||||
%s
|
||||
|
||||
请分析:
|
||||
1. 当前价格趋势
|
||||
2. 关键支撑/阻力位
|
||||
3. 持仓建议(继续持有/加仓/减仓/平仓)
|
||||
4. 风险提示
|
||||
|
||||
注:这是基于有限数据的分析,不构成投资建议。`, symbol, priceData, positionData)
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
// Package assistant - Intelligent Strategy Builder
|
||||
// Allows users to create powerful, flexible trading strategies through natural language
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// StrategyType defines the type of trading strategy
|
||||
type StrategyType string
|
||||
|
||||
const (
|
||||
StrategyTypeAI StrategyType = "ai" // AI decides everything
|
||||
StrategyTypeTrend StrategyType = "trend" // Trend following
|
||||
StrategyTypeMeanRevert StrategyType = "mean_revert" // Mean reversion
|
||||
StrategyTypeGrid StrategyType = "grid" // Grid trading
|
||||
StrategyTypeDCA StrategyType = "dca" // Dollar cost averaging
|
||||
StrategyTypeBreakout StrategyType = "breakout" // Breakout trading
|
||||
StrategyTypeArbitrage StrategyType = "arbitrage" // Cross-exchange arbitrage
|
||||
StrategyTypeCustom StrategyType = "custom" // Custom rules
|
||||
)
|
||||
|
||||
// SmartStrategy represents a user-defined trading strategy
|
||||
type SmartStrategy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type StrategyType `json:"type"`
|
||||
|
||||
// Trading pairs
|
||||
Symbols []string `json:"symbols"` // e.g., ["BTCUSDT", "ETHUSDT"]
|
||||
SymbolMode string `json:"symbol_mode"` // "static", "ai_select", "top_volume", "top_oi"
|
||||
MaxSymbols int `json:"max_symbols"` // Max symbols to trade simultaneously
|
||||
|
||||
// Entry conditions
|
||||
EntryRules []Rule `json:"entry_rules"`
|
||||
EntryMode string `json:"entry_mode"` // "any" (OR) or "all" (AND)
|
||||
|
||||
// Exit conditions
|
||||
ExitRules []Rule `json:"exit_rules"`
|
||||
TakeProfit *float64 `json:"take_profit"` // TP percentage
|
||||
StopLoss *float64 `json:"stop_loss"` // SL percentage
|
||||
TrailingStop *float64 `json:"trailing_stop"` // Trailing stop percentage
|
||||
|
||||
// Position sizing
|
||||
PositionSize PositionSizeConfig `json:"position_size"`
|
||||
MaxPositions int `json:"max_positions"` // Max concurrent positions
|
||||
MaxPerSymbol int `json:"max_per_symbol"` // Max positions per symbol
|
||||
|
||||
// Risk management
|
||||
RiskConfig RiskConfig `json:"risk_config"`
|
||||
|
||||
// Leverage settings
|
||||
LeverageConfig LeverageConfig `json:"leverage_config"`
|
||||
|
||||
// Time settings
|
||||
TimeConfig TimeConfig `json:"time_config"`
|
||||
|
||||
// AI enhancement
|
||||
AIConfig AIStrategyConfig `json:"ai_config"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Performance *StrategyPerformance `json:"performance,omitempty"`
|
||||
}
|
||||
|
||||
// Rule represents a trading rule/condition
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "indicator", "price", "time", "volume", "ai", "custom"
|
||||
Indicator string `json:"indicator"` // e.g., "RSI", "MACD", "EMA"
|
||||
Condition string `json:"condition"` // e.g., "crosses_above", "greater_than", "less_than"
|
||||
Value interface{} `json:"value"` // The value to compare against
|
||||
Timeframe string `json:"timeframe"` // e.g., "1h", "4h", "1d"
|
||||
Weight float64 `json:"weight"` // Weight for scoring (0-1)
|
||||
Description string `json:"description"` // Human readable description
|
||||
}
|
||||
|
||||
// PositionSizeConfig defines how to size positions
|
||||
type PositionSizeConfig struct {
|
||||
Mode string `json:"mode"` // "fixed", "percent", "risk_based", "kelly"
|
||||
FixedAmount float64 `json:"fixed_amount"` // Fixed USDT amount
|
||||
PercentOfEquity float64 `json:"percent_of_equity"` // Percentage of total equity
|
||||
RiskPerTrade float64 `json:"risk_per_trade"` // Max risk per trade (%)
|
||||
MaxSingleTrade float64 `json:"max_single_trade"` // Max single trade size (USDT)
|
||||
}
|
||||
|
||||
// RiskConfig defines risk management rules
|
||||
type RiskConfig struct {
|
||||
MaxDrawdown float64 `json:"max_drawdown"` // Max drawdown before stopping (%)
|
||||
MaxDailyLoss float64 `json:"max_daily_loss"` // Max daily loss (%)
|
||||
MaxOpenRisk float64 `json:"max_open_risk"` // Max total open risk (%)
|
||||
CooldownAfterLoss int `json:"cooldown_after_loss"` // Minutes to wait after a loss
|
||||
RequireConfirmation bool `json:"require_confirmation"` // Require user confirmation for trades
|
||||
EmergencyStopLoss float64 `json:"emergency_stop_loss"` // Emergency SL for all positions (%)
|
||||
}
|
||||
|
||||
// LeverageConfig defines leverage settings
|
||||
type LeverageConfig struct {
|
||||
Mode string `json:"mode"` // "fixed", "dynamic", "per_symbol"
|
||||
DefaultLeverage int `json:"default_leverage"`
|
||||
MaxLeverage int `json:"max_leverage"`
|
||||
PerSymbol map[string]int `json:"per_symbol"` // Symbol-specific leverage
|
||||
PerVolatility []VolatilityLever `json:"per_volatility"` // Volatility-based leverage
|
||||
}
|
||||
|
||||
// VolatilityLever defines leverage based on volatility
|
||||
type VolatilityLever struct {
|
||||
MaxVolatility float64 `json:"max_volatility"` // ATR percentage threshold
|
||||
Leverage int `json:"leverage"`
|
||||
}
|
||||
|
||||
// TimeConfig defines time-based settings
|
||||
type TimeConfig struct {
|
||||
TradingHours []TimeRange `json:"trading_hours"` // When to trade
|
||||
AvoidNews bool `json:"avoid_news"` // Avoid major news events
|
||||
AvoidWeekends bool `json:"avoid_weekends"`
|
||||
MinHoldTime int `json:"min_hold_time"` // Minimum hold time (minutes)
|
||||
MaxHoldTime int `json:"max_hold_time"` // Maximum hold time (minutes)
|
||||
ScanInterval int `json:"scan_interval"` // How often to scan (minutes)
|
||||
}
|
||||
|
||||
// TimeRange represents a time range
|
||||
type TimeRange struct {
|
||||
Start string `json:"start"` // "09:00"
|
||||
End string `json:"end"` // "17:00"
|
||||
TZ string `json:"tz"` // Timezone
|
||||
}
|
||||
|
||||
// AIStrategyConfig defines AI-specific settings
|
||||
type AIStrategyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Model string `json:"model"` // AI model to use
|
||||
ConfidenceThreshold float64 `json:"confidence_threshold"` // Min confidence to act
|
||||
UseMarketSentiment bool `json:"use_market_sentiment"`
|
||||
UseTechnicalAnalysis bool `json:"use_technical_analysis"`
|
||||
UseOnChainData bool `json:"use_onchain_data"`
|
||||
CustomPrompt string `json:"custom_prompt"` // Custom instructions for AI
|
||||
Personality string `json:"personality"` // "aggressive", "conservative", "balanced"
|
||||
}
|
||||
|
||||
// StrategyPerformance tracks strategy performance
|
||||
type StrategyPerformance struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
LosingTrades int `json:"losing_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
SharpeRatio float64 `json:"sharpe_ratio"`
|
||||
ProfitFactor float64 `json:"profit_factor"`
|
||||
AvgWin float64 `json:"avg_win"`
|
||||
AvgLoss float64 `json:"avg_loss"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// StrategyBuilder helps users create strategies through conversation
|
||||
type StrategyBuilder struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// NewStrategyBuilder creates a new strategy builder
|
||||
func NewStrategyBuilder(st *store.Store) *StrategyBuilder {
|
||||
return &StrategyBuilder{store: st}
|
||||
}
|
||||
|
||||
// CreateFromNaturalLanguage creates a strategy from natural language description
|
||||
func (sb *StrategyBuilder) CreateFromNaturalLanguage(description string, userID string) (*SmartStrategy, error) {
|
||||
// This would typically call an AI to parse the description
|
||||
// For now, we create a basic template
|
||||
strategy := &SmartStrategy{
|
||||
ID: uuid.New().String()[:8],
|
||||
Name: "Custom Strategy",
|
||||
Description: description,
|
||||
Type: StrategyTypeAI,
|
||||
SymbolMode: "ai_select",
|
||||
MaxSymbols: 5,
|
||||
EntryMode: "all",
|
||||
MaxPositions: 5,
|
||||
MaxPerSymbol: 1,
|
||||
PositionSize: PositionSizeConfig{
|
||||
Mode: "percent",
|
||||
PercentOfEquity: 5,
|
||||
MaxSingleTrade: 1000,
|
||||
},
|
||||
RiskConfig: RiskConfig{
|
||||
MaxDrawdown: 20,
|
||||
MaxDailyLoss: 5,
|
||||
MaxOpenRisk: 10,
|
||||
CooldownAfterLoss: 30,
|
||||
RequireConfirmation: true,
|
||||
EmergencyStopLoss: 30,
|
||||
},
|
||||
LeverageConfig: LeverageConfig{
|
||||
Mode: "dynamic",
|
||||
DefaultLeverage: 3,
|
||||
MaxLeverage: 10,
|
||||
},
|
||||
TimeConfig: TimeConfig{
|
||||
ScanInterval: 5,
|
||||
AvoidWeekends: false,
|
||||
},
|
||||
AIConfig: AIStrategyConfig{
|
||||
Enabled: true,
|
||||
ConfidenceThreshold: 0.7,
|
||||
UseMarketSentiment: true,
|
||||
UseTechnicalAnalysis: true,
|
||||
Personality: "balanced",
|
||||
CustomPrompt: description,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedBy: userID,
|
||||
IsActive: false,
|
||||
}
|
||||
|
||||
return strategy, nil
|
||||
}
|
||||
|
||||
// CreateGridStrategy creates a grid trading strategy
|
||||
func (sb *StrategyBuilder) CreateGridStrategy(symbol string, lowerPrice, upperPrice float64, gridCount int, amountPerGrid float64) *SmartStrategy {
|
||||
return &SmartStrategy{
|
||||
ID: uuid.New().String()[:8],
|
||||
Name: fmt.Sprintf("Grid %s", symbol),
|
||||
Description: fmt.Sprintf("Grid trading %s from %.2f to %.2f with %d grids", symbol, lowerPrice, upperPrice, gridCount),
|
||||
Type: StrategyTypeGrid,
|
||||
Symbols: []string{symbol},
|
||||
SymbolMode: "static",
|
||||
MaxPositions: gridCount,
|
||||
PositionSize: PositionSizeConfig{
|
||||
Mode: "fixed",
|
||||
FixedAmount: amountPerGrid,
|
||||
},
|
||||
EntryRules: []Rule{
|
||||
{
|
||||
ID: "grid_entry",
|
||||
Type: "price",
|
||||
Condition: "grid_level",
|
||||
Value: map[string]interface{}{
|
||||
"lower_price": lowerPrice,
|
||||
"upper_price": upperPrice,
|
||||
"grid_count": gridCount,
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: false,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDCAStrategy creates a DCA strategy
|
||||
func (sb *StrategyBuilder) CreateDCAStrategy(symbol string, intervalMinutes int, amountPerBuy float64, maxBuys int) *SmartStrategy {
|
||||
return &SmartStrategy{
|
||||
ID: uuid.New().String()[:8],
|
||||
Name: fmt.Sprintf("DCA %s", symbol),
|
||||
Description: fmt.Sprintf("DCA into %s every %d minutes, $%.2f per buy, max %d buys", symbol, intervalMinutes, amountPerBuy, maxBuys),
|
||||
Type: StrategyTypeDCA,
|
||||
Symbols: []string{symbol},
|
||||
SymbolMode: "static",
|
||||
MaxPositions: maxBuys,
|
||||
PositionSize: PositionSizeConfig{
|
||||
Mode: "fixed",
|
||||
FixedAmount: amountPerBuy,
|
||||
},
|
||||
TimeConfig: TimeConfig{
|
||||
ScanInterval: intervalMinutes,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: false,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTrendStrategy creates a trend following strategy
|
||||
func (sb *StrategyBuilder) CreateTrendStrategy(symbols []string, emaFast, emaSlow int, leverage int) *SmartStrategy {
|
||||
return &SmartStrategy{
|
||||
ID: uuid.New().String()[:8],
|
||||
Name: "Trend Following",
|
||||
Description: fmt.Sprintf("EMA %d/%d crossover strategy", emaFast, emaSlow),
|
||||
Type: StrategyTypeTrend,
|
||||
Symbols: symbols,
|
||||
SymbolMode: "static",
|
||||
EntryMode: "all",
|
||||
EntryRules: []Rule{
|
||||
{
|
||||
ID: "ema_cross",
|
||||
Name: "EMA Crossover",
|
||||
Type: "indicator",
|
||||
Indicator: "EMA",
|
||||
Condition: "crosses_above",
|
||||
Value: map[string]int{
|
||||
"fast_period": emaFast,
|
||||
"slow_period": emaSlow,
|
||||
},
|
||||
Timeframe: "1h",
|
||||
Weight: 1.0,
|
||||
},
|
||||
},
|
||||
ExitRules: []Rule{
|
||||
{
|
||||
ID: "ema_cross_exit",
|
||||
Name: "EMA Crossover Exit",
|
||||
Type: "indicator",
|
||||
Indicator: "EMA",
|
||||
Condition: "crosses_below",
|
||||
Value: map[string]int{
|
||||
"fast_period": emaFast,
|
||||
"slow_period": emaSlow,
|
||||
},
|
||||
Timeframe: "1h",
|
||||
},
|
||||
},
|
||||
LeverageConfig: LeverageConfig{
|
||||
Mode: "fixed",
|
||||
DefaultLeverage: leverage,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: false,
|
||||
}
|
||||
}
|
||||
|
||||
// StrategyToPrompt converts a strategy to an AI prompt
|
||||
func StrategyToPrompt(s *SmartStrategy) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("# 策略: %s\n\n", s.Name))
|
||||
sb.WriteString(fmt.Sprintf("**描述**: %s\n", s.Description))
|
||||
sb.WriteString(fmt.Sprintf("**类型**: %s\n\n", s.Type))
|
||||
|
||||
// Trading pairs
|
||||
if len(s.Symbols) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("**交易对**: %s\n", strings.Join(s.Symbols, ", ")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("**选币模式**: %s (最多 %d 个)\n", s.SymbolMode, s.MaxSymbols))
|
||||
}
|
||||
|
||||
// Entry rules
|
||||
if len(s.EntryRules) > 0 {
|
||||
sb.WriteString("\n## 入场规则\n")
|
||||
for _, rule := range s.EntryRules {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s %s %v\n", rule.Name, rule.Indicator, rule.Condition, rule.Value))
|
||||
}
|
||||
}
|
||||
|
||||
// Exit rules
|
||||
sb.WriteString("\n## 出场规则\n")
|
||||
if s.TakeProfit != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 止盈: %.1f%%\n", *s.TakeProfit))
|
||||
}
|
||||
if s.StopLoss != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 止损: %.1f%%\n", *s.StopLoss))
|
||||
}
|
||||
if s.TrailingStop != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 移动止损: %.1f%%\n", *s.TrailingStop))
|
||||
}
|
||||
|
||||
// Risk management
|
||||
sb.WriteString("\n## 风险管理\n")
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n", s.RiskConfig.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- 单日最大亏损: %.1f%%\n", s.RiskConfig.MaxDailyLoss))
|
||||
sb.WriteString(fmt.Sprintf("- 最大持仓数: %d\n", s.MaxPositions))
|
||||
|
||||
// AI settings
|
||||
if s.AIConfig.Enabled {
|
||||
sb.WriteString("\n## AI 配置\n")
|
||||
sb.WriteString(fmt.Sprintf("- 置信度阈值: %.0f%%\n", s.AIConfig.ConfidenceThreshold*100))
|
||||
sb.WriteString(fmt.Sprintf("- 风格: %s\n", s.AIConfig.Personality))
|
||||
if s.AIConfig.CustomPrompt != "" {
|
||||
sb.WriteString(fmt.Sprintf("- 自定义指令: %s\n", s.AIConfig.CustomPrompt))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// StrategyTools provides strategy management tools for the AI agent
|
||||
type StrategyTools struct {
|
||||
store *store.Store
|
||||
strategyBuilder *StrategyBuilder
|
||||
strategies map[string]*SmartStrategy // In-memory strategy cache
|
||||
}
|
||||
|
||||
// NewStrategyTools creates strategy tools
|
||||
func NewStrategyTools(st *store.Store) *StrategyTools {
|
||||
return &StrategyTools{
|
||||
store: st,
|
||||
strategyBuilder: NewStrategyBuilder(st),
|
||||
strategies: make(map[string]*SmartStrategy),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllTools returns all strategy tools
|
||||
func (st *StrategyTools) GetAllTools() []Tool {
|
||||
return []Tool{
|
||||
st.CreateStrategyTool(),
|
||||
st.CreateGridStrategyTool(),
|
||||
st.CreateDCAStrategyTool(),
|
||||
st.CreateTrendStrategyTool(),
|
||||
st.ListSmartStrategiesTool(),
|
||||
st.GetStrategyDetailsTool(),
|
||||
st.UpdateStrategyTool(),
|
||||
st.ActivateStrategyTool(),
|
||||
st.DeactivateStrategyTool(),
|
||||
st.DeleteStrategyTool(),
|
||||
st.GetStrategyTemplates(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateStrategyTool creates a strategy from natural language
|
||||
func (st *StrategyTools) CreateStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"create_strategy",
|
||||
`Create a new trading strategy from natural language description.
|
||||
Examples:
|
||||
- "当RSI低于30时买入BTC,RSI高于70时卖出"
|
||||
- "每天定投100美元ETH"
|
||||
- "BTC在5万到6万之间做网格交易"`,
|
||||
`{
|
||||
"name": "string (required) - Strategy name",
|
||||
"description": "string (required) - Natural language description of the strategy",
|
||||
"symbols": "array (optional) - Trading pairs, e.g., [\"BTCUSDT\", \"ETHUSDT\"]",
|
||||
"take_profit": "number (optional) - Take profit percentage",
|
||||
"stop_loss": "number (optional) - Stop loss percentage",
|
||||
"leverage": "number (optional) - Leverage to use (default: 3)",
|
||||
"max_positions": "number (optional) - Max concurrent positions (default: 5)"
|
||||
}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Symbols []string `json:"symbols"`
|
||||
TakeProfit *float64 `json:"take_profit"`
|
||||
StopLoss *float64 `json:"stop_loss"`
|
||||
Leverage int `json:"leverage"`
|
||||
MaxPositions int `json:"max_positions"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if params.Description == "" {
|
||||
return nil, fmt.Errorf("strategy description is required")
|
||||
}
|
||||
|
||||
strategy, err := st.strategyBuilder.CreateFromNaturalLanguage(params.Description, "default")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply user customizations
|
||||
if params.Name != "" {
|
||||
strategy.Name = params.Name
|
||||
}
|
||||
if len(params.Symbols) > 0 {
|
||||
strategy.Symbols = params.Symbols
|
||||
strategy.SymbolMode = "static"
|
||||
}
|
||||
if params.TakeProfit != nil {
|
||||
strategy.TakeProfit = params.TakeProfit
|
||||
}
|
||||
if params.StopLoss != nil {
|
||||
strategy.StopLoss = params.StopLoss
|
||||
}
|
||||
if params.Leverage > 0 {
|
||||
strategy.LeverageConfig.DefaultLeverage = params.Leverage
|
||||
}
|
||||
if params.MaxPositions > 0 {
|
||||
strategy.MaxPositions = params.MaxPositions
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
st.strategies[strategy.ID] = strategy
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"strategy": strategy,
|
||||
"message": fmt.Sprintf("策略 '%s' (ID: %s) 创建成功!使用 activate_strategy 激活它。", strategy.Name, strategy.ID),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// CreateGridStrategyTool creates a grid trading strategy
|
||||
func (st *StrategyTools) CreateGridStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"create_grid_strategy",
|
||||
"Create a grid trading strategy. Grid trading places buy and sell orders at predetermined price levels.",
|
||||
`{
|
||||
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
|
||||
"lower_price": "number (required) - Lower price bound",
|
||||
"upper_price": "number (required) - Upper price bound",
|
||||
"grid_count": "number (required) - Number of grids (10-100)",
|
||||
"amount_per_grid": "number (required) - USDT amount per grid"
|
||||
}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
Symbol string `json:"symbol"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
GridCount int `json:"grid_count"`
|
||||
AmountPerGrid float64 `json:"amount_per_grid"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if params.LowerPrice >= params.UpperPrice {
|
||||
return nil, fmt.Errorf("lower_price must be less than upper_price")
|
||||
}
|
||||
if params.GridCount < 2 || params.GridCount > 100 {
|
||||
return nil, fmt.Errorf("grid_count must be between 2 and 100")
|
||||
}
|
||||
|
||||
strategy := st.strategyBuilder.CreateGridStrategy(
|
||||
params.Symbol, params.LowerPrice, params.UpperPrice,
|
||||
params.GridCount, params.AmountPerGrid,
|
||||
)
|
||||
st.strategies[strategy.ID] = strategy
|
||||
|
||||
gridSize := (params.UpperPrice - params.LowerPrice) / float64(params.GridCount)
|
||||
totalInvestment := params.AmountPerGrid * float64(params.GridCount)
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"strategy": strategy,
|
||||
"details": map[string]interface{}{
|
||||
"grid_size": gridSize,
|
||||
"total_investment": totalInvestment,
|
||||
"profit_per_grid": (gridSize / params.LowerPrice) * 100,
|
||||
},
|
||||
"message": fmt.Sprintf("网格策略创建成功!\n价格区间: %.2f - %.2f\n网格数: %d\n每格间距: %.2f\n总投资: $%.2f",
|
||||
params.LowerPrice, params.UpperPrice, params.GridCount, gridSize, totalInvestment),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// CreateDCAStrategyTool creates a DCA strategy
|
||||
func (st *StrategyTools) CreateDCAStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"create_dca_strategy",
|
||||
"Create a Dollar Cost Averaging (DCA) strategy. Automatically buy at regular intervals.",
|
||||
`{
|
||||
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
|
||||
"interval_minutes": "number (required) - Buy interval in minutes (min: 5)",
|
||||
"amount_per_buy": "number (required) - USDT amount per purchase",
|
||||
"max_buys": "number (optional) - Maximum number of buys (default: unlimited)"
|
||||
}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
Symbol string `json:"symbol"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
AmountPerBuy float64 `json:"amount_per_buy"`
|
||||
MaxBuys int `json:"max_buys"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if params.IntervalMinutes < 5 {
|
||||
return nil, fmt.Errorf("interval must be at least 5 minutes")
|
||||
}
|
||||
if params.MaxBuys == 0 {
|
||||
params.MaxBuys = 1000 // Effectively unlimited
|
||||
}
|
||||
|
||||
strategy := st.strategyBuilder.CreateDCAStrategy(
|
||||
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys,
|
||||
)
|
||||
st.strategies[strategy.ID] = strategy
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"strategy": strategy,
|
||||
"message": fmt.Sprintf("DCA策略创建成功!\n币种: %s\n定投间隔: %d分钟\n每次金额: $%.2f\n最大次数: %d",
|
||||
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// CreateTrendStrategyTool creates a trend following strategy
|
||||
func (st *StrategyTools) CreateTrendStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"create_trend_strategy",
|
||||
"Create a trend following strategy using EMA crossover.",
|
||||
`{
|
||||
"symbols": "array (required) - Trading pairs",
|
||||
"ema_fast": "number (optional) - Fast EMA period (default: 9)",
|
||||
"ema_slow": "number (optional) - Slow EMA period (default: 21)",
|
||||
"leverage": "number (optional) - Leverage (default: 3)",
|
||||
"take_profit": "number (optional) - Take profit %",
|
||||
"stop_loss": "number (optional) - Stop loss %"
|
||||
}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
Symbols []string `json:"symbols"`
|
||||
EMAFast int `json:"ema_fast"`
|
||||
EMASlow int `json:"ema_slow"`
|
||||
Leverage int `json:"leverage"`
|
||||
TakeProfit *float64 `json:"take_profit"`
|
||||
StopLoss *float64 `json:"stop_loss"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if len(params.Symbols) == 0 {
|
||||
params.Symbols = []string{"BTCUSDT", "ETHUSDT"}
|
||||
}
|
||||
if params.EMAFast == 0 {
|
||||
params.EMAFast = 9
|
||||
}
|
||||
if params.EMASlow == 0 {
|
||||
params.EMASlow = 21
|
||||
}
|
||||
if params.Leverage == 0 {
|
||||
params.Leverage = 3
|
||||
}
|
||||
|
||||
strategy := st.strategyBuilder.CreateTrendStrategy(
|
||||
params.Symbols, params.EMAFast, params.EMASlow, params.Leverage,
|
||||
)
|
||||
strategy.TakeProfit = params.TakeProfit
|
||||
strategy.StopLoss = params.StopLoss
|
||||
st.strategies[strategy.ID] = strategy
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"strategy": strategy,
|
||||
"message": fmt.Sprintf("趋势策略创建成功!\nEMA %d/%d 交叉\n交易对: %v\n杠杆: %dx",
|
||||
params.EMAFast, params.EMASlow, params.Symbols, params.Leverage),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ListSmartStrategiesTool lists all smart strategies
|
||||
func (st *StrategyTools) ListSmartStrategiesTool() Tool {
|
||||
return NewTool(
|
||||
"list_smart_strategies",
|
||||
"List all smart strategies (both in-memory and saved).",
|
||||
`{}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var result []map[string]interface{}
|
||||
|
||||
for _, s := range st.strategies {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"name": s.Name,
|
||||
"type": s.Type,
|
||||
"description": s.Description,
|
||||
"is_active": s.IsActive,
|
||||
"symbols": s.Symbols,
|
||||
"created_at": s.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Also get strategies from store
|
||||
if dbStrategies, err := st.store.Strategy().List("default"); err == nil {
|
||||
for _, s := range dbStrategies {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"name": s.Name,
|
||||
"type": "db_strategy",
|
||||
"description": s.Description,
|
||||
"is_active": s.IsActive,
|
||||
"source": "database",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return map[string]interface{}{
|
||||
"strategies": []interface{}{},
|
||||
"message": "暂无策略。使用 create_strategy 创建一个新策略。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"strategies": result,
|
||||
"count": len(result),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetStrategyDetailsTool gets detailed strategy info
|
||||
func (st *StrategyTools) GetStrategyDetailsTool() Tool {
|
||||
return NewTool(
|
||||
"get_strategy_details",
|
||||
"Get detailed information about a specific strategy.",
|
||||
`{"strategy_id": "string (required)"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if s, ok := st.strategies[params.StrategyID]; ok {
|
||||
return map[string]interface{}{
|
||||
"strategy": s,
|
||||
"prompt_text": StrategyToPrompt(s),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// UpdateStrategyTool updates a strategy
|
||||
func (st *StrategyTools) UpdateStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"update_strategy",
|
||||
"Update an existing strategy's settings.",
|
||||
`{
|
||||
"strategy_id": "string (required)",
|
||||
"name": "string (optional)",
|
||||
"take_profit": "number (optional)",
|
||||
"stop_loss": "number (optional)",
|
||||
"leverage": "number (optional)",
|
||||
"max_positions": "number (optional)",
|
||||
"symbols": "array (optional)"
|
||||
}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
StrategyID string `json:"strategy_id"`
|
||||
Name string `json:"name"`
|
||||
TakeProfit *float64 `json:"take_profit"`
|
||||
StopLoss *float64 `json:"stop_loss"`
|
||||
Leverage int `json:"leverage"`
|
||||
MaxPositions int `json:"max_positions"`
|
||||
Symbols []string `json:"symbols"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
s, ok := st.strategies[params.StrategyID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||
}
|
||||
|
||||
if params.Name != "" {
|
||||
s.Name = params.Name
|
||||
}
|
||||
if params.TakeProfit != nil {
|
||||
s.TakeProfit = params.TakeProfit
|
||||
}
|
||||
if params.StopLoss != nil {
|
||||
s.StopLoss = params.StopLoss
|
||||
}
|
||||
if params.Leverage > 0 {
|
||||
s.LeverageConfig.DefaultLeverage = params.Leverage
|
||||
}
|
||||
if params.MaxPositions > 0 {
|
||||
s.MaxPositions = params.MaxPositions
|
||||
}
|
||||
if len(params.Symbols) > 0 {
|
||||
s.Symbols = params.Symbols
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"strategy": s,
|
||||
"message": "策略已更新",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ActivateStrategyTool activates a strategy
|
||||
func (st *StrategyTools) ActivateStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"activate_strategy",
|
||||
"Activate a strategy to start trading. ⚠️ This will start real trading!",
|
||||
`{"strategy_id": "string (required)"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
s, ok := st.strategies[params.StrategyID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||
}
|
||||
|
||||
s.IsActive = true
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("⚠️ 策略 '%s' 已激活!将开始真实交易。", s.Name),
|
||||
"strategy": s,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// DeactivateStrategyTool deactivates a strategy
|
||||
func (st *StrategyTools) DeactivateStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"deactivate_strategy",
|
||||
"Deactivate a strategy to stop trading.",
|
||||
`{"strategy_id": "string (required)"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
s, ok := st.strategies[params.StrategyID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||
}
|
||||
|
||||
s.IsActive = false
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("策略 '%s' 已停用", s.Name),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// DeleteStrategyTool deletes a strategy
|
||||
func (st *StrategyTools) DeleteStrategyTool() Tool {
|
||||
return NewTool(
|
||||
"delete_strategy",
|
||||
"Delete a strategy permanently.",
|
||||
`{"strategy_id": "string (required)"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := st.strategies[params.StrategyID]; !ok {
|
||||
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||
}
|
||||
|
||||
delete(st.strategies, params.StrategyID)
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "策略已删除",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetStrategyTemplates returns available strategy templates
|
||||
func (st *StrategyTools) GetStrategyTemplates() Tool {
|
||||
return NewTool(
|
||||
"get_strategy_templates",
|
||||
"Get available strategy templates and examples.",
|
||||
`{}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
templates := []map[string]interface{}{
|
||||
{
|
||||
"name": "AI 智能交易",
|
||||
"type": "ai",
|
||||
"description": "让 AI 自主分析市场并决策,适合不想手动盯盘的用户",
|
||||
"example": "create_strategy(name='AI智能', description='分析BTC和ETH的技术指标和市场情绪,在有明确趋势时入场')",
|
||||
},
|
||||
{
|
||||
"name": "网格交易",
|
||||
"type": "grid",
|
||||
"description": "在价格区间内自动低买高卖,适合震荡行情",
|
||||
"example": "create_grid_strategy(symbol='BTCUSDT', lower_price=90000, upper_price=100000, grid_count=20, amount_per_grid=100)",
|
||||
},
|
||||
{
|
||||
"name": "定投 DCA",
|
||||
"type": "dca",
|
||||
"description": "定期定额买入,摊薄成本,适合长期投资",
|
||||
"example": "create_dca_strategy(symbol='ETHUSDT', interval_minutes=1440, amount_per_buy=50, max_buys=365)",
|
||||
},
|
||||
{
|
||||
"name": "趋势跟踪",
|
||||
"type": "trend",
|
||||
"description": "跟随趋势,EMA金叉买入死叉卖出",
|
||||
"example": "create_trend_strategy(symbols=['BTCUSDT','ETHUSDT'], ema_fast=9, ema_slow=21, leverage=3)",
|
||||
},
|
||||
{
|
||||
"name": "RSI 超买超卖",
|
||||
"type": "custom",
|
||||
"description": "RSI 低于 30 买入,高于 70 卖出",
|
||||
"example": "create_strategy(name='RSI策略', description='当RSI14低于30时买入,高于70时卖出,止损10%')",
|
||||
},
|
||||
{
|
||||
"name": "突破策略",
|
||||
"type": "breakout",
|
||||
"description": "价格突破关键位时入场",
|
||||
"example": "create_strategy(name='突破策略', description='当价格突破20日最高点时做多,突破20日最低点时做空')",
|
||||
},
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"templates": templates,
|
||||
"message": "以上是可用的策略模板,选择一个并告诉我你想怎么定制!",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Tool represents a callable tool that the AI agent can use
|
||||
type Tool interface {
|
||||
// Name returns the tool's unique identifier
|
||||
Name() string
|
||||
|
||||
// Description returns a human-readable description for the AI
|
||||
Description() string
|
||||
|
||||
// ParameterSchema returns JSON schema for the tool's parameters
|
||||
ParameterSchema() string
|
||||
|
||||
// Execute runs the tool with the given arguments
|
||||
Execute(ctx context.Context, args json.RawMessage) (interface{}, error)
|
||||
}
|
||||
|
||||
// BaseTool provides common functionality for tools
|
||||
type BaseTool struct {
|
||||
ToolName string
|
||||
ToolDescription string
|
||||
ToolSchema string
|
||||
ExecuteFunc func(ctx context.Context, args json.RawMessage) (interface{}, error)
|
||||
}
|
||||
|
||||
func (t *BaseTool) Name() string { return t.ToolName }
|
||||
func (t *BaseTool) Description() string { return t.ToolDescription }
|
||||
func (t *BaseTool) ParameterSchema() string { return t.ToolSchema }
|
||||
|
||||
func (t *BaseTool) Execute(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
return t.ExecuteFunc(ctx, args)
|
||||
}
|
||||
|
||||
// NewTool creates a simple tool from a function
|
||||
func NewTool(name, description, schema string, fn func(ctx context.Context, args json.RawMessage) (interface{}, error)) Tool {
|
||||
return &BaseTool{
|
||||
ToolName: name,
|
||||
ToolDescription: description,
|
||||
ToolSchema: schema,
|
||||
ExecuteFunc: fn,
|
||||
}
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// TradingTools provides all trading-related tools for the AI agent
|
||||
type TradingTools struct {
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// NewTradingTools creates trading tools with access to NOFX core
|
||||
func NewTradingTools(tm *manager.TraderManager, st *store.Store) *TradingTools {
|
||||
return &TradingTools{
|
||||
traderManager: tm,
|
||||
store: st,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllTools returns all trading tools
|
||||
func (t *TradingTools) GetAllTools() []Tool {
|
||||
return []Tool{
|
||||
t.GetBalanceTool(),
|
||||
t.GetPositionsTool(),
|
||||
t.ListTradersTool(),
|
||||
t.GetTraderStatusTool(),
|
||||
t.StartTraderTool(),
|
||||
t.StopTraderTool(),
|
||||
t.GetMarketPriceTool(),
|
||||
t.OpenLongTool(),
|
||||
t.OpenShortTool(),
|
||||
t.ClosePositionTool(),
|
||||
t.ListStrategiesTool(),
|
||||
t.ListExchangesTool(),
|
||||
t.ListAIModelsTool(),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Query Tools ====================
|
||||
|
||||
// GetBalanceTool returns the get_balance tool
|
||||
func (t *TradingTools) GetBalanceTool() Tool {
|
||||
return NewTool(
|
||||
"get_balance",
|
||||
"Get account balance for a trader. Returns available balance, total equity, and margin info.",
|
||||
`{"trader_id": "string (required) - The trader ID to query"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
balance, err := trader.GetAccountInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get balance: %w", err)
|
||||
}
|
||||
|
||||
return balance, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetPositionsTool returns the get_positions tool
|
||||
func (t *TradingTools) GetPositionsTool() Tool {
|
||||
return NewTool(
|
||||
"get_positions",
|
||||
"Get all open positions for a trader. Returns symbol, side, size, entry price, unrealized P&L.",
|
||||
`{"trader_id": "string (required) - The trader ID to query"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
return positions, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ListTradersTool returns the list_traders tool
|
||||
func (t *TradingTools) ListTradersTool() Tool {
|
||||
return NewTool(
|
||||
"list_traders",
|
||||
"List all configured AI traders with their status (running/stopped), exchange, AI model, and performance.",
|
||||
`{}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
traders, err := t.store.Trader().List("default")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list traders: %w", err)
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, tr := range traders {
|
||||
traderInfo := map[string]interface{}{
|
||||
"id": tr.ID,
|
||||
"name": tr.Name,
|
||||
"is_running": tr.IsRunning,
|
||||
"ai_model_id": tr.AIModelID,
|
||||
"exchange_id": tr.ExchangeID,
|
||||
"strategy_id": tr.StrategyID,
|
||||
"created_at": tr.CreatedAt,
|
||||
}
|
||||
|
||||
// Try to get live status if trader is running
|
||||
if liveTrader, err := t.traderManager.GetTrader(tr.ID); err == nil {
|
||||
status := liveTrader.GetStatus()
|
||||
traderInfo["live_status"] = status
|
||||
}
|
||||
|
||||
result = append(result, traderInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetTraderStatusTool returns detailed status of a specific trader
|
||||
func (t *TradingTools) GetTraderStatusTool() Tool {
|
||||
return NewTool(
|
||||
"get_trader_status",
|
||||
"Get detailed status of a specific trader including current positions, recent trades, and performance metrics.",
|
||||
`{"trader_id": "string (required) - The trader ID to query"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
// Get trader config from store
|
||||
traderConfig, err := t.store.Trader().GetByID(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": traderConfig.ID,
|
||||
"name": traderConfig.Name,
|
||||
"is_running": traderConfig.IsRunning,
|
||||
"ai_model_id": traderConfig.AIModelID,
|
||||
"exchange_id": traderConfig.ExchangeID,
|
||||
"strategy_id": traderConfig.StrategyID,
|
||||
}
|
||||
|
||||
// If trader is running, get live data
|
||||
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err == nil && trader != nil {
|
||||
result["live_status"] = trader.GetStatus()
|
||||
if balance, err := trader.GetAccountInfo(); err == nil {
|
||||
result["balance"] = balance
|
||||
}
|
||||
if positions, err := trader.GetPositions(); err == nil {
|
||||
result["positions"] = positions
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Control Tools ====================
|
||||
|
||||
// StartTraderTool starts an AI trader
|
||||
func (t *TradingTools) StartTraderTool() Tool {
|
||||
return NewTool(
|
||||
"start_trader",
|
||||
"Start an AI trader to begin automated trading. The trader will execute trades based on its configured strategy.",
|
||||
`{"trader_id": "string (required) - The trader ID to start"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
existingTrader, _ := t.traderManager.GetTrader(params.TraderID)
|
||||
if existingTrader != nil {
|
||||
status := existingTrader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
return nil, fmt.Errorf("trader is already running")
|
||||
}
|
||||
// Remove from memory to reload
|
||||
t.traderManager.RemoveTrader(params.TraderID)
|
||||
}
|
||||
|
||||
// Load and start trader
|
||||
if err := t.traderManager.LoadUserTradersFromStore(t.store, "default"); err != nil {
|
||||
return nil, fmt.Errorf("failed to load trader: %w", err)
|
||||
}
|
||||
|
||||
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trader after load: %w", err)
|
||||
}
|
||||
|
||||
// Start the trader in a goroutine
|
||||
go func() {
|
||||
if err := trader.Run(); err != nil {
|
||||
logger.Errorf("Trader %s error: %v", params.TraderID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Update status in database
|
||||
if err := t.store.Trader().UpdateStatus("default", params.TraderID, true); err != nil {
|
||||
logger.Warnf("Failed to update trader status in DB: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"trader_id": params.TraderID,
|
||||
"message": "Trader started successfully",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// StopTraderTool stops an AI trader
|
||||
func (t *TradingTools) StopTraderTool() Tool {
|
||||
return NewTool(
|
||||
"stop_trader",
|
||||
"Stop an AI trader. This will halt automated trading but keep existing positions open.",
|
||||
`{"trader_id": "string (required) - The trader ID to stop"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
// Check if running
|
||||
status := trader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
|
||||
return nil, fmt.Errorf("trader is already stopped")
|
||||
}
|
||||
|
||||
// Stop the trader
|
||||
trader.Stop()
|
||||
|
||||
// Update status in database
|
||||
if err := t.store.Trader().UpdateStatus("default", params.TraderID, false); err != nil {
|
||||
logger.Warnf("Failed to update trader status in DB: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"trader_id": params.TraderID,
|
||||
"message": "Trader stopped successfully",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Trading Tools ====================
|
||||
|
||||
// GetMarketPriceTool gets current market price
|
||||
func (t *TradingTools) GetMarketPriceTool() Tool {
|
||||
return NewTool(
|
||||
"get_market_price",
|
||||
"Get current market price for a trading pair from a specific trader's exchange.",
|
||||
`{"trader_id": "string (required)", "symbol": "string (required) - e.g., BTCUSDT"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
// Get the underlying trader interface
|
||||
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||
if underlyingTrader == nil {
|
||||
return nil, fmt.Errorf("underlying trader not available")
|
||||
}
|
||||
|
||||
price, err := underlyingTrader.GetMarketPrice(params.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get price: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"symbol": params.Symbol,
|
||||
"price": price,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// OpenLongTool opens a long position
|
||||
func (t *TradingTools) OpenLongTool() Tool {
|
||||
return NewTool(
|
||||
"open_long",
|
||||
"Open a long (buy) position. WARNING: This will execute a real trade!",
|
||||
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if params.Leverage == 0 {
|
||||
params.Leverage = 1
|
||||
}
|
||||
|
||||
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||
if underlyingTrader == nil {
|
||||
return nil, fmt.Errorf("underlying trader not available")
|
||||
}
|
||||
|
||||
result, err := underlyingTrader.OpenLong(params.Symbol, params.Quantity, params.Leverage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// OpenShortTool opens a short position
|
||||
func (t *TradingTools) OpenShortTool() Tool {
|
||||
return NewTool(
|
||||
"open_short",
|
||||
"Open a short (sell) position. WARNING: This will execute a real trade!",
|
||||
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if params.Leverage == 0 {
|
||||
params.Leverage = 1
|
||||
}
|
||||
|
||||
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||
if underlyingTrader == nil {
|
||||
return nil, fmt.Errorf("underlying trader not available")
|
||||
}
|
||||
|
||||
result, err := underlyingTrader.OpenShort(params.Symbol, params.Quantity, params.Leverage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ClosePositionTool closes a position
|
||||
func (t *TradingTools) ClosePositionTool() Tool {
|
||||
return NewTool(
|
||||
"close_position",
|
||||
"Close an existing position (long or short). WARNING: This will execute a real trade!",
|
||||
`{"trader_id": "string (required)", "symbol": "string (required)", "side": "string (required) - 'long' or 'short'", "quantity": "number (optional) - leave empty to close all"}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
var params struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trader not found: %w", err)
|
||||
}
|
||||
|
||||
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||
if underlyingTrader == nil {
|
||||
return nil, fmt.Errorf("underlying trader not available")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
|
||||
if params.Side == "long" {
|
||||
result, err = underlyingTrader.CloseLong(params.Symbol, params.Quantity)
|
||||
} else if params.Side == "short" {
|
||||
result, err = underlyingTrader.CloseShort(params.Symbol, params.Quantity)
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid side: %s (must be 'long' or 'short')", params.Side)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close position: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Config Tools ====================
|
||||
|
||||
// ListStrategiesTool lists all strategies
|
||||
func (t *TradingTools) ListStrategiesTool() Tool {
|
||||
return NewTool(
|
||||
"list_strategies",
|
||||
"List all trading strategies configured in the system.",
|
||||
`{}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
strategies, err := t.store.Strategy().List("default")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list strategies: %w", err)
|
||||
}
|
||||
return strategies, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ListExchangesTool lists all exchange configurations
|
||||
func (t *TradingTools) ListExchangesTool() Tool {
|
||||
return NewTool(
|
||||
"list_exchanges",
|
||||
"List all configured exchanges (without showing API keys).",
|
||||
`{}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
exchanges, err := t.store.Exchange().List("default")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list exchanges: %w", err)
|
||||
}
|
||||
|
||||
// Remove sensitive data
|
||||
var result []map[string]interface{}
|
||||
for _, ex := range exchanges {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": ex.ID,
|
||||
"name": ex.Name,
|
||||
"exchange_type": ex.ExchangeType,
|
||||
"type": ex.Type,
|
||||
"enabled": ex.Enabled,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ListAIModelsTool lists all AI model configurations
|
||||
func (t *TradingTools) ListAIModelsTool() Tool {
|
||||
return NewTool(
|
||||
"list_ai_models",
|
||||
"List all configured AI models (without showing API keys).",
|
||||
`{}`,
|
||||
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||
models, err := t.store.AIModel().List("default")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list AI models: %w", err)
|
||||
}
|
||||
|
||||
// Remove sensitive data
|
||||
var result []map[string]interface{}
|
||||
for _, m := range models {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"name": m.Name,
|
||||
"provider": m.Provider,
|
||||
"custom_model": m.CustomModelName,
|
||||
"enabled": m.Enabled,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
18
go.mod
18
go.mod
@@ -5,25 +5,19 @@ go 1.25.3
|
||||
require (
|
||||
github.com/adshao/go-binance/v2 v2.8.9
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
|
||||
github.com/ethereum/go-ethereum v1.16.7
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/net v0.43.0
|
||||
gopkg.in/telebot.v3 v3.3.8
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
modernc.org/sqlite v1.40.0
|
||||
)
|
||||
|
||||
@@ -33,6 +27,7 @@ require (
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
@@ -44,6 +39,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
@@ -54,7 +50,6 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -67,6 +62,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -97,12 +93,16 @@ require (
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -447,7 +447,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_top":
|
||||
@@ -467,27 +466,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_low":
|
||||
// 持仓减少榜,适合做空
|
||||
if !coinSource.UseOILow {
|
||||
logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
coins, err := e.getOILowCoins(coinSource.OILowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
@@ -513,17 +491,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseOILow {
|
||||
oiLowCoins, err := e.getOILowCoins(coinSource.OILowLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get OI Low: %v", err)
|
||||
} else {
|
||||
for _, coin := range oiLowCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_low")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
if _, exists := symbolSources[symbol]; !exists {
|
||||
@@ -594,7 +561,7 @@ func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
||||
|
||||
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
limit = 20
|
||||
}
|
||||
|
||||
positions, err := e.nofxosClient.GetOITopPositions()
|
||||
@@ -616,30 +583,6 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
positions, err := e.nofxosClient.GetOILowPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for i, pos := range positions {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
symbol := market.Normalize(pos.Symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"oi_low"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -1346,38 +1289,13 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
||||
|
||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if len(sources) > 1 {
|
||||
// 多信号源组合
|
||||
hasAI500 := false
|
||||
hasOITop := false
|
||||
hasOILow := false
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
hasAI500 = true
|
||||
case "oi_top":
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = true
|
||||
}
|
||||
}
|
||||
if hasAI500 && hasOITop {
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
}
|
||||
if hasAI500 && hasOILow {
|
||||
return " (AI500+OI_Low dual signal)"
|
||||
}
|
||||
if hasOITop && hasOILow {
|
||||
return " (OI_Top+OI_Low)"
|
||||
}
|
||||
return " (Multiple sources)"
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
case "ai500":
|
||||
return " (AI500)"
|
||||
case "oi_top":
|
||||
return " (OI_Top 持仓增加)"
|
||||
case "oi_low":
|
||||
return " (OI_Low 持仓减少)"
|
||||
return " (OI_Top position growth)"
|
||||
case "static":
|
||||
return " (Manual selection)"
|
||||
}
|
||||
@@ -1849,8 +1767,8 @@ func compactArrayOpen(s string) string {
|
||||
// ============================================================================
|
||||
|
||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||
for i := range decisions {
|
||||
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
for i, decision := range decisions {
|
||||
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
102
main.go
102
main.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"nofx/api"
|
||||
"nofx/assistant"
|
||||
"nofx/auth"
|
||||
"nofx/backtest"
|
||||
"nofx/config"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"nofx/manager"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -138,52 +136,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize and start Telegram bot (if configured)
|
||||
var telegramBot *telegram.Bot
|
||||
telegramConfig := telegram.LoadConfigFromEnv()
|
||||
if telegramConfig.Token != "" {
|
||||
logger.Info("🤖 Initializing Smart Trading Assistant...")
|
||||
|
||||
// Create AI client for the assistant
|
||||
aiClient := createAssistantAIClient()
|
||||
if aiClient == nil {
|
||||
logger.Error("❌ No AI API key configured, Telegram bot disabled")
|
||||
} else {
|
||||
// Create Smart AI Agent with trading context awareness
|
||||
agentConfig := assistant.DefaultAgentConfig()
|
||||
smartAgent := assistant.NewSmartAgent(aiClient, agentConfig, traderManager, st)
|
||||
|
||||
// Register trading tools
|
||||
tradingTools := assistant.NewTradingTools(traderManager, st)
|
||||
smartAgent.RegisterTools(tradingTools.GetAllTools()...)
|
||||
|
||||
// Register strategy tools
|
||||
strategyTools := assistant.NewStrategyTools(st)
|
||||
smartAgent.RegisterTools(strategyTools.GetAllTools()...)
|
||||
|
||||
// Create and start Telegram bot
|
||||
var err error
|
||||
telegramBot, err = telegram.NewBot(telegramConfig, smartAgent.Agent)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ Failed to create Telegram bot: %v", err)
|
||||
} else {
|
||||
// Start background monitor with alert forwarding to Telegram
|
||||
smartAgent.OnAlert(func(alert assistant.Alert) {
|
||||
telegramBot.BroadcastAlert(alert.Message)
|
||||
})
|
||||
smartAgent.StartMonitor()
|
||||
|
||||
go telegramBot.Start()
|
||||
logger.Info("✅ Smart Trading Assistant started successfully")
|
||||
logger.Info(" 📊 Real-time context injection: enabled")
|
||||
logger.Info(" 🔍 Background monitoring: enabled")
|
||||
logger.Info(" ⚠️ Proactive alerts: enabled")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Info("ℹ️ Telegram bot not configured (set TELEGRAM_BOT_TOKEN to enable)")
|
||||
}
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -194,11 +146,6 @@ func main() {
|
||||
<-quit
|
||||
logger.Info("📴 Shutdown signal received, closing system...")
|
||||
|
||||
// Stop Telegram bot
|
||||
if telegramBot != nil {
|
||||
telegramBot.Stop()
|
||||
}
|
||||
|
||||
// Stop all traders
|
||||
traderManager.StopAll()
|
||||
logger.Info("✅ System shut down safely")
|
||||
@@ -214,55 +161,6 @@ func newSharedMCPClient() mcp.AIClient {
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
|
||||
// createAssistantAIClient creates an AI client for the Telegram assistant
|
||||
// Supports multiple providers based on environment configuration
|
||||
func createAssistantAIClient() mcp.AIClient {
|
||||
// Try different providers in order of preference
|
||||
|
||||
// 1. DeepSeek (cost-effective, recommended)
|
||||
if apiKey := os.Getenv("DEEPSEEK_API_KEY"); apiKey != "" {
|
||||
client := mcp.NewDeepSeekClient()
|
||||
customURL := os.Getenv("DEEPSEEK_API_URL")
|
||||
customModel := os.Getenv("DEEPSEEK_MODEL")
|
||||
client.SetAPIKey(apiKey, customURL, customModel)
|
||||
logger.Info("🧠 Assistant using DeepSeek AI")
|
||||
return client
|
||||
}
|
||||
|
||||
// 2. Claude
|
||||
if apiKey := os.Getenv("CLAUDE_API_KEY"); apiKey != "" {
|
||||
client := mcp.NewClaudeClient()
|
||||
customURL := os.Getenv("CLAUDE_API_URL")
|
||||
customModel := os.Getenv("CLAUDE_MODEL")
|
||||
client.SetAPIKey(apiKey, customURL, customModel)
|
||||
logger.Info("🧠 Assistant using Claude AI")
|
||||
return client
|
||||
}
|
||||
|
||||
// 3. OpenAI
|
||||
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
|
||||
client := mcp.NewOpenAIClient()
|
||||
customURL := os.Getenv("OPENAI_API_URL")
|
||||
customModel := os.Getenv("OPENAI_MODEL")
|
||||
client.SetAPIKey(apiKey, customURL, customModel)
|
||||
logger.Info("🧠 Assistant using OpenAI")
|
||||
return client
|
||||
}
|
||||
|
||||
// 4. Qwen
|
||||
if apiKey := os.Getenv("QWEN_API_KEY"); apiKey != "" {
|
||||
client := mcp.NewQwenClient()
|
||||
customURL := os.Getenv("QWEN_API_URL")
|
||||
customModel := os.Getenv("QWEN_MODEL")
|
||||
client.SetAPIKey(apiKey, customURL, customModel)
|
||||
logger.Info("🧠 Assistant using Qwen AI")
|
||||
return client
|
||||
}
|
||||
|
||||
logger.Warn("⚠️ No AI API key configured for assistant")
|
||||
return nil
|
||||
}
|
||||
|
||||
// initInstallationID initializes the anonymous installation ID for experience improvement
|
||||
// This ID is persisted in database and used for anonymous usage statistics
|
||||
func initInstallationID(st *store.Store) {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package coinank_api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const MainDepthWsUrl = "wss://ws.coinank.com/wsDepth/wsKline"
|
||||
|
||||
type DepthWs struct {
|
||||
conn *websocket.Conn
|
||||
DepthV3Ch <-chan *WsResult[DepthV3]
|
||||
}
|
||||
|
||||
// DepthWsConn connect ws , read data from DepthV3Ch
|
||||
func DepthWsConn(ctx context.Context) (*DepthWs, error) {
|
||||
conn, ch, err := depth_ws(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws := &DepthWs{
|
||||
conn: conn,
|
||||
DepthV3Ch: ch,
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// Subscribe subscribe depth
|
||||
func (ws *DepthWs) Subscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
|
||||
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
|
||||
info := SubscribeInfo{
|
||||
Op: "subscribe",
|
||||
Args: args,
|
||||
}
|
||||
json, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = websocket.Message.Send(ws.conn, json)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnSubscribe unsubscribe depth
|
||||
func (ws *DepthWs) UnSubscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
|
||||
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
|
||||
info := SubscribeInfo{
|
||||
Op: "unsubscribe",
|
||||
Args: args,
|
||||
}
|
||||
json, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = websocket.Message.Send(ws.conn, json)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close websocket
|
||||
func (ws *DepthWs) Close() error {
|
||||
return ws.conn.Close()
|
||||
}
|
||||
|
||||
func depth_ws(ctx context.Context) (*websocket.Conn, <-chan *WsResult[DepthV3], error) {
|
||||
config, err := websocket.NewConfig(MainDepthWsUrl, "http://localhost")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
conn, err := config.DialContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ch := make(chan *WsResult[DepthV3], 1024)
|
||||
go depth_read(conn, ch)
|
||||
return conn, ch, nil
|
||||
}
|
||||
|
||||
func depth_read(conn *websocket.Conn, ch chan *WsResult[DepthV3]) {
|
||||
defer conn.Close()
|
||||
defer close(ch)
|
||||
var msg string
|
||||
for {
|
||||
err := websocket.Message.Receive(conn, &msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var depth WsResult[DepthV3]
|
||||
err = json.Unmarshal([]byte(msg), &depth)
|
||||
if err == nil {
|
||||
ch <- &depth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DepthV3 struct {
|
||||
Type string `json:"type"`
|
||||
Ts uint64 `json:"ts"`
|
||||
Asks [][]string `json:"asks"`
|
||||
Bids [][]string `json:"bids"`
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package coinank_api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDepthWs(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
ws, err := DepthWsConn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
for tickers := range ws.DepthV3Ch {
|
||||
msg, err := json.Marshal(tickers)
|
||||
if err != nil {
|
||||
fmt.Println("json err:", err)
|
||||
}
|
||||
fmt.Println(string(msg))
|
||||
}
|
||||
fmt.Println("DepthV3Ch closed")
|
||||
}()
|
||||
err = ws.Subscribe("BTCUSDT", coinank_enum.Binance, "0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("sub success")
|
||||
time.Sleep(10 * time.Second)
|
||||
err = ws.UnSubscribe("BTCUSDT", coinank_enum.Binance, "0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("unsub success")
|
||||
time.Sleep(10 * time.Second)
|
||||
ws.Close()
|
||||
fmt.Println("cancel success")
|
||||
}
|
||||
@@ -73,10 +73,8 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
// 空列表是正常情况,不是错误
|
||||
if len(response.Data.Coins) == 0 {
|
||||
log.Printf("ℹ️ AI500 returned empty coin list (no coins meet criteria currently)")
|
||||
return []CoinData{}, nil
|
||||
return nil, fmt.Errorf("coin list is empty")
|
||||
}
|
||||
|
||||
// Set IsAvailable flag
|
||||
|
||||
@@ -106,11 +106,11 @@ func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosit
|
||||
|
||||
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
|
||||
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
|
||||
positions, _, err := c.fetchOIRanking("top", "1h", 20)
|
||||
data, err := c.GetOIRanking("1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return positions, nil
|
||||
return data.TopPositions, nil
|
||||
}
|
||||
|
||||
// GetOITopSymbols retrieves OI top coin symbol list
|
||||
@@ -129,31 +129,6 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
|
||||
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
|
||||
positions, _, err := c.fetchOIRanking("low", "1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOILowSymbols retrieves OI low coin symbol list
|
||||
func (c *Client) GetOILowSymbols() ([]string, error) {
|
||||
positions, err := c.GetOILowPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := NormalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||
if data == nil {
|
||||
|
||||
@@ -158,19 +158,16 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
newFee := pos.Fee + addFee
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": newQty,
|
||||
"entry_quantity": newEntryQty,
|
||||
"entry_price": newEntryPrice,
|
||||
"fee": newFee,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ReducePositionQuantity reduces position quantity for partial close
|
||||
// If quantity reaches 0 (or near 0), automatically closes the position
|
||||
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
||||
var pos TraderPosition
|
||||
if err := s.db.First(&pos, id).Error; err != nil {
|
||||
@@ -190,40 +187,19 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
// Check if position should be fully closed (quantity reduced to ~0)
|
||||
const QUANTITY_TOLERANCE = 0.0001
|
||||
if newQty <= QUANTITY_TOLERANCE {
|
||||
// Auto-close: set status to CLOSED
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": 0,
|
||||
"fee": newFee,
|
||||
"exit_price": newExitPrice,
|
||||
"realized_pnl": newPnL,
|
||||
"status": "CLOSED",
|
||||
"exit_time": nowMs,
|
||||
"close_reason": "sync",
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": newQty,
|
||||
"fee": newFee,
|
||||
"exit_price": newExitPrice,
|
||||
"realized_pnl": newPnL,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
||||
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exchange_id": exchangeID,
|
||||
"exchange_type": exchangeType,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ type PromptSectionsConfig struct {
|
||||
|
||||
// CoinSourceConfig coin source configuration
|
||||
type CoinSourceConfig struct {
|
||||
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
|
||||
// source type: "static" | "ai500" | "oi_top" | "mixed"
|
||||
SourceType string `json:"source_type"`
|
||||
// static coin list (used when source_type = "static")
|
||||
StaticCoins []string `json:"static_coins,omitempty"`
|
||||
@@ -107,14 +107,10 @@ type CoinSourceConfig struct {
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
// AI500 coin pool maximum count
|
||||
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||
// whether to use OI Top (持仓增加榜,适合做多)
|
||||
// whether to use OI Top
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
// OI Top maximum count
|
||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||
// whether to use OI Low (持仓减少榜,适合做空)
|
||||
UseOILow bool `json:"use_oi_low"`
|
||||
// OI Low maximum count
|
||||
OILowLimit int `json:"oi_low_limit,omitempty"`
|
||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||
}
|
||||
|
||||
@@ -252,9 +248,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
UseAI500: true,
|
||||
AI500Limit: 10,
|
||||
UseOITop: false,
|
||||
OITopLimit: 10,
|
||||
UseOILow: false,
|
||||
OILowLimit: 10,
|
||||
OITopLimit: 20,
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
|
||||
490
telegram/bot.go
490
telegram/bot.go
@@ -1,490 +0,0 @@
|
||||
// Package telegram provides Telegram bot integration for NOFX trading assistant
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"nofx/assistant"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
// Bot represents the Telegram bot for NOFX
|
||||
type Bot struct {
|
||||
bot *tele.Bot
|
||||
agent *assistant.Agent
|
||||
config BotConfig
|
||||
|
||||
// Allowed users (for security)
|
||||
allowedUsers map[int64]bool
|
||||
allowedUsersLock sync.RWMutex
|
||||
|
||||
// Rate limiting
|
||||
rateLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// BotConfig holds bot configuration
|
||||
type BotConfig struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
// Polling or webhook mode
|
||||
UseWebhook bool `json:"use_webhook"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
WebhookPort int `json:"webhook_port"`
|
||||
|
||||
// Security
|
||||
AllowedUserIDs []int64 `json:"allowed_user_ids"` // Empty = allow all
|
||||
AdminUserIDs []int64 `json:"admin_user_ids"`
|
||||
|
||||
// Rate limiting
|
||||
MaxMessagesPerMinute int `json:"max_messages_per_minute"`
|
||||
|
||||
// Language
|
||||
DefaultLanguage string `json:"default_language"` // "en" or "zh"
|
||||
}
|
||||
|
||||
// DefaultBotConfig returns default configuration
|
||||
func DefaultBotConfig() BotConfig {
|
||||
return BotConfig{
|
||||
MaxMessagesPerMinute: 30,
|
||||
DefaultLanguage: "zh",
|
||||
}
|
||||
}
|
||||
|
||||
// NewBot creates a new Telegram bot
|
||||
func NewBot(config BotConfig, agent *assistant.Agent) (*Bot, error) {
|
||||
if config.Token == "" {
|
||||
return nil, fmt.Errorf("telegram bot token is required")
|
||||
}
|
||||
|
||||
settings := tele.Settings{
|
||||
Token: config.Token,
|
||||
Poller: &tele.LongPoller{Timeout: 30 * time.Second},
|
||||
}
|
||||
|
||||
teleBot, err := tele.NewBot(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||
}
|
||||
|
||||
bot := &Bot{
|
||||
bot: teleBot,
|
||||
agent: agent,
|
||||
config: config,
|
||||
allowedUsers: make(map[int64]bool),
|
||||
rateLimiter: NewRateLimiter(config.MaxMessagesPerMinute),
|
||||
}
|
||||
|
||||
// Initialize allowed users
|
||||
for _, uid := range config.AllowedUserIDs {
|
||||
bot.allowedUsers[uid] = true
|
||||
}
|
||||
|
||||
// Register handlers
|
||||
bot.registerHandlers()
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// Start starts the bot
|
||||
func (b *Bot) Start() {
|
||||
logger.Info("🤖 Starting Telegram bot...")
|
||||
b.bot.Start()
|
||||
}
|
||||
|
||||
// Stop stops the bot
|
||||
func (b *Bot) Stop() {
|
||||
logger.Info("🤖 Stopping Telegram bot...")
|
||||
b.bot.Stop()
|
||||
}
|
||||
|
||||
// registerHandlers sets up all message handlers
|
||||
func (b *Bot) registerHandlers() {
|
||||
// Middleware for access control and rate limiting
|
||||
b.bot.Use(b.accessControlMiddleware)
|
||||
b.bot.Use(b.rateLimitMiddleware)
|
||||
|
||||
// Command handlers
|
||||
b.bot.Handle("/start", b.handleStart)
|
||||
b.bot.Handle("/help", b.handleHelp)
|
||||
b.bot.Handle("/status", b.handleStatus)
|
||||
b.bot.Handle("/balance", b.handleBalance)
|
||||
b.bot.Handle("/positions", b.handlePositions)
|
||||
b.bot.Handle("/traders", b.handleTraders)
|
||||
b.bot.Handle("/clear", b.handleClear)
|
||||
|
||||
// Handle all text messages (send to AI agent)
|
||||
b.bot.Handle(tele.OnText, b.handleText)
|
||||
|
||||
// Handle callbacks (for inline keyboards)
|
||||
b.bot.Handle(tele.OnCallback, b.handleCallback)
|
||||
|
||||
logger.Info("✅ Telegram handlers registered")
|
||||
}
|
||||
|
||||
// accessControlMiddleware checks if user is allowed
|
||||
func (b *Bot) accessControlMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
|
||||
// If allowlist is empty, allow all
|
||||
if len(b.config.AllowedUserIDs) == 0 {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
b.allowedUsersLock.RLock()
|
||||
allowed := b.allowedUsers[userID]
|
||||
b.allowedUsersLock.RUnlock()
|
||||
|
||||
if !allowed {
|
||||
logger.Warnf("⚠️ Unauthorized access attempt from user %d", userID)
|
||||
return c.Send("⛔ Sorry, you are not authorized to use this bot.\n\n抱歉,您没有使用此机器人的权限。")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimitMiddleware implements rate limiting
|
||||
func (b *Bot) rateLimitMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
|
||||
if !b.rateLimiter.Allow(userID) {
|
||||
return c.Send("⏳ Please slow down. Too many messages.\n\n请稍等,消息发送过于频繁。")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Command Handlers ====================
|
||||
|
||||
func (b *Bot) handleStart(c tele.Context) error {
|
||||
welcome := `🚀 *Welcome to NOFX Trading Assistant!*
|
||||
|
||||
I'm your AI-powered trading assistant. I can help you:
|
||||
|
||||
📊 *Monitor* - Check balances, positions, and market prices
|
||||
🤖 *Manage* - Start/stop AI traders, configure strategies
|
||||
💹 *Trade* - Execute trades (with confirmation)
|
||||
📈 *Analyze* - Market analysis and AI debates
|
||||
|
||||
*Commands:*
|
||||
/help - Show all commands
|
||||
/status - System status
|
||||
/balance - Account balances
|
||||
/positions - Current positions
|
||||
/traders - List AI traders
|
||||
/clear - Clear conversation history
|
||||
|
||||
Or just chat with me in natural language!
|
||||
|
||||
---
|
||||
|
||||
🚀 *欢迎使用 NOFX 交易助手!*
|
||||
|
||||
我是你的 AI 交易助手,可以帮你:
|
||||
|
||||
📊 *监控* - 查看余额、持仓、行情
|
||||
🤖 *管理* - 启停 AI 交易员、配置策略
|
||||
💹 *交易* - 执行交易(需确认)
|
||||
📈 *分析* - 市场分析和 AI 辩论
|
||||
|
||||
直接用自然语言和我对话即可!`
|
||||
|
||||
return c.Send(welcome, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
func (b *Bot) handleHelp(c tele.Context) error {
|
||||
help := `📖 *NOFX Trading Assistant Help*
|
||||
|
||||
*Commands:*
|
||||
• /start - Welcome message
|
||||
• /help - This help message
|
||||
• /status - System overview
|
||||
• /balance - Show all balances
|
||||
• /positions - Show all positions
|
||||
• /traders - List AI traders
|
||||
• /clear - Clear conversation history
|
||||
|
||||
*Natural Language Examples:*
|
||||
• "查看我的余额"
|
||||
• "BTC 现在多少钱"
|
||||
• "启动交易员 xxx"
|
||||
• "帮我平掉 ETH 的多单"
|
||||
• "我的持仓盈亏怎么样"
|
||||
• "列出所有策略"
|
||||
|
||||
*Tips:*
|
||||
• I'll always confirm before executing trades
|
||||
• Use specific trader names/IDs for operations
|
||||
• Ask me anything about your trading!`
|
||||
|
||||
return c.Send(help, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
func (b *Bot) handleStatus(c tele.Context) error {
|
||||
ctx := context.Background()
|
||||
sessionID := b.getSessionID(c)
|
||||
|
||||
response, err := b.agent.Chat(ctx, sessionID, "Please give me a brief system status: list all traders and their status, show total positions count.")
|
||||
if err != nil {
|
||||
logger.Errorf("Agent error: %v", err)
|
||||
return c.Send("❌ Failed to get status. Please try again.")
|
||||
}
|
||||
|
||||
return c.Send(response.Text, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
func (b *Bot) handleBalance(c tele.Context) error {
|
||||
ctx := context.Background()
|
||||
sessionID := b.getSessionID(c)
|
||||
|
||||
response, err := b.agent.Chat(ctx, sessionID, "Show me all account balances from all running traders.")
|
||||
if err != nil {
|
||||
logger.Errorf("Agent error: %v", err)
|
||||
return c.Send("❌ Failed to get balances. Please try again.")
|
||||
}
|
||||
|
||||
return c.Send(response.Text, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
func (b *Bot) handlePositions(c tele.Context) error {
|
||||
ctx := context.Background()
|
||||
sessionID := b.getSessionID(c)
|
||||
|
||||
response, err := b.agent.Chat(ctx, sessionID, "Show me all current positions from all running traders with P&L.")
|
||||
if err != nil {
|
||||
logger.Errorf("Agent error: %v", err)
|
||||
return c.Send("❌ Failed to get positions. Please try again.")
|
||||
}
|
||||
|
||||
return c.Send(response.Text, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
func (b *Bot) handleTraders(c tele.Context) error {
|
||||
ctx := context.Background()
|
||||
sessionID := b.getSessionID(c)
|
||||
|
||||
response, err := b.agent.Chat(ctx, sessionID, "List all configured AI traders with their status, exchange, and AI model.")
|
||||
if err != nil {
|
||||
logger.Errorf("Agent error: %v", err)
|
||||
return c.Send("❌ Failed to list traders. Please try again.")
|
||||
}
|
||||
|
||||
return c.Send(response.Text, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
func (b *Bot) handleClear(c tele.Context) error {
|
||||
sessionID := b.getSessionID(c)
|
||||
session := b.agent.GetSession(sessionID)
|
||||
session.Clear()
|
||||
|
||||
return c.Send("🧹 Conversation history cleared.\n\n对话历史已清除。")
|
||||
}
|
||||
|
||||
// ==================== Message Handler ====================
|
||||
|
||||
func (b *Bot) handleText(c tele.Context) error {
|
||||
text := strings.TrimSpace(c.Text())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show typing indicator
|
||||
_ = c.Notify(tele.Typing)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sessionID := b.getSessionID(c)
|
||||
|
||||
// Set user info in session
|
||||
session := b.agent.GetSession(sessionID)
|
||||
session.SetUserInfo(
|
||||
strconv.FormatInt(c.Sender().ID, 10),
|
||||
c.Sender().Username,
|
||||
"telegram",
|
||||
)
|
||||
|
||||
logger.Infof("💬 [%s] %s: %s", sessionID, c.Sender().Username, text)
|
||||
|
||||
response, err := b.agent.Chat(ctx, sessionID, text)
|
||||
if err != nil {
|
||||
logger.Errorf("Agent error: %v", err)
|
||||
return c.Send("❌ Sorry, something went wrong. Please try again.\n\n抱歉,出现了问题,请重试。")
|
||||
}
|
||||
|
||||
logger.Infof("🤖 [%s] Response: %s", sessionID, truncate(response.Text, 100))
|
||||
|
||||
// Send response (split if too long)
|
||||
return b.sendLongMessage(c, response.Text)
|
||||
}
|
||||
|
||||
// handleCallback handles inline keyboard callbacks
|
||||
func (b *Bot) handleCallback(c tele.Context) error {
|
||||
data := c.Callback().Data
|
||||
|
||||
// Parse callback data (format: "action:param1:param2")
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 0 {
|
||||
return c.Respond()
|
||||
}
|
||||
|
||||
action := parts[0]
|
||||
|
||||
switch action {
|
||||
case "confirm_trade":
|
||||
if len(parts) >= 2 {
|
||||
// Execute the confirmed trade
|
||||
return b.executeConfirmedTrade(c, parts[1:])
|
||||
}
|
||||
case "cancel_trade":
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "Trade cancelled / 交易已取消"})
|
||||
return c.Edit("❌ Trade cancelled.\n\n交易已取消。")
|
||||
}
|
||||
|
||||
return c.Respond()
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
func (b *Bot) getSessionID(c tele.Context) string {
|
||||
return fmt.Sprintf("tg_%d", c.Chat().ID)
|
||||
}
|
||||
|
||||
func (b *Bot) sendLongMessage(c tele.Context, text string) error {
|
||||
// Telegram message limit is 4096 characters
|
||||
const maxLen = 4000
|
||||
|
||||
if len(text) <= maxLen {
|
||||
return c.Send(text, tele.ModeMarkdown)
|
||||
}
|
||||
|
||||
// Split into chunks
|
||||
for len(text) > 0 {
|
||||
chunk := text
|
||||
if len(chunk) > maxLen {
|
||||
// Try to split at newline
|
||||
idx := strings.LastIndex(text[:maxLen], "\n")
|
||||
if idx > 0 {
|
||||
chunk = text[:idx]
|
||||
text = text[idx+1:]
|
||||
} else {
|
||||
chunk = text[:maxLen]
|
||||
text = text[maxLen:]
|
||||
}
|
||||
} else {
|
||||
text = ""
|
||||
}
|
||||
|
||||
if err := c.Send(chunk, tele.ModeMarkdown); err != nil {
|
||||
// Try without markdown if it fails
|
||||
if err := c.Send(chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) executeConfirmedTrade(c tele.Context, params []string) error {
|
||||
// TODO: Implement trade execution from callback
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "Executing trade..."})
|
||||
return c.Edit("✅ Trade executed.\n\n交易已执行。")
|
||||
}
|
||||
|
||||
// AddAllowedUser adds a user to the allowlist
|
||||
func (b *Bot) AddAllowedUser(userID int64) {
|
||||
b.allowedUsersLock.Lock()
|
||||
defer b.allowedUsersLock.Unlock()
|
||||
b.allowedUsers[userID] = true
|
||||
}
|
||||
|
||||
// BroadcastAlert sends an alert to all admin users
|
||||
func (b *Bot) BroadcastAlert(message string) {
|
||||
for _, adminID := range b.config.AdminUserIDs {
|
||||
chat := &tele.Chat{ID: adminID}
|
||||
_, err := b.bot.Send(chat, "🚨 "+message)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to send alert to admin %d: %v", adminID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// If no admins configured, send to allowed users
|
||||
if len(b.config.AdminUserIDs) == 0 {
|
||||
for userID := range b.allowedUsers {
|
||||
chat := &tele.Chat{ID: userID}
|
||||
_, _ = b.bot.Send(chat, "🚨 "+message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveAllowedUser removes a user from the allowlist
|
||||
func (b *Bot) RemoveAllowedUser(userID int64) {
|
||||
b.allowedUsersLock.Lock()
|
||||
defer b.allowedUsersLock.Unlock()
|
||||
delete(b.allowedUsers, userID)
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// ==================== Rate Limiter ====================
|
||||
|
||||
// RateLimiter implements per-user rate limiting
|
||||
type RateLimiter struct {
|
||||
maxPerMinute int
|
||||
users map[int64][]time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(maxPerMinute int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
maxPerMinute: maxPerMinute,
|
||||
users: make(map[int64][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks if a user is allowed to send a message
|
||||
func (r *RateLimiter) Allow(userID int64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-time.Minute)
|
||||
|
||||
// Get user's recent messages
|
||||
timestamps := r.users[userID]
|
||||
|
||||
// Filter out old timestamps
|
||||
var recent []time.Time
|
||||
for _, t := range timestamps {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if under limit
|
||||
if len(recent) >= r.maxPerMinute {
|
||||
return false
|
||||
}
|
||||
|
||||
// Add current timestamp
|
||||
recent = append(recent, now)
|
||||
r.users[userID] = recent
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadConfigFromEnv loads Telegram bot configuration from environment variables
|
||||
func LoadConfigFromEnv() BotConfig {
|
||||
config := DefaultBotConfig()
|
||||
|
||||
// Bot token (required)
|
||||
config.Token = os.Getenv("TELEGRAM_BOT_TOKEN")
|
||||
|
||||
// Webhook settings
|
||||
if webhook := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhook != "" {
|
||||
config.UseWebhook = true
|
||||
config.WebhookURL = webhook
|
||||
}
|
||||
if port := os.Getenv("TELEGRAM_WEBHOOK_PORT"); port != "" {
|
||||
if p, err := strconv.Atoi(port); err == nil {
|
||||
config.WebhookPort = p
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed users (comma-separated list of user IDs)
|
||||
if allowedStr := os.Getenv("TELEGRAM_ALLOWED_USERS"); allowedStr != "" {
|
||||
for _, idStr := range strings.Split(allowedStr, ",") {
|
||||
idStr = strings.TrimSpace(idStr)
|
||||
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
|
||||
config.AllowedUserIDs = append(config.AllowedUserIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin users
|
||||
if adminStr := os.Getenv("TELEGRAM_ADMIN_USERS"); adminStr != "" {
|
||||
for _, idStr := range strings.Split(adminStr, ",") {
|
||||
idStr = strings.TrimSpace(idStr)
|
||||
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
|
||||
config.AdminUserIDs = append(config.AdminUserIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if rateStr := os.Getenv("TELEGRAM_RATE_LIMIT"); rateStr != "" {
|
||||
if rate, err := strconv.Atoi(rateStr); err == nil {
|
||||
config.MaxMessagesPerMinute = rate
|
||||
}
|
||||
}
|
||||
|
||||
// Language
|
||||
if lang := os.Getenv("TELEGRAM_LANGUAGE"); lang != "" {
|
||||
config.DefaultLanguage = lang
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -534,12 +534,6 @@ func (at *AutoTrader) runCycle() error {
|
||||
return fmt.Errorf("failed to build trading context: %w", err)
|
||||
}
|
||||
|
||||
// 如果没有候选币种,友好提示并跳过本周期
|
||||
if len(ctx.CandidateCoins) == 0 {
|
||||
logger.Infof("ℹ️ No candidate coins available, skipping this cycle")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||||
at.saveEquitySnapshot(ctx)
|
||||
|
||||
|
||||
@@ -56,8 +56,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
|
||||
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||
syncStartTimeMs := nowMs
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
|
||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
@@ -96,17 +100,18 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
|
||||
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
|
||||
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
||||
// IMPORTANT: Must run always, not just when symbolMap is empty,
|
||||
// because a position might be fully closed (no active position) but have PnL
|
||||
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
if len(symbolMap) == 0 {
|
||||
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
|
||||
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +122,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
|
||||
if len(changedSymbols) == 0 {
|
||||
logger.Infof("📭 No symbols with new trades to sync")
|
||||
// DON'T update lastSyncTime to current time here!
|
||||
// Keep using the last actual trade time from DB to avoid creating gaps
|
||||
// The lastSyncTimeMs from DB already has +1000ms buffer added
|
||||
// Update last sync time even if no changes
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -152,12 +158,17 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
|
||||
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
|
||||
|
||||
// Only update last sync time if ALL symbols were successfully queried
|
||||
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||
if len(failedSymbols) == 0 {
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
} else {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
if len(allTrades) == 0 {
|
||||
// No trades returned, but symbols were detected - might be false positive from COMMISSION/PnL detection
|
||||
// Don't update lastSyncTime, keep using DB value
|
||||
if len(failedSymbols) > 0 {
|
||||
logger.Infof(" ⚠️ %d symbols failed: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -171,12 +182,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
skippedCount := 0
|
||||
for _, trade := range allTrades {
|
||||
// Check if trade already exists
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
skippedCount++
|
||||
continue // Trade already exists, skip
|
||||
}
|
||||
|
||||
@@ -271,21 +280,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
trade.Time.UTC().Format("01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Update lastSyncTime to the LATEST trade time (not current time!)
|
||||
// This ensures next sync starts from where we left off, not from "now"
|
||||
// allTrades is already sorted by time ASC, so last element is the latest
|
||||
if len(allTrades) > 0 && len(failedSymbols) == 0 {
|
||||
latestTradeTimeMs := allTrades[len(allTrades)-1].Time.UTC().UnixMilli()
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = latestTradeTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
logger.Infof("📅 Updated lastSyncTime to latest trade: %s (UTC)",
|
||||
time.UnixMilli(latestTradeTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
} else if len(failedSymbols) > 0 {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced, %d skipped (already exist)", syncedCount, skippedCount)
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
||||
import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
@@ -23,38 +23,27 @@ export function CoinSourceEditor({
|
||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||
static: { zh: '静态列表', en: 'Static List' },
|
||||
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
|
||||
oi_top: { zh: 'OI 持仓增加', en: 'OI Increase' },
|
||||
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease' },
|
||||
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
||||
ai500Limit: { zh: '数量上限', en: 'Limit' },
|
||||
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase' },
|
||||
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||
oiTopLimit: { zh: '数量上限', en: 'Limit' },
|
||||
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease' },
|
||||
oiLowLimit: { zh: '数量上限', en: 'Limit' },
|
||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||
ai500Desc: {
|
||||
zh: '使用 AI500 智能筛选的热门币种',
|
||||
en: 'Use AI500 smart-filtered popular coins',
|
||||
},
|
||||
oiTopDesc: {
|
||||
zh: '持仓增加榜,适合做多',
|
||||
en: 'OI increase ranking, for long',
|
||||
},
|
||||
oi_lowDesc: {
|
||||
zh: '持仓减少榜,适合做空',
|
||||
en: 'OI decrease ranking, for short',
|
||||
zh: '使用持仓量增长最快的币种',
|
||||
en: 'Use coins with fastest OI growth',
|
||||
},
|
||||
mixedDesc: {
|
||||
zh: '组合多种数据源',
|
||||
en: 'Combine multiple sources',
|
||||
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
||||
},
|
||||
mixedConfig: { zh: '组合数据源配置', en: 'Combined Sources Configuration' },
|
||||
mixedSummary: { zh: '已选组合', en: 'Selected Sources' },
|
||||
maxCoins: { zh: '最多', en: 'Up to' },
|
||||
coins: { zh: '个币种', en: 'coins' },
|
||||
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
||||
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
||||
@@ -68,35 +57,9 @@ export function CoinSourceEditor({
|
||||
{ value: 'static', icon: List, color: '#848E9C' },
|
||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
// Calculate mixed mode summary
|
||||
const getMixedSummary = () => {
|
||||
const sources: string[] = []
|
||||
let totalLimit = 0
|
||||
|
||||
if (config.use_ai500) {
|
||||
sources.push(`AI500(${config.ai500_limit || 10})`)
|
||||
totalLimit += config.ai500_limit || 10
|
||||
}
|
||||
if (config.use_oi_top) {
|
||||
sources.push(`${language === 'zh' ? 'OI增' : 'OI↑'}(${config.oi_top_limit || 10})`)
|
||||
totalLimit += config.oi_top_limit || 10
|
||||
}
|
||||
if (config.use_oi_low) {
|
||||
sources.push(`${language === 'zh' ? 'OI减' : 'OI↓'}(${config.oi_low_limit || 10})`)
|
||||
totalLimit += config.oi_low_limit || 10
|
||||
}
|
||||
if ((config.static_coins || []).length > 0) {
|
||||
sources.push(`${language === 'zh' ? '自定义' : 'Custom'}(${config.static_coins?.length || 0})`)
|
||||
totalLimit += config.static_coins?.length || 0
|
||||
}
|
||||
|
||||
return { sources, totalLimit }
|
||||
}
|
||||
|
||||
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
|
||||
const xyzDexAssets = new Set([
|
||||
// Stocks
|
||||
@@ -193,7 +156,7 @@ export function CoinSourceEditor({
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{t('sourceType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -219,8 +182,8 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Static Coins - only for static mode */}
|
||||
{config.source_type === 'static' && (
|
||||
{/* Static Coins */}
|
||||
{(config.source_type === 'static' || config.source_type === 'mixed') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{t('staticCoins')}
|
||||
@@ -320,8 +283,8 @@ export function CoinSourceEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI500 Options - only for ai500 mode */}
|
||||
{config.source_type === 'ai500' && (
|
||||
{/* AI500 Options */}
|
||||
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
|
||||
>
|
||||
@@ -377,8 +340,8 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Top Options - only for oi_top mode */}
|
||||
{config.source_type === 'oi_top' && (
|
||||
{/* OI Top Options */}
|
||||
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
|
||||
>
|
||||
@@ -386,7 +349,7 @@ export function CoinSourceEditor({
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
OI {language === 'zh' ? '持仓增加榜' : 'Increase'} {t('dataSourceConfig')}
|
||||
OI Top {t('dataSourceConfig')}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
@@ -412,10 +375,10 @@ export function CoinSourceEditor({
|
||||
{t('oiTopLimit')}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 10}
|
||||
value={config.oi_top_limit || 20}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
@@ -433,306 +396,6 @@ export function CoinSourceEditor({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Low Options - only for oi_low mode */}
|
||||
{config.source_type === 'oi_low' && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-danger/5 border border-nofx-danger/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-nofx-danger" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
OI {language === 'zh' ? '持仓减少榜' : 'Decrease'} {t('dataSourceConfig')}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_low}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_low: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-red-500"
|
||||
/>
|
||||
<span className="text-nofx-text">{t('useOILow')}</span>
|
||||
</label>
|
||||
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{t('oiLowLimit')}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{t('nofxosNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mixed Mode - Unified Card Selector */}
|
||||
{config.source_type === 'mixed' && (
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shuffle className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{t('mixedConfig')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 4 Source Cards in 2x2 Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{/* AI500 Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
config.use_ai500
|
||||
? 'bg-nofx-gold/10 border-nofx-gold/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-gold/30'
|
||||
}`}
|
||||
onClick={() => !disabled && onChange({ ...config, use_ai500: !config.use_ai500 })}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_ai500}
|
||||
onChange={(e) => !disabled && onChange({ ...config, use_ai500: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-nofx-gold"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Database className="w-4 h-4 text-nofx-gold" />
|
||||
<span className="text-sm font-medium text-nofx-text">AI500</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
{config.use_ai500 && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OI Top Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
config.use_oi_top
|
||||
? 'bg-nofx-success/10 border-nofx-success/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-success/30'
|
||||
}`}
|
||||
onClick={() => !disabled && onChange({ ...config, use_oi_top: !config.use_oi_top })}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) => !disabled && onChange({ ...config, use_oi_top: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-nofx-success"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{language === 'zh' ? 'OI 增加' : 'OI Increase'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
|
||||
{language === 'zh' ? '适合做多' : 'For long'}
|
||||
</p>
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OI Low Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
config.use_oi_low
|
||||
? 'bg-nofx-danger/10 border-nofx-danger/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-danger/30'
|
||||
}`}
|
||||
onClick={() => !disabled && onChange({ ...config, use_oi_low: !config.use_oi_low })}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_low}
|
||||
onChange={(e) => !disabled && onChange({ ...config, use_oi_low: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-red-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<TrendingDown className="w-4 h-4 text-nofx-danger" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{language === 'zh' ? 'OI 减少' : 'OI Decrease'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
|
||||
{language === 'zh' ? '适合做空' : 'For short'}
|
||||
</p>
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Static/Custom Card */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
(config.static_coins || []).length > 0
|
||||
? 'bg-gray-500/10 border-gray-500/50'
|
||||
: 'bg-nofx-bg border-nofx-border hover:border-gray-500/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<List className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{language === 'zh' ? '自定义' : 'Custom'}
|
||||
</span>
|
||||
{(config.static_coins || []).length > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">
|
||||
{config.static_coins?.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(config.static_coins || []).slice(0, 3).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-nofx-bg-lighter text-nofx-text"
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveCoin(coin)
|
||||
}}
|
||||
className="hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(config.static_coins || []).length > 3 && (
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
+{(config.static_coins?.length || 0) - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCoin}
|
||||
onChange={(e) => setNewCoin(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') handleAddCoin()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="BTC, ETH..."
|
||||
className="flex-1 px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddCoin()
|
||||
}}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-gold text-black hover:bg-yellow-500"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{(() => {
|
||||
const { sources, totalLimit } = getMixedSummary()
|
||||
if (sources.length === 0) return null
|
||||
return (
|
||||
<div className="p-2 rounded bg-nofx-bg border border-nofx-border">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-nofx-text-muted">{t('mixedSummary')}:</span>
|
||||
<span className="text-nofx-text font-medium">
|
||||
{sources.join(' + ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-nofx-text-muted mt-1">
|
||||
{t('maxCoins')} {totalLimit} {t('coins')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function GridConfigEditor({
|
||||
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)' },
|
||||
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy' },
|
||||
leverage: { zh: '杠杆倍数', en: 'Leverage' },
|
||||
leverageDesc: { zh: '交易使用的杠杆倍数 (1-5)', en: 'Leverage for trading (1-5)' },
|
||||
leverageDesc: { zh: '交易使用的杠杆倍数 (1-20)', en: 'Leverage for trading (1-20)' },
|
||||
|
||||
// Grid parameters
|
||||
gridCount: { zh: '网格数量', en: 'Grid Count' },
|
||||
@@ -171,7 +171,7 @@ export function GridConfigEditor({
|
||||
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={5}
|
||||
max={20}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
@@ -509,15 +509,13 @@ export interface GridStrategyConfig {
|
||||
}
|
||||
|
||||
export interface CoinSourceConfig {
|
||||
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
|
||||
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
|
||||
static_coins?: string[];
|
||||
excluded_coins?: string[]; // 排除的币种列表
|
||||
use_ai500: boolean;
|
||||
ai500_limit?: number;
|
||||
use_oi_top: boolean;
|
||||
oi_top_limit?: number;
|
||||
use_oi_low: boolean;
|
||||
oi_low_limit?: number;
|
||||
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user