23 Commits

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

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

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

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

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

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

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

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

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

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

Trading tools available:
- Query: get_balance, get_positions, list_traders, get_trader_status
- Control: start_trader, stop_trader
- Trading: get_market_price, open_long, open_short, close_position
- Config: list_strategies, list_exchanges, list_ai_models
2026-01-30 03:29:22 +08:00
wqqqqqq
9dbc861cdf feat: add depth websocket from coinank (#1362) 2026-01-27 22:07:38 +08:00
tinkle-community
fcc0267a46 docs: update sponsors list (11 sponsors) 2026-01-23 21:23:38 +08:00
tinkle-community
c9150e8273 feat: add OI Low coin source and improve Mixed mode UI
- Add oi_low as independent source_type for short opportunities
- Redesign Mixed mode with card-based selector (2x2 grid)
- Show combination summary with total coin limit
- Support both Chinese and English languages
- Change default limits to 10 for OI Top and OI Low
2026-01-23 20:50:23 +08:00
tinkle-community
fcaabea6cb feat: add oi_low coin source for short opportunities
- Add GetOILowPositions/GetOILowSymbols in oi.go
- Add UseOILow/OILowLimit config fields
- Add oi_low case in GetCandidateCoins
- Support oi_low in mixed mode
- Update source tag formatting
2026-01-23 20:16:30 +08:00
tinkle-community
b5716ff3cb fix: handle empty AI500 coin list gracefully instead of error 2026-01-23 20:12:11 +08:00
tinkle-community
2f54d1d4c0 docs: update sponsors list (8 sponsors) 2026-01-19 23:23:14 +08:00
tinkle-community
0b448558ca docs: update sponsors list (5 sponsors) 2026-01-19 20:27:41 +08:00
tinkle-community
84276f64ae docs: add sponsor @1733055465 2026-01-19 19:11:55 +08:00
tinkle-community
5560df133e docs: use manual sponsor list instead of workflow 2026-01-19 19:06:54 +08:00
tinkle-community
f43c63699b docs: trigger sponsors update on new sponsorship events 2026-01-19 19:05:12 +08:00
tinkle-community
7b1edaa51f docs: add auto-update sponsors workflow 2026-01-19 19:04:33 +08:00
tinkle-community
ed8ad63288 docs: add sponsors section to README 2026-01-19 18:48:36 +08:00
tinkle-community
a7370efc2f fix(sync): use actual trade time instead of current time for lastSyncTime
- Remove syncStartTimeMs that was causing sync gaps
- Update binanceSyncState to latest trade's timestamp after successful sync
- Don't update lastSyncTime when no trades found (keep using DB value)

Fixes issue where trades between last sync and current time were missed
2026-01-19 17:33:13 +08:00
tinkle-community
5b384d126f fix(sync): add diagnostic logging for debugging sync issues
- Log lastSyncTimeMs and nowMs raw values for timestamp debugging
- Count and log skipped trades (already exist in DB)
- Helps diagnose positions sync stops at 6am issue
2026-01-19 16:25:02 +08:00
tinkle-community
1532b55d77 fix(sync): always query REALIZED_PNL to detect closed positions
Previously Method 4 (REALIZED_PNL) only ran when symbolMap was empty.
This caused fully-closed positions to be missed if other symbols were detected.

Now REALIZED_PNL is always queried to catch positions that:
- Have no active position (fully closed)
- Were missed by COMMISSION detection (VIP users, BNB fee discount)
2026-01-19 15:50:53 +08:00
tinkle-community
0e75b80d95 Revert "fix(sync): handle close trades without matching open position"
This reverts commit 9c57134dfb.
2026-01-19 15:35:17 +08:00
tinkle-community
9c57134dfb fix(sync): handle close trades without matching open position
- Create synthetic CLOSED position when close trade has no matching open position
- This happens when position was opened before sync window (>24h) but closed during sync
- Multiple close trades are merged into same synthetic position
- Added GetSyntheticClosedPosition and UpdateSyntheticPosition functions
- Synthetic positions marked with close_reason='sync_partial' for identification
2026-01-19 15:33:29 +08:00
tinkle-community
7ce7361cef fix(sync): add updated_at to position updates and auto-close when quantity=0
- UpdatePositionQuantityAndPrice: add updated_at timestamp
- ReducePositionQuantity: add updated_at and auto-close position when qty <= 0.0001
- UpdatePositionExchangeInfo: add updated_at timestamp

Fixes position sync issue after int64 timestamp migration where GORM autoUpdateTime
tag no longer works with int64 fields
2026-01-19 15:13:34 +08:00
tinkle-community
7a1643c56c fix: leverage validation bug and limit grid leverage to 1-5
- Fix Go range loop copy issue in validateDecisions (leverage auto-adjust was modifying copy, not original)
- Limit grid leverage from 1-20 to 1-5 for safer grid trading
2026-01-19 13:16:16 +08:00
tinkle-community
7e96c5d0f2 Ai grid (#1344)
* feat: add AI grid trading and market regime classification

- Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook
- Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter)
- Add grid engine with ATR-based boundary calculation and fund distribution
- Add market regime classification documents (Chinese/English)
- Add GridConfigEditor component for frontend configuration

* fix: implement GetOpenOrders for Lighter exchange

* debug: add logging for Lighter GetActiveOrders API call

* fix: correct Lighter API response parsing for GetOpenOrders

- Changed response field from 'data' to 'orders' to match Lighter API
- Updated OrderResponse struct to match Lighter's actual field names
- Fixed field types: price/quantity as strings, is_ask for side

* feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges

- Aster: uses /fapi/v3/openOrders endpoint
- OKX: uses /api/v5/trade/orders-pending and orders-algo-pending
- Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending

* fix: address code review issues for GetOpenOrders

- Add error logging for OKX/Bitget API failures (was silently swallowed)
- Fix Lighter position side logic to handle reduce-only orders
- Change verbose debug logs from Infof to Debugf level

* fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch

Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck

* fix: use auth query parameter instead of Authorization header for Lighter API

* test: add Lighter API authentication tests and diagnostic tools

* fix(grid): add leverage setting before order placement

CRITICAL BUG FIX:
- Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder()
- Set leverage during grid initialization
- Log leverage setting results

* fix(grid): prevent CancelOrder from canceling all orders

CRITICAL BUG FIX:
- CancelOrder no longer calls CancelAllOrders
- Try exchange-specific CancelOrder if available
- Return error if individual cancellation not supported

* fix(grid): add total position value limit check

CRITICAL: Prevent excessive position accumulation
- New checkTotalPositionLimit() function
- Checks current + pending + new order value
- Rejects orders that would exceed TotalInvestment x Leverage
- Logs clear error messages when limit exceeded

* feat(grid): implement stop loss execution

CRITICAL: Add code-level stop loss protection
- New checkAndExecuteStopLoss() function
- Checks each filled level against StopLossPct
- Automatically closes positions exceeding stop loss
- Called during every grid state sync

* feat(grid): add breakout detection and auto-pause

CRITICAL: Detect price breakout from grid range
- New checkBreakout() function to detect upper/lower breakouts
- Auto-pause grid on significant breakout (>2%)
- Cancel all orders when breakout detected
- Prevent continued losses in trending market
- Minor breakouts (1-2%) logged for AI consideration

* feat(grid): enforce max drawdown limit with emergency exit

CRITICAL: Add drawdown protection
- New checkMaxDrawdown() function tracks peak equity
- emergencyExit() closes all positions and cancels orders
- Auto-pause grid when MaxDrawdownPct exceeded
- Protect capital from excessive losses

* feat(grid): enforce daily loss limit

- Add checkDailyLossLimit() function to check if daily loss exceeds limit
- Track daily PnL with auto-reset at midnight
- Pause grid when DailyLossLimitPct exceeded
- Add updateDailyPnL() helper for realized PnL tracking
- Prevent excessive single-day losses

* fix(grid): update daily PnL when stop loss is executed

The updateDailyPnL() function was added but never called, leaving
DailyPnL always at 0 and preventing daily loss limit checks from
triggering.

This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss()
when a stop loss is executed. We update directly rather than calling
updateDailyPnL() because the mutex is already held in that function.

* feat(grid): add automatic grid adjustment

- New checkGridSkew() detects imbalanced grid
- autoAdjustGrid() reinitializes around current price
- Prevents grid from becoming ineffective after drift
- Triggers when one side is 3x more filled than other

* fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels

Critical fix for grid auto-adjustment:
- Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered
  on current price before reinitializing grid levels
- Preserve filled positions during adjustment by saving and restoring
  them to the closest new level after reinitialization
- Hold mutex lock for the entire adjustment operation to ensure atomicity
- Add locked variants of calculateDefaultBounds, calculateATRBounds, and
  initializeGridLevels to use during adjustment

Without this fix, autoAdjustGrid was using old boundaries when creating
new grid levels, defeating the purpose of auto-adjustment when price
moved significantly.

* fix(grid): improve order state sync logic

- Don't assume missing orders are filled
- Compare position size to determine fill vs cancel
- Properly reset cancelled orders to empty state
- More accurate grid state tracking

* fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic

The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity`
which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution
(gaussian, pyramid, uniform) where orders have different quantities, this could lead to
incorrect fill detection.

Now sums the actual PositionSize from filled levels for accurate comparison.
Also adds warning log when GetPositions() fails.

* docs: add grid market regime detection design

Design for enhanced market state recognition with:
- Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI)
- Multi-period box indicators (72/240/500 1h candles)
- 4-level ranging classification
- Breakout detection and handling
- Frontend risk control panel

* docs: add grid market regime implementation plan

20 tasks covering:
- Donchian channel calculation
- Box data types and API
- Regime classification (4 levels)
- Breakout detection and handling
- False breakout recovery
- Frontend risk panel
- AI prompt updates

* feat(market): add Donchian channel calculation

Add calculateDonchian function to compute highest high and lowest low
over a specified period. This is the foundation for box (range) detection
in the multi-period box indicator system for grid trading.

* fix(market): handle invalid period in calculateDonchian

* feat(market): add BoxData and RegimeLevel types

* feat(market): add GetBoxData for multi-period box calculation

Adds calculateBoxData internal function and GetBoxData public API that
fetches 1h klines and computes three Donchian box levels (short/mid/long).
This will be used by the grid trading system to detect market regime.

* feat(store): add box and regime fields to grid models

* feat(trader): add regime classification and breakout detection

Implements Tasks 6-9 for grid market regime awareness:
- Task 6: classifyRegimeLevel with Bollinger/ATR thresholds
- Task 7: detectBoxBreakout for multi-period box breakouts
- Task 8: confirmBreakout with 3-candle confirmation logic
- Task 9: getBreakoutAction mapping breakout levels to actions

* feat(trader): integrate box breakout detection into grid cycle

- Task 10: Add checkBoxBreakout with 3-candle confirmation
- Task 11: Add checkFalseBreakoutRecovery for 50% position recovery
- Task 12: Add box/breakout/regime fields to GridState

* feat: add grid risk panel with API endpoint

- Task 13: Add GridRiskInfo type to frontend
- Task 14: Add /traders/:id/grid-risk API endpoint
- Task 15: Add GetGridRiskInfo method to AutoTrader
- Task 16: Create GridRiskPanel component with i18n

* feat(kernel): add box indicators to AI prompt

- Add BoxData field to GridContext
- Add box indicator table to both zh/en prompts
- Show breakout/warning alerts based on price position

* feat(web): integrate GridRiskPanel into TraderDashboardPage

* feat(lighter): improve API key validation and market caching

- Add API key validation status tracking
- Add market list caching to reduce API calls
- Improve logging (debug vs info levels)
- Add comprehensive integration tests
- Update trader manager and store for lighter support

* fix: remove hardcoded test wallet address

* fix(grid): improve GridRiskPanel layout and fix liquidation data

- Make panel collapsible with summary badges when collapsed
- Use compact 2-column grid layout for detailed info
- Fix auth token key (token -> auth_token)
- Only calculate liquidation distance when position exists

* fix(grid): add isRunning checks to prevent trades after Stop() is called
2026-01-19 12:07:14 +08:00
32 changed files with 5194 additions and 100 deletions

View File

@@ -49,12 +49,51 @@ RSA_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END RSA PRI
TRANSPORT_ENCRYPTION=false
# ===========================================
# Optional: External Services
# Telegram AI Assistant (NEW - moltbot-nofx)
# ===========================================
# Telegram notifications (optional)
# TELEGRAM_BOT_TOKEN=your-bot-token
# TELEGRAM_CHAT_ID=your-chat-id
# 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=
DB_TYPE=postgres
DB_HOST=10.

1
.gitignore vendored
View File

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

View File

@@ -488,6 +488,26 @@ 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
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

350
assistant/agent.go Normal file
View File

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

312
assistant/context.go Normal file
View File

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

200
assistant/monitor.go Normal file
View File

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

117
assistant/prompts.go Normal file
View File

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

122
assistant/session.go Normal file
View File

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

216
assistant/smart_agent.go Normal file
View File

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

115
assistant/smart_prompts.go Normal file
View File

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

View File

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

548
assistant/strategy_tools.go Normal file
View File

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

47
assistant/tool.go Normal file
View File

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

530
assistant/trading_tools.go Normal file
View File

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

18
go.mod
View File

@@ -5,19 +5,25 @@ 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
)
@@ -27,7 +33,6 @@ 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
@@ -39,7 +44,6 @@ 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
@@ -50,6 +54,7 @@ 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
@@ -62,7 +67,6 @@ 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
@@ -93,16 +97,12 @@ 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

850
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -447,6 +447,7 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "oi_top":
@@ -466,6 +467,27 @@ 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":
@@ -491,6 +513,17 @@ 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 {
@@ -561,7 +594,7 @@ func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 20
limit = 10
}
positions, err := e.nofxosClient.GetOITopPositions()
@@ -583,6 +616,30 @@ 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
// ============================================================================
@@ -1289,13 +1346,38 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 {
return " (AI500+OI_Top dual signal)"
// 多信号源组合
hasAI500 := false
hasOITop := false
hasOILow := false
for _, s := range sources {
switch s {
case "ai500":
hasAI500 = true
case "oi_top":
hasOITop = true
case "oi_low":
hasOILow = true
}
}
if hasAI500 && hasOITop {
return " (AI500+OI_Top dual signal)"
}
if hasAI500 && hasOILow {
return " (AI500+OI_Low dual signal)"
}
if hasOITop && hasOILow {
return " (OI_Top+OI_Low)"
}
return " (Multiple sources)"
} else if len(sources) == 1 {
switch sources[0] {
case "ai500":
return " (AI500)"
case "oi_top":
return " (OI_Top position growth)"
return " (OI_Top 持仓增加)"
case "oi_low":
return " (OI_Low 持仓减少)"
case "static":
return " (Manual selection)"
}
@@ -1767,8 +1849,8 @@ func compactArrayOpen(s string) string {
// ============================================================================
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
for i, decision := range decisions {
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
for i := range decisions {
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
}
}

102
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"nofx/api"
"nofx/assistant"
"nofx/auth"
"nofx/backtest"
"nofx/config"
@@ -11,6 +12,7 @@ import (
"nofx/manager"
"nofx/mcp"
"nofx/store"
"nofx/telegram"
"os"
"os/signal"
"path/filepath"
@@ -136,6 +138,52 @@ func main() {
}
}()
// Initialize and start Telegram bot (if configured)
var telegramBot *telegram.Bot
telegramConfig := telegram.LoadConfigFromEnv()
if telegramConfig.Token != "" {
logger.Info("🤖 Initializing Smart Trading Assistant...")
// Create AI client for the assistant
aiClient := createAssistantAIClient()
if aiClient == nil {
logger.Error("❌ No AI API key configured, Telegram bot disabled")
} else {
// Create Smart AI Agent with trading context awareness
agentConfig := assistant.DefaultAgentConfig()
smartAgent := assistant.NewSmartAgent(aiClient, agentConfig, traderManager, st)
// Register trading tools
tradingTools := assistant.NewTradingTools(traderManager, st)
smartAgent.RegisterTools(tradingTools.GetAllTools()...)
// Register strategy tools
strategyTools := assistant.NewStrategyTools(st)
smartAgent.RegisterTools(strategyTools.GetAllTools()...)
// Create and start Telegram bot
var err error
telegramBot, err = telegram.NewBot(telegramConfig, smartAgent.Agent)
if err != nil {
logger.Errorf("❌ Failed to create Telegram bot: %v", err)
} else {
// Start background monitor with alert forwarding to Telegram
smartAgent.OnAlert(func(alert assistant.Alert) {
telegramBot.BroadcastAlert(alert.Message)
})
smartAgent.StartMonitor()
go telegramBot.Start()
logger.Info("✅ Smart Trading Assistant started successfully")
logger.Info(" 📊 Real-time context injection: enabled")
logger.Info(" 🔍 Background monitoring: enabled")
logger.Info(" ⚠️ Proactive alerts: enabled")
}
}
} else {
logger.Info(" Telegram bot not configured (set TELEGRAM_BOT_TOKEN to enable)")
}
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -146,6 +194,11 @@ 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")
@@ -161,6 +214,55 @@ 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) {

View File

@@ -0,0 +1,108 @@
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"`
}

View File

@@ -0,0 +1,42 @@
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")
}

View File

@@ -73,8 +73,10 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
return nil, fmt.Errorf("API returned failure status")
}
// 空列表是正常情况,不是错误
if len(response.Data.Coins) == 0 {
return nil, fmt.Errorf("coin list is empty")
log.Printf(" AI500 returned empty coin list (no coins meet criteria currently)")
return []CoinData{}, nil
}
// Set IsAvailable flag

View File

@@ -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) {
data, err := c.GetOIRanking("1h", 20)
positions, _, err := c.fetchOIRanking("top", "1h", 20)
if err != nil {
return nil, err
}
return data.TopPositions, nil
return positions, nil
}
// GetOITopSymbols retrieves OI top coin symbol list
@@ -129,6 +129,31 @@ 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 {

View File

@@ -158,16 +158,19 @@ 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 {
@@ -187,19 +190,40 @@ 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
}

View File

@@ -97,7 +97,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration
type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "mixed"
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
SourceType string `json:"source_type"`
// static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"`
@@ -107,10 +107,14 @@ 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
}
@@ -248,7 +252,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
UseAI500: true,
AI500Limit: 10,
UseOITop: false,
OITopLimit: 20,
OITopLimit: 10,
UseOILow: false,
OILowLimit: 10,
},
Indicators: IndicatorConfig{
Klines: KlineConfig{

490
telegram/bot.go Normal file
View File

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

60
telegram/config.go Normal file
View File

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

View File

@@ -534,6 +534,12 @@ 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)

View File

@@ -56,12 +56,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
}
}
// 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"))
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)
// Step 1: Get max trade IDs from local DB for incremental sync
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
@@ -100,18 +96,17 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
symbolMap[s] = true
}
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
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
}
// 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
}
}
@@ -122,10 +117,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
if len(changedSymbols) == 0 {
logger.Infof("📭 No symbols with new trades to sync")
// Update last sync time even if no changes
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock()
// 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
return nil
}
@@ -158,17 +152,12 @@ 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
}
@@ -182,10 +171,12 @@ 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
}
@@ -280,7 +271,21 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
trade.Time.UTC().Format("01-02 15:04:05"))
}
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
// 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)
return nil
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
interface CoinSourceEditorProps {
@@ -23,27 +23,38 @@ export function CoinSourceEditor({
sourceType: { zh: '数据来源类型', en: 'Source Type' },
static: { zh: '静态列表', en: 'Static List' },
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
oi_top: { zh: 'OI Top 持仓增', en: 'OI Top' },
oi_top: { zh: 'OI 持仓增', en: 'OI Increase' },
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease' },
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 Top 数据', en: 'Enable OI Top' },
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase' },
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: 'Use coins with fastest OI growth',
zh: '持仓增加榜,适合做多',
en: 'OI increase ranking, for long',
},
oi_lowDesc: {
zh: '持仓减少榜,适合做空',
en: 'OI decrease ranking, for short',
},
mixedDesc: {
zh: '组合多种数据源AI500 + OI Top + 自定义',
en: 'Combine multiple sources: AI500 + OI Top + Custom',
zh: '组合多种数据源',
en: 'Combine multiple sources',
},
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' },
@@ -57,9 +68,35 @@ export function CoinSourceEditor({
{ value: 'static', icon: List, color: '#848E9C' },
{ value: 'ai500', icon: Database, color: '#F0B90B' },
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
{ value: 'mixed', icon: Database, color: '#60a5fa' },
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
{ value: 'mixed', icon: Shuffle, 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
@@ -156,7 +193,7 @@ export function CoinSourceEditor({
<label className="block text-sm font-medium mb-3 text-nofx-text">
{t('sourceType')}
</label>
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-5 gap-2">
{sourceTypes.map(({ value, icon: Icon, color }) => (
<button
key={value}
@@ -182,8 +219,8 @@ export function CoinSourceEditor({
</div>
</div>
{/* Static Coins */}
{(config.source_type === 'static' || config.source_type === 'mixed') && (
{/* Static Coins - only for static mode */}
{config.source_type === 'static' && (
<div>
<label className="block text-sm font-medium mb-3 text-nofx-text">
{t('staticCoins')}
@@ -283,8 +320,8 @@ export function CoinSourceEditor({
)}
</div>
{/* AI500 Options */}
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
{/* AI500 Options - only for ai500 mode */}
{config.source_type === 'ai500' && (
<div
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
>
@@ -340,8 +377,8 @@ export function CoinSourceEditor({
</div>
)}
{/* OI Top Options */}
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
{/* OI Top Options - only for oi_top mode */}
{config.source_type === 'oi_top' && (
<div
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
>
@@ -349,7 +386,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 Top {t('dataSourceConfig')}
OI {language === 'zh' ? '持仓增加榜' : 'Increase'} {t('dataSourceConfig')}
</span>
<NofxOSBadge />
</div>
@@ -375,10 +412,10 @@ export function CoinSourceEditor({
{t('oiTopLimit')}:
</span>
<select
value={config.oi_top_limit || 20}
value={config.oi_top_limit || 10}
onChange={(e) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
onChange({ ...config, oi_top_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"
@@ -396,6 +433,306 @@ 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>
)
}

View File

@@ -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-20)', en: 'Leverage for trading (1-20)' },
leverageDesc: { zh: '交易使用的杠杆倍数 (1-5)', en: 'Leverage for trading (1-5)' },
// 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={20}
max={5}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>

View File

@@ -509,13 +509,15 @@ export interface GridStrategyConfig {
}
export interface CoinSourceConfig {
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | '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
}