mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 09:11:03 +08:00
Refactor/trading actions (#1169)
* refactor: 简化交易动作,移除 update_stop_loss/update_take_profit/partial_close - 移除 Decision 结构体中的 NewStopLoss, NewTakeProfit, ClosePercentage 字段 - 删除 executeUpdateStopLossWithRecord, executeUpdateTakeProfitWithRecord, executePartialCloseWithRecord 函数 - 简化 logger 中的 partial_close 聚合逻辑 - 更新 AI prompt 和验证逻辑,只保留 6 个核心动作 - 清理相关测试代码 保留的交易动作: open_long, open_short, close_long, close_short, hold, wait * refactor: 移除 AI学习与反思 模块 - 删除前端 AILearning.tsx 组件和相关引用 - 删除后端 /performance API 接口 - 删除 logger 中 AnalyzePerformance、calculateSharpeRatio 等函数 - 删除 PerformanceAnalysis、TradeOutcome、SymbolPerformance 等结构体 - 删除 Context 中的 Performance 字段 - 移除 AI prompt 中夏普比率自我进化相关内容 - 清理 i18n 翻译文件中的相关条目 该模块基于磁盘存储计算,经常出错,做减法移除 * refactor: 将数据库操作统一迁移到 store 包 - 新增 store/ 包,统一管理所有数据库操作 - store.go: 主 Store 结构,懒加载各子模块 - user.go, ai_model.go, exchange.go, trader.go 等子模块 - 支持加密/解密函数注入 (SetCryptoFuncs) - 更新 main.go 使用 store.New() 替代 config.NewDatabase() - 更新 api/server.go 使用 *store.Store 替代 *config.Database - 更新 manager/trader_manager.go: - 新增 LoadTradersFromStore, LoadUserTradersFromStore 方法 - 删除旧版 LoadUserTraders, LoadTraderByID, loadSingleTrader 等方法 - 移除 nofx/config 依赖 - 删除 config/database.go 和 config/database_test.go - 更新 api/server_test.go 使用 store.Trader 类型 - 清理 logger/ 包中未使用的 telegram 相关代码 * refactor: unify encryption key management via .env - Remove redundant EncryptionManager and SecureStorage - Simplify CryptoService to load keys from environment variables only - RSA_PRIVATE_KEY: RSA private key for client-server encryption - DATA_ENCRYPTION_KEY: AES-256 key for database encryption - JWT_SECRET: JWT signing key for authentication - Update start.sh to auto-generate missing keys on first run - Remove secrets/ directory and file-based key storage - Delete obsolete encryption setup scripts - Update .env.example with all required keys * refactor: unify logger usage across mcp package - Add MCPLogger adapter in logger package to implement mcp.Logger interface - Update mcp/config.go to use global logger by default - Remove redundant defaultLogger from mcp/logger.go - Keep noopLogger for testing purposes * chore: remove leftover test RSA key file * chore: remove unused bootstrap package * refactor: unify logging to use logger package instead of fmt/log - Replace all fmt.Print/log.Print calls with logger package - Add auto-initialization in logger package init() for test compatibility - Update main.go to initialize logger at startup - Migrate all packages: api, backtest, config, decision, manager, market, store, trader * refactor: rename database file from config.db to data.db - Update main.go, start.sh, docker-compose.yml - Update migration script and documentation - Update .gitignore and translations * fix: add RSA_PRIVATE_KEY to docker-compose environment * fix: add registration_enabled to /api/config response * fix: Fix navigation between login and register pages Use window.location.href instead of react-router's navigate() to fix the issue where URL changes but the page doesn't reload due to App.tsx using custom route state management. * fix: Switch SQLite from WAL to DELETE mode for Docker compatibility WAL mode causes data sync issues with Docker bind mounts on macOS due to incompatible file locking mechanisms between the container and host. DELETE mode (traditional journaling) ensures data is written directly to the main database file. * refactor: Remove default user from database initialization The default user was a legacy placeholder that is no longer needed now that proper user registration is in place. * feat: Add order tracking system with centralized status sync - Add trader_orders table for tracking all order lifecycle - Implement GetOrderStatus interface for all exchanges (Binance, Bybit, Hyperliquid, Aster, Lighter) - Create OrderSyncManager for centralized order status polling - Add trading statistics (Sharpe ratio, win rate, profit factor) to AI context - Include recent completed orders in AI decision input - Remove per-order goroutine polling in favor of global sync manager * feat: Add TradingView K-line chart to dashboard - Create TradingViewChart component with exchange/symbol selectors - Support Binance, Bybit, OKX, Coinbase, Kraken, KuCoin exchanges - Add popular symbols quick selection - Support multiple timeframes (1m to 1W) - Add fullscreen mode - Integrate with Dashboard page below equity chart - Add i18n translations for zh/en * refactor: Replace separate charts with tabbed ChartTabs component - Create ChartTabs component with tab switching between equity curve and K-line - Add embedded mode support for EquityChart and TradingViewChart - User can now switch between account equity and market chart in same area * fix: Use ChartTabs in App.tsx and fix embedded mode in EquityChart - Replace EquityChart with ChartTabs in App.tsx (the actual dashboard renderer) - Fix EquityChart embedded mode for error and empty data states - Rename interval state to timeInterval to avoid shadowing window.setInterval - Add debug logging to ChartTabs component * feat: Add position tracking system for accurate trade history - Add trader_positions table to track complete open/close trades - Add PositionSyncManager to detect manual closes via polling - Record position on open, update on close with PnL calculation - Use positions table for trading stats and recent trades (replacing orders table) - Fix TradingView chart symbol format (add .P suffix for futures) - Fix DecisionCard wait/hold action color (gray instead of red) - Auto-append USDT suffix for custom symbol input * update ---------
This commit is contained in:
294
store/ai_model.go
Normal file
294
store/ai_model.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIModelStore AI模型存储
|
||||
type AIModelStore struct {
|
||||
db *sql.DB
|
||||
encryptFunc func(string) string
|
||||
decryptFunc func(string) string
|
||||
}
|
||||
|
||||
// AIModel AI模型配置
|
||||
type AIModel struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey"`
|
||||
CustomAPIURL string `json:"customApiUrl"`
|
||||
CustomModelName string `json:"customModelName"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *AIModelStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_models (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT 0,
|
||||
api_key TEXT DEFAULT '',
|
||||
custom_api_url TEXT DEFAULT '',
|
||||
custom_model_name TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发器
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at
|
||||
AFTER UPDATE ON ai_models
|
||||
BEGIN
|
||||
UPDATE ai_models SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 向后兼容:添加可能缺失的列
|
||||
s.db.Exec(`ALTER TABLE ai_models ADD COLUMN custom_api_url TEXT DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE ai_models ADD COLUMN custom_model_name TEXT DEFAULT ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AIModelStore) initDefaultData() error {
|
||||
models := []struct {
|
||||
id, name, provider string
|
||||
}{
|
||||
{"deepseek", "DeepSeek", "deepseek"},
|
||||
{"qwen", "Qwen", "qwen"},
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled)
|
||||
VALUES (?, 'default', ?, ?, 0)
|
||||
`, model.id, model.name, model.provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化AI模型失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AIModelStore) encrypt(plaintext string) string {
|
||||
if s.encryptFunc != nil {
|
||||
return s.encryptFunc(plaintext)
|
||||
}
|
||||
return plaintext
|
||||
}
|
||||
|
||||
func (s *AIModelStore) decrypt(encrypted string) string {
|
||||
if s.decryptFunc != nil {
|
||||
return s.decryptFunc(encrypted)
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// List 获取用户的AI模型列表
|
||||
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, provider, enabled, api_key,
|
||||
COALESCE(custom_api_url, '') as custom_api_url,
|
||||
COALESCE(custom_model_name, '') as custom_model_name,
|
||||
created_at, updated_at
|
||||
FROM ai_models WHERE user_id = ? ORDER BY id
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
models := make([]*AIModel, 0)
|
||||
for rows.Next() {
|
||||
var model AIModel
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&model.ID, &model.UserID, &model.Name, &model.Provider,
|
||||
&model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
model.APIKey = s.decrypt(model.APIKey)
|
||||
models = append(models, &model)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// Get 获取单个AI模型
|
||||
func (s *AIModelStore) Get(userID, modelID string) (*AIModel, error) {
|
||||
if modelID == "" {
|
||||
return nil, fmt.Errorf("模型ID不能为空")
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if userID != "" {
|
||||
candidates = append(candidates, userID)
|
||||
}
|
||||
if userID != "default" {
|
||||
candidates = append(candidates, "default")
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
candidates = append(candidates, "default")
|
||||
}
|
||||
|
||||
for _, uid := range candidates {
|
||||
var model AIModel
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, provider, enabled, api_key,
|
||||
COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at
|
||||
FROM ai_models WHERE user_id = ? AND id = ? LIMIT 1
|
||||
`, uid, modelID).Scan(
|
||||
&model.ID, &model.UserID, &model.Name, &model.Provider,
|
||||
&model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
model.APIKey = s.decrypt(model.APIKey)
|
||||
return &model, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// GetDefault 获取默认启用的AI模型
|
||||
func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
model, err := s.firstEnabled(userID)
|
||||
if err == nil {
|
||||
return model, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if userID != "default" {
|
||||
return s.firstEnabled("default")
|
||||
}
|
||||
return nil, fmt.Errorf("请先在系统中配置可用的AI模型")
|
||||
}
|
||||
|
||||
func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
var model AIModel
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, provider, enabled, api_key,
|
||||
COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at
|
||||
FROM ai_models WHERE user_id = ? AND enabled = 1
|
||||
ORDER BY datetime(updated_at) DESC, id ASC LIMIT 1
|
||||
`, userID).Scan(
|
||||
&model.ID, &model.UserID, &model.Name, &model.Provider,
|
||||
&model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
model.APIKey = s.decrypt(model.APIKey)
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Update 更新AI模型,不存在则创建
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
// 先尝试精确匹配ID
|
||||
var existingID string
|
||||
err := s.db.QueryRow(`SELECT id FROM ai_models WHERE user_id = ? AND id = ? LIMIT 1`, userID, id).Scan(&existingID)
|
||||
if err == nil {
|
||||
encryptedAPIKey := s.encrypt(apiKey)
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE ai_models SET enabled = ?, api_key = ?, custom_api_url = ?, custom_model_name = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, enabled, encryptedAPIKey, customAPIURL, customModelName, existingID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// 尝试兼容旧逻辑:将id作为provider查找
|
||||
provider := id
|
||||
err = s.db.QueryRow(`SELECT id FROM ai_models WHERE user_id = ? AND provider = ? LIMIT 1`, userID, provider).Scan(&existingID)
|
||||
if err == nil {
|
||||
logger.Warnf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID)
|
||||
encryptedAPIKey := s.encrypt(apiKey)
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE ai_models SET enabled = ?, api_key = ?, custom_api_url = ?, custom_model_name = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, enabled, encryptedAPIKey, customAPIURL, customModelName, existingID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建新记录
|
||||
if provider == id && (provider == "deepseek" || provider == "qwen") {
|
||||
provider = id
|
||||
} else {
|
||||
parts := strings.Split(id, "_")
|
||||
if len(parts) >= 2 {
|
||||
provider = parts[len(parts)-1]
|
||||
} else {
|
||||
provider = id
|
||||
}
|
||||
}
|
||||
|
||||
var name string
|
||||
err = s.db.QueryRow(`SELECT name FROM ai_models WHERE provider = ? LIMIT 1`, provider).Scan(&name)
|
||||
if err != nil {
|
||||
if provider == "deepseek" {
|
||||
name = "DeepSeek AI"
|
||||
} else if provider == "qwen" {
|
||||
name = "Qwen AI"
|
||||
} else {
|
||||
name = provider + " AI"
|
||||
}
|
||||
}
|
||||
|
||||
newModelID := id
|
||||
if id == provider {
|
||||
newModelID = fmt.Sprintf("%s_%s", userID, provider)
|
||||
}
|
||||
|
||||
logger.Infof("✓ 创建新的 AI 模型配置: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
|
||||
encryptedAPIKey := s.encrypt(apiKey)
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`, newModelID, userID, name, provider, enabled, encryptedAPIKey, customAPIURL, customModelName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create 创建AI模型
|
||||
func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, userID, name, provider, enabled, apiKey, customAPIURL)
|
||||
return err
|
||||
}
|
||||
583
store/backtest.go
Normal file
583
store/backtest.go
Normal file
@@ -0,0 +1,583 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BacktestStore 回测数据存储
|
||||
type BacktestStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// RunState 回测状态
|
||||
type RunState string
|
||||
|
||||
const (
|
||||
RunStateCreated RunState = "created"
|
||||
RunStateRunning RunState = "running"
|
||||
RunStatePaused RunState = "paused"
|
||||
RunStateCompleted RunState = "completed"
|
||||
RunStateFailed RunState = "failed"
|
||||
)
|
||||
|
||||
// RunMetadata 回测元数据
|
||||
type RunMetadata struct {
|
||||
RunID string `json:"run_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Version int `json:"version"`
|
||||
State RunState `json:"state"`
|
||||
Label string `json:"label"`
|
||||
LastError string `json:"last_error"`
|
||||
Summary RunSummary `json:"summary"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RunSummary 回测摘要
|
||||
type RunSummary struct {
|
||||
SymbolCount int `json:"symbol_count"`
|
||||
DecisionTF string `json:"decision_tf"`
|
||||
ProcessedBars int `json:"processed_bars"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
EquityLast float64 `json:"equity_last"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
LiquidationNote string `json:"liquidation_note"`
|
||||
}
|
||||
|
||||
// EquityPoint 权益点
|
||||
type EquityPoint struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Equity float64 `json:"equity"`
|
||||
Available float64 `json:"available"`
|
||||
PnL float64 `json:"pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
DrawdownPct float64 `json:"drawdown_pct"`
|
||||
Cycle int `json:"cycle"`
|
||||
}
|
||||
|
||||
// TradeEvent 交易事件
|
||||
type TradeEvent struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"`
|
||||
Side string `json:"side"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Fee float64 `json:"fee"`
|
||||
Slippage float64 `json:"slippage"`
|
||||
OrderValue float64 `json:"order_value"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Leverage int `json:"leverage"`
|
||||
Cycle int `json:"cycle"`
|
||||
PositionAfter float64 `json:"position_after"`
|
||||
LiquidationFlag bool `json:"liquidation_flag"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// RunIndexEntry 回测索引条目
|
||||
type RunIndexEntry struct {
|
||||
RunID string `json:"run_id"`
|
||||
State string `json:"state"`
|
||||
Symbols []string `json:"symbols"`
|
||||
DecisionTF string `json:"decision_tf"`
|
||||
EquityLast float64 `json:"equity_last"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
StartTS int64 `json:"start_ts"`
|
||||
EndTS int64 `json:"end_ts"`
|
||||
CreatedAtISO string `json:"created_at"`
|
||||
UpdatedAtISO string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// initTables 初始化回测相关表
|
||||
func (s *BacktestStore) initTables() error {
|
||||
queries := []string{
|
||||
// 回测运行主表
|
||||
`CREATE TABLE IF NOT EXISTS backtest_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
config_json TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT 'created',
|
||||
label TEXT DEFAULT '',
|
||||
symbol_count INTEGER DEFAULT 0,
|
||||
decision_tf TEXT DEFAULT '',
|
||||
processed_bars INTEGER DEFAULT 0,
|
||||
progress_pct REAL DEFAULT 0,
|
||||
equity_last REAL DEFAULT 0,
|
||||
max_drawdown_pct REAL DEFAULT 0,
|
||||
liquidated BOOLEAN DEFAULT 0,
|
||||
liquidation_note TEXT DEFAULT '',
|
||||
prompt_template TEXT DEFAULT '',
|
||||
custom_prompt TEXT DEFAULT '',
|
||||
override_prompt BOOLEAN DEFAULT 0,
|
||||
ai_provider TEXT DEFAULT '',
|
||||
ai_model TEXT DEFAULT '',
|
||||
last_error TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// 回测检查点
|
||||
`CREATE TABLE IF NOT EXISTS backtest_checkpoints (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
payload BLOB NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 回测权益曲线
|
||||
`CREATE TABLE IF NOT EXISTS backtest_equity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
equity REAL NOT NULL,
|
||||
available REAL NOT NULL,
|
||||
pnl REAL NOT NULL,
|
||||
pnl_pct REAL NOT NULL,
|
||||
dd_pct REAL NOT NULL,
|
||||
cycle INTEGER NOT NULL,
|
||||
FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 回测交易记录
|
||||
`CREATE TABLE IF NOT EXISTS backtest_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
side TEXT DEFAULT '',
|
||||
qty REAL DEFAULT 0,
|
||||
price REAL DEFAULT 0,
|
||||
fee REAL DEFAULT 0,
|
||||
slippage REAL DEFAULT 0,
|
||||
order_value REAL DEFAULT 0,
|
||||
realized_pnl REAL DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 0,
|
||||
cycle INTEGER DEFAULT 0,
|
||||
position_after REAL DEFAULT 0,
|
||||
liquidation BOOLEAN DEFAULT 0,
|
||||
note TEXT DEFAULT '',
|
||||
FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 回测指标
|
||||
`CREATE TABLE IF NOT EXISTS backtest_metrics (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
payload BLOB NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 回测决策日志
|
||||
`CREATE TABLE IF NOT EXISTS backtest_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
cycle INTEGER NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 索引
|
||||
`CREATE INDEX IF NOT EXISTS idx_backtest_runs_state ON backtest_runs(state, updated_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := s.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("执行SQL失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加可能缺失的列(向后兼容)
|
||||
s.addColumnIfNotExists("backtest_runs", "label", "TEXT DEFAULT ''")
|
||||
s.addColumnIfNotExists("backtest_runs", "last_error", "TEXT DEFAULT ''")
|
||||
s.addColumnIfNotExists("backtest_trades", "leverage", "INTEGER DEFAULT 0")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BacktestStore) addColumnIfNotExists(table, column, definition string) {
|
||||
rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt interface{}
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
continue
|
||||
}
|
||||
if name == column {
|
||||
return // 列已存在
|
||||
}
|
||||
}
|
||||
|
||||
s.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
|
||||
}
|
||||
|
||||
// SaveCheckpoint 保存检查点
|
||||
func (s *BacktestStore) SaveCheckpoint(runID string, payload []byte) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadCheckpoint 加载检查点
|
||||
func (s *BacktestStore) LoadCheckpoint(runID string) ([]byte, error) {
|
||||
var payload []byte
|
||||
err := s.db.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload)
|
||||
return payload, err
|
||||
}
|
||||
|
||||
// SaveRunMetadata 保存运行元数据
|
||||
func (s *BacktestStore) SaveRunMetadata(meta *RunMetadata) error {
|
||||
created := meta.CreatedAt.UTC().Format(time.RFC3339)
|
||||
updated := meta.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
userID := meta.UserID
|
||||
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?,
|
||||
progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?,
|
||||
liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF,
|
||||
meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast,
|
||||
meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote,
|
||||
meta.Label, meta.LastError, updated, meta.RunID)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadRunMetadata 加载运行元数据
|
||||
func (s *BacktestStore) LoadRunMetadata(runID string) (*RunMetadata, error) {
|
||||
var (
|
||||
userID string
|
||||
state string
|
||||
label string
|
||||
lastErr string
|
||||
symbolCount int
|
||||
decisionTF string
|
||||
processedBars int
|
||||
progressPct float64
|
||||
equityLast float64
|
||||
maxDD float64
|
||||
liquidated bool
|
||||
liquidationNote string
|
||||
createdISO string
|
||||
updatedISO string
|
||||
)
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars,
|
||||
progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note,
|
||||
created_at, updated_at
|
||||
FROM backtest_runs WHERE run_id = ?
|
||||
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF,
|
||||
&processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote,
|
||||
&createdISO, &updatedISO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meta := &RunMetadata{
|
||||
RunID: runID,
|
||||
UserID: userID,
|
||||
Version: 1,
|
||||
State: RunState(state),
|
||||
Label: label,
|
||||
LastError: lastErr,
|
||||
Summary: RunSummary{
|
||||
SymbolCount: symbolCount,
|
||||
DecisionTF: decisionTF,
|
||||
ProcessedBars: processedBars,
|
||||
ProgressPct: progressPct,
|
||||
EquityLast: equityLast,
|
||||
MaxDrawdownPct: maxDD,
|
||||
Liquidated: liquidated,
|
||||
LiquidationNote: liquidationNote,
|
||||
},
|
||||
}
|
||||
|
||||
meta.CreatedAt, _ = time.Parse(time.RFC3339, createdISO)
|
||||
meta.UpdatedAt, _ = time.Parse(time.RFC3339, updatedISO)
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ListRunIDs 列出所有运行ID
|
||||
func (s *BacktestStore) ListRunIDs() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT run_id FROM backtest_runs ORDER BY datetime(updated_at) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var runID string
|
||||
if err := rows.Scan(&runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, runID)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// AppendEquityPoint 添加权益点
|
||||
func (s *BacktestStore) AppendEquityPoint(runID string, point EquityPoint) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL,
|
||||
point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadEquityPoints 加载权益点
|
||||
func (s *BacktestStore) LoadEquityPoints(runID string) ([]EquityPoint, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
points := make([]EquityPoint, 0)
|
||||
for rows.Next() {
|
||||
var point EquityPoint
|
||||
if err := rows.Scan(&point.Timestamp, &point.Equity, &point.Available,
|
||||
&point.PnL, &point.PnLPct, &point.DrawdownPct, &point.Cycle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
return points, rows.Err()
|
||||
}
|
||||
|
||||
// AppendTradeEvent 添加交易事件
|
||||
func (s *BacktestStore) AppendTradeEvent(runID string, event TradeEvent) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee,
|
||||
slippage, order_value, realized_pnl, leverage, cycle,
|
||||
position_after, liquidation, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity,
|
||||
event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL,
|
||||
event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadTradeEvents 加载交易事件
|
||||
func (s *BacktestStore) LoadTradeEvents(runID string) ([]TradeEvent, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value,
|
||||
realized_pnl, leverage, cycle, position_after, liquidation, note
|
||||
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]TradeEvent, 0)
|
||||
for rows.Next() {
|
||||
var event TradeEvent
|
||||
if err := rows.Scan(&event.Timestamp, &event.Symbol, &event.Action, &event.Side,
|
||||
&event.Quantity, &event.Price, &event.Fee, &event.Slippage, &event.OrderValue,
|
||||
&event.RealizedPnL, &event.Leverage, &event.Cycle, &event.PositionAfter,
|
||||
&event.LiquidationFlag, &event.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// SaveMetrics 保存指标
|
||||
func (s *BacktestStore) SaveMetrics(runID string, payload []byte) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadMetrics 加载指标
|
||||
func (s *BacktestStore) LoadMetrics(runID string) ([]byte, error) {
|
||||
var payload []byte
|
||||
err := s.db.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload)
|
||||
return payload, err
|
||||
}
|
||||
|
||||
// SaveDecisionRecord 保存决策记录
|
||||
func (s *BacktestStore) SaveDecisionRecord(runID string, cycle int, payload []byte) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`, runID, cycle, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadDecisionRecords 加载决策记录
|
||||
func (s *BacktestStore) LoadDecisionRecords(runID string, limit, offset int) ([]json.RawMessage, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT payload FROM backtest_decisions
|
||||
WHERE run_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, runID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]json.RawMessage, 0, limit)
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, json.RawMessage(payload))
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// LoadLatestDecision 加载最新决策
|
||||
func (s *BacktestStore) LoadLatestDecision(runID string, cycle int) ([]byte, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if cycle > 0 {
|
||||
query = `SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY datetime(created_at) DESC LIMIT 1`
|
||||
args = []interface{}{runID, cycle}
|
||||
} else {
|
||||
query = `SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY datetime(created_at) DESC LIMIT 1`
|
||||
args = []interface{}{runID}
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
err := s.db.QueryRow(query, args...).Scan(&payload)
|
||||
return payload, err
|
||||
}
|
||||
|
||||
// UpdateProgress 更新进度
|
||||
func (s *BacktestStore) UpdateProgress(runID string, progressPct, equity float64, barIndex int, liquidated bool) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?
|
||||
`, progressPct, equity, barIndex, liquidated, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListIndexEntries 列出索引条目
|
||||
func (s *BacktestStore) ListIndexEntries() ([]RunIndexEntry, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT run_id, state, symbol_count, decision_tf, equity_last, max_drawdown_pct,
|
||||
created_at, updated_at, config_json
|
||||
FROM backtest_runs
|
||||
ORDER BY datetime(updated_at) DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []RunIndexEntry
|
||||
for rows.Next() {
|
||||
var entry RunIndexEntry
|
||||
var symbolCnt int
|
||||
var cfgJSON []byte
|
||||
var createdISO, updatedISO string
|
||||
|
||||
if err := rows.Scan(&entry.RunID, &entry.State, &symbolCnt, &entry.DecisionTF,
|
||||
&entry.EquityLast, &entry.MaxDrawdownPct, &createdISO, &updatedISO, &cfgJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry.CreatedAtISO = createdISO
|
||||
entry.UpdatedAtISO = updatedISO
|
||||
entry.Symbols = make([]string, 0, symbolCnt)
|
||||
|
||||
// 尝试从配置中提取更多信息
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg struct {
|
||||
Symbols []string `json:"symbols"`
|
||||
StartTS int64 `json:"start_ts"`
|
||||
EndTS int64 `json:"end_ts"`
|
||||
}
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
entry.Symbols = cfg.Symbols
|
||||
entry.StartTS = cfg.StartTS
|
||||
entry.EndTS = cfg.EndTS
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteRun 删除运行
|
||||
func (s *BacktestStore) DeleteRun(runID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置
|
||||
func (s *BacktestStore) SaveConfig(runID, userID, template, customPrompt, provider, model string, override bool, configJSON []byte) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt,
|
||||
override_prompt, ai_provider, ai_model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, runID, userID, configJSON, template, customPrompt, override, provider, model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?,
|
||||
override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?
|
||||
`, userID, configJSON, template, customPrompt, override, provider, model, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadConfig 加载配置
|
||||
func (s *BacktestStore) LoadConfig(runID string) ([]byte, error) {
|
||||
var payload []byte
|
||||
err := s.db.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload)
|
||||
return payload, err
|
||||
}
|
||||
121
store/beta_code.go
Normal file
121
store/beta_code.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BetaCodeStore 内测码存储
|
||||
type BetaCodeStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *BetaCodeStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS beta_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
used BOOLEAN DEFAULT 0,
|
||||
used_by TEXT DEFAULT '',
|
||||
used_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadFromFile 从文件加载内测码
|
||||
func (s *BetaCodeStore) LoadFromFile(filePath string) error {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取内测码文件失败: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var codes []string
|
||||
for _, line := range lines {
|
||||
code := strings.TrimSpace(line)
|
||||
if code != "" && !strings.HasPrefix(code, "#") {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`INSERT OR IGNORE INTO beta_codes (code) VALUES (?)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("准备语句失败: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
insertedCount := 0
|
||||
for _, code := range codes {
|
||||
result, err := stmt.Exec(code)
|
||||
if err != nil {
|
||||
logger.Warnf("插入内测码 %s 失败: %v", code, err)
|
||||
continue
|
||||
}
|
||||
if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 {
|
||||
insertedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✅ 成功加载 %d 个内测码到数据库 (总计 %d 个)", insertedCount, len(codes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 验证内测码是否有效
|
||||
func (s *BetaCodeStore) Validate(code string) (bool, error) {
|
||||
var used bool
|
||||
err := s.db.QueryRow(`SELECT used FROM beta_codes WHERE code = ?`, code).Scan(&used)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return !used, nil
|
||||
}
|
||||
|
||||
// Use 使用内测码
|
||||
func (s *BetaCodeStore) Use(code, userEmail string) error {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE beta_codes SET used = 1, used_by = ?, used_at = CURRENT_TIMESTAMP
|
||||
WHERE code = ? AND used = 0
|
||||
`, userEmail, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("内测码无效或已被使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStats 获取内测码统计
|
||||
func (s *BetaCodeStore) GetStats() (total, used int, err error) {
|
||||
err = s.db.QueryRow(`SELECT COUNT(*) FROM beta_codes`).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
err = s.db.QueryRow(`SELECT COUNT(*) FROM beta_codes WHERE used = 1`).Scan(&used)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, used, nil
|
||||
}
|
||||
530
store/decision.go
Normal file
530
store/decision.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DecisionStore 决策日志存储
|
||||
type DecisionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// DecisionRecord 决策记录
|
||||
type DecisionRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"`
|
||||
CycleNumber int `json:"cycle_number"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
InputPrompt string `json:"input_prompt"`
|
||||
CoTTrace string `json:"cot_trace"`
|
||||
DecisionJSON string `json:"decision_json"`
|
||||
CandidateCoins []string `json:"candidate_coins"`
|
||||
ExecutionLog []string `json:"execution_log"`
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
AIRequestDurationMs int64 `json:"ai_request_duration_ms"`
|
||||
AccountState AccountSnapshot `json:"account_state"`
|
||||
Positions []PositionSnapshot `json:"positions"`
|
||||
Decisions []DecisionAction `json:"decisions"`
|
||||
}
|
||||
|
||||
// AccountSnapshot 账户状态快照
|
||||
type AccountSnapshot struct {
|
||||
TotalBalance float64 `json:"total_balance"`
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
TotalUnrealizedProfit float64 `json:"total_unrealized_profit"`
|
||||
PositionCount int `json:"position_count"`
|
||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
}
|
||||
|
||||
// PositionSnapshot 持仓快照
|
||||
type PositionSnapshot struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionAmt float64 `json:"position_amt"`
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
UnrealizedProfit float64 `json:"unrealized_profit"`
|
||||
Leverage float64 `json:"leverage"`
|
||||
LiquidationPrice float64 `json:"liquidation_price"`
|
||||
}
|
||||
|
||||
// DecisionAction 决策动作
|
||||
type DecisionAction struct {
|
||||
Action string `json:"action"`
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
Price float64 `json:"price"`
|
||||
OrderID int64 `json:"order_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Statistics 统计信息
|
||||
type Statistics struct {
|
||||
TotalCycles int `json:"total_cycles"`
|
||||
SuccessfulCycles int `json:"successful_cycles"`
|
||||
FailedCycles int `json:"failed_cycles"`
|
||||
TotalOpenPositions int `json:"total_open_positions"`
|
||||
TotalClosePositions int `json:"total_close_positions"`
|
||||
}
|
||||
|
||||
// initTables 初始化决策相关表
|
||||
func (s *DecisionStore) initTables() error {
|
||||
queries := []string{
|
||||
// 决策记录主表
|
||||
`CREATE TABLE IF NOT EXISTS decision_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trader_id TEXT NOT NULL,
|
||||
cycle_number INTEGER NOT NULL,
|
||||
timestamp DATETIME NOT NULL,
|
||||
system_prompt TEXT DEFAULT '',
|
||||
input_prompt TEXT DEFAULT '',
|
||||
cot_trace TEXT DEFAULT '',
|
||||
decision_json TEXT DEFAULT '',
|
||||
candidate_coins TEXT DEFAULT '',
|
||||
execution_log TEXT DEFAULT '',
|
||||
success BOOLEAN DEFAULT 0,
|
||||
error_message TEXT DEFAULT '',
|
||||
ai_request_duration_ms INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// 账户状态快照表
|
||||
`CREATE TABLE IF NOT EXISTS decision_account_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision_id INTEGER NOT NULL,
|
||||
total_balance REAL DEFAULT 0,
|
||||
available_balance REAL DEFAULT 0,
|
||||
total_unrealized_profit REAL DEFAULT 0,
|
||||
position_count INTEGER DEFAULT 0,
|
||||
margin_used_pct REAL DEFAULT 0,
|
||||
initial_balance REAL DEFAULT 0,
|
||||
FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 持仓快照表
|
||||
`CREATE TABLE IF NOT EXISTS decision_position_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision_id INTEGER NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT DEFAULT '',
|
||||
position_amt REAL DEFAULT 0,
|
||||
entry_price REAL DEFAULT 0,
|
||||
mark_price REAL DEFAULT 0,
|
||||
unrealized_profit REAL DEFAULT 0,
|
||||
leverage REAL DEFAULT 0,
|
||||
liquidation_price REAL DEFAULT 0,
|
||||
FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 决策动作表(订单详情)
|
||||
`CREATE TABLE IF NOT EXISTS decision_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision_id INTEGER NOT NULL,
|
||||
trader_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
quantity REAL DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 0,
|
||||
price REAL DEFAULT 0,
|
||||
order_id INTEGER DEFAULT 0,
|
||||
timestamp DATETIME NOT NULL,
|
||||
success BOOLEAN DEFAULT 0,
|
||||
error TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 索引
|
||||
`CREATE INDEX IF NOT EXISTS idx_decision_records_trader_time ON decision_records(trader_id, timestamp DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_decision_records_timestamp ON decision_records(timestamp DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_decision_actions_trader ON decision_actions(trader_id, timestamp DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_decision_actions_symbol ON decision_actions(symbol, timestamp DESC)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := s.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("执行SQL失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogDecision 记录决策
|
||||
func (s *DecisionStore) LogDecision(record *DecisionRecord) error {
|
||||
if record.Timestamp.IsZero() {
|
||||
record.Timestamp = time.Now().UTC()
|
||||
} else {
|
||||
record.Timestamp = record.Timestamp.UTC()
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 序列化候选币种和执行日志为 JSON
|
||||
candidateCoinsJSON, _ := json.Marshal(record.CandidateCoins)
|
||||
executionLogJSON, _ := json.Marshal(record.ExecutionLog)
|
||||
|
||||
// 插入决策记录主表
|
||||
result, err := tx.Exec(`
|
||||
INSERT INTO decision_records (
|
||||
trader_id, cycle_number, timestamp, system_prompt, input_prompt,
|
||||
cot_trace, decision_json, candidate_coins, execution_log,
|
||||
success, error_message, ai_request_duration_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
record.TraderID, record.CycleNumber, record.Timestamp.Format(time.RFC3339),
|
||||
record.SystemPrompt, record.InputPrompt, record.CoTTrace, record.DecisionJSON,
|
||||
string(candidateCoinsJSON), string(executionLogJSON),
|
||||
record.Success, record.ErrorMessage, record.AIRequestDurationMs,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("插入决策记录失败: %w", err)
|
||||
}
|
||||
|
||||
decisionID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取决策ID失败: %w", err)
|
||||
}
|
||||
record.ID = decisionID
|
||||
|
||||
// 插入账户状态快照
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO decision_account_snapshots (
|
||||
decision_id, total_balance, available_balance, total_unrealized_profit,
|
||||
position_count, margin_used_pct, initial_balance
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
decisionID, record.AccountState.TotalBalance, record.AccountState.AvailableBalance,
|
||||
record.AccountState.TotalUnrealizedProfit, record.AccountState.PositionCount,
|
||||
record.AccountState.MarginUsedPct, record.AccountState.InitialBalance,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("插入账户快照失败: %w", err)
|
||||
}
|
||||
|
||||
// 插入持仓快照
|
||||
for _, pos := range record.Positions {
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO decision_position_snapshots (
|
||||
decision_id, symbol, side, position_amt, entry_price,
|
||||
mark_price, unrealized_profit, leverage, liquidation_price
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
decisionID, pos.Symbol, pos.Side, pos.PositionAmt, pos.EntryPrice,
|
||||
pos.MarkPrice, pos.UnrealizedProfit, pos.Leverage, pos.LiquidationPrice,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("插入持仓快照失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 插入决策动作(订单详情)
|
||||
for _, action := range record.Decisions {
|
||||
actionTimestamp := action.Timestamp
|
||||
if actionTimestamp.IsZero() {
|
||||
actionTimestamp = record.Timestamp
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO decision_actions (
|
||||
decision_id, trader_id, action, symbol, quantity, leverage,
|
||||
price, order_id, timestamp, success, error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
decisionID, record.TraderID, action.Action, action.Symbol, action.Quantity,
|
||||
action.Leverage, action.Price, action.OrderID,
|
||||
actionTimestamp.Format(time.RFC3339), action.Success, action.Error,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("插入决策动作失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLatestRecords 获取指定交易员最近N条记录(按时间正序:从旧到新)
|
||||
func (s *DecisionStore) GetLatestRecords(traderID string, n int) ([]*DecisionRecord, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt,
|
||||
cot_trace, decision_json, candidate_coins, execution_log,
|
||||
success, error_message, ai_request_duration_ms
|
||||
FROM decision_records
|
||||
WHERE trader_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`, traderID, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询决策记录失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []*DecisionRecord
|
||||
for rows.Next() {
|
||||
record, err := s.scanDecisionRecord(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
// 填充关联数据
|
||||
for _, record := range records {
|
||||
s.fillRecordDetails(record)
|
||||
}
|
||||
|
||||
// 反转数组,让时间从旧到新排列
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetAllLatestRecords 获取所有交易员最近N条记录
|
||||
func (s *DecisionStore) GetAllLatestRecords(n int) ([]*DecisionRecord, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt,
|
||||
cot_trace, decision_json, candidate_coins, execution_log,
|
||||
success, error_message, ai_request_duration_ms
|
||||
FROM decision_records
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询决策记录失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []*DecisionRecord
|
||||
for rows.Next() {
|
||||
record, err := s.scanDecisionRecord(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
// 反转数组
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetRecordsByDate 获取指定交易员指定日期的所有记录
|
||||
func (s *DecisionStore) GetRecordsByDate(traderID string, date time.Time) ([]*DecisionRecord, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt,
|
||||
cot_trace, decision_json, candidate_coins, execution_log,
|
||||
success, error_message, ai_request_duration_ms
|
||||
FROM decision_records
|
||||
WHERE trader_id = ? AND DATE(timestamp) = ?
|
||||
ORDER BY timestamp ASC
|
||||
`, traderID, dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询决策记录失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []*DecisionRecord
|
||||
for rows.Next() {
|
||||
record, err := s.scanDecisionRecord(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CleanOldRecords 清理N天前的旧记录
|
||||
func (s *DecisionStore) CleanOldRecords(traderID string, days int) (int64, error) {
|
||||
cutoffTime := time.Now().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
DELETE FROM decision_records
|
||||
WHERE trader_id = ? AND timestamp < ?
|
||||
`, traderID, cutoffTime)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("清理旧记录失败: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// GetStatistics 获取指定交易员的统计信息
|
||||
func (s *DecisionStore) GetStatistics(traderID string) (*Statistics, error) {
|
||||
stats := &Statistics{}
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM decision_records WHERE trader_id = ?
|
||||
`, traderID).Scan(&stats.TotalCycles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询总周期数失败: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM decision_records WHERE trader_id = ? AND success = 1
|
||||
`, traderID).Scan(&stats.SuccessfulCycles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询成功周期数失败: %w", err)
|
||||
}
|
||||
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
||||
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM decision_actions
|
||||
WHERE trader_id = ? AND success = 1 AND action IN ('open_long', 'open_short')
|
||||
`, traderID).Scan(&stats.TotalOpenPositions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询开仓次数失败: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM decision_actions
|
||||
WHERE trader_id = ? AND success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
||||
`, traderID).Scan(&stats.TotalClosePositions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询平仓次数失败: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetAllStatistics 获取所有交易员的统计信息
|
||||
func (s *DecisionStore) GetAllStatistics() (*Statistics, error) {
|
||||
stats := &Statistics{}
|
||||
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM decision_records`).Scan(&stats.TotalCycles)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles)
|
||||
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
||||
|
||||
s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM decision_actions
|
||||
WHERE success = 1 AND action IN ('open_long', 'open_short')
|
||||
`).Scan(&stats.TotalOpenPositions)
|
||||
|
||||
s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM decision_actions
|
||||
WHERE success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
||||
`).Scan(&stats.TotalClosePositions)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetLastCycleNumber 获取指定交易员的最后周期编号
|
||||
func (s *DecisionStore) GetLastCycleNumber(traderID string) (int, error) {
|
||||
var cycleNumber int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT COALESCE(MAX(cycle_number), 0) FROM decision_records WHERE trader_id = ?
|
||||
`, traderID).Scan(&cycleNumber)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return cycleNumber, nil
|
||||
}
|
||||
|
||||
// scanDecisionRecord 从行中扫描决策记录
|
||||
func (s *DecisionStore) scanDecisionRecord(rows *sql.Rows) (*DecisionRecord, error) {
|
||||
var record DecisionRecord
|
||||
var timestampStr string
|
||||
var candidateCoinsJSON, executionLogJSON string
|
||||
|
||||
err := rows.Scan(
|
||||
&record.ID, &record.TraderID, &record.CycleNumber, ×tampStr,
|
||||
&record.SystemPrompt, &record.InputPrompt, &record.CoTTrace,
|
||||
&record.DecisionJSON, &candidateCoinsJSON, &executionLogJSON,
|
||||
&record.Success, &record.ErrorMessage, &record.AIRequestDurationMs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
|
||||
json.Unmarshal([]byte(candidateCoinsJSON), &record.CandidateCoins)
|
||||
json.Unmarshal([]byte(executionLogJSON), &record.ExecutionLog)
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// fillRecordDetails 填充决策记录的关联数据
|
||||
func (s *DecisionStore) fillRecordDetails(record *DecisionRecord) {
|
||||
// 查询账户状态
|
||||
s.db.QueryRow(`
|
||||
SELECT total_balance, available_balance, total_unrealized_profit,
|
||||
position_count, margin_used_pct, initial_balance
|
||||
FROM decision_account_snapshots
|
||||
WHERE decision_id = ?
|
||||
`, record.ID).Scan(
|
||||
&record.AccountState.TotalBalance,
|
||||
&record.AccountState.AvailableBalance,
|
||||
&record.AccountState.TotalUnrealizedProfit,
|
||||
&record.AccountState.PositionCount,
|
||||
&record.AccountState.MarginUsedPct,
|
||||
&record.AccountState.InitialBalance,
|
||||
)
|
||||
|
||||
// 查询持仓快照
|
||||
posRows, err := s.db.Query(`
|
||||
SELECT symbol, side, position_amt, entry_price, mark_price,
|
||||
unrealized_profit, leverage, liquidation_price
|
||||
FROM decision_position_snapshots
|
||||
WHERE decision_id = ?
|
||||
`, record.ID)
|
||||
if err == nil {
|
||||
defer posRows.Close()
|
||||
for posRows.Next() {
|
||||
var pos PositionSnapshot
|
||||
posRows.Scan(
|
||||
&pos.Symbol, &pos.Side, &pos.PositionAmt, &pos.EntryPrice,
|
||||
&pos.MarkPrice, &pos.UnrealizedProfit, &pos.Leverage,
|
||||
&pos.LiquidationPrice,
|
||||
)
|
||||
record.Positions = append(record.Positions, pos)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询决策动作
|
||||
actionRows, err := s.db.Query(`
|
||||
SELECT action, symbol, quantity, leverage, price, order_id,
|
||||
timestamp, success, error
|
||||
FROM decision_actions
|
||||
WHERE decision_id = ?
|
||||
`, record.ID)
|
||||
if err == nil {
|
||||
defer actionRows.Close()
|
||||
for actionRows.Next() {
|
||||
var action DecisionAction
|
||||
var timestampStr string
|
||||
actionRows.Scan(
|
||||
&action.Action, &action.Symbol, &action.Quantity,
|
||||
&action.Leverage, &action.Price, &action.OrderID,
|
||||
×tampStr, &action.Success, &action.Error,
|
||||
)
|
||||
action.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
|
||||
record.Decisions = append(record.Decisions, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
245
store/exchange.go
Normal file
245
store/exchange.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExchangeStore 交易所存储
|
||||
type ExchangeStore struct {
|
||||
db *sql.DB
|
||||
encryptFunc func(string) string
|
||||
decryptFunc func(string) string
|
||||
}
|
||||
|
||||
// Exchange 交易所配置
|
||||
type Exchange struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"`
|
||||
AsterUser string `json:"asterUser"`
|
||||
AsterSigner string `json:"asterSigner"`
|
||||
AsterPrivateKey string `json:"asterPrivateKey"`
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"`
|
||||
LighterPrivateKey string `json:"lighterPrivateKey"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighterAPIKeyPrivateKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *ExchangeStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS exchanges (
|
||||
id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT 0,
|
||||
api_key TEXT DEFAULT '',
|
||||
secret_key TEXT DEFAULT '',
|
||||
testnet BOOLEAN DEFAULT 0,
|
||||
hyperliquid_wallet_addr TEXT DEFAULT '',
|
||||
aster_user TEXT DEFAULT '',
|
||||
aster_signer TEXT DEFAULT '',
|
||||
aster_private_key TEXT DEFAULT '',
|
||||
lighter_wallet_addr TEXT DEFAULT '',
|
||||
lighter_private_key TEXT DEFAULT '',
|
||||
lighter_api_key_private_key TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id, user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发器
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at
|
||||
AFTER UPDATE ON exchanges
|
||||
BEGIN
|
||||
UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id AND user_id = NEW.user_id;
|
||||
END
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExchangeStore) initDefaultData() error {
|
||||
exchanges := []struct {
|
||||
id, name, typ string
|
||||
}{
|
||||
{"binance", "Binance Futures", "binance"},
|
||||
{"bybit", "Bybit Futures", "bybit"},
|
||||
{"hyperliquid", "Hyperliquid", "hyperliquid"},
|
||||
{"aster", "Aster DEX", "aster"},
|
||||
{"lighter", "LIGHTER DEX", "lighter"},
|
||||
}
|
||||
|
||||
for _, exchange := range exchanges {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled)
|
||||
VALUES (?, 'default', ?, ?, 0)
|
||||
`, exchange.id, exchange.name, exchange.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化交易所失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExchangeStore) encrypt(plaintext string) string {
|
||||
if s.encryptFunc != nil {
|
||||
return s.encryptFunc(plaintext)
|
||||
}
|
||||
return plaintext
|
||||
}
|
||||
|
||||
func (s *ExchangeStore) decrypt(encrypted string) string {
|
||||
if s.decryptFunc != nil {
|
||||
return s.decryptFunc(encrypted)
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// List 获取用户的交易所列表
|
||||
func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet,
|
||||
COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
|
||||
COALESCE(aster_user, '') as aster_user,
|
||||
COALESCE(aster_signer, '') as aster_signer,
|
||||
COALESCE(aster_private_key, '') as aster_private_key,
|
||||
COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
|
||||
COALESCE(lighter_private_key, '') as lighter_private_key,
|
||||
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
|
||||
created_at, updated_at
|
||||
FROM exchanges WHERE user_id = ? ORDER BY id
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
exchanges := make([]*Exchange, 0)
|
||||
for rows.Next() {
|
||||
var e Exchange
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&e.ID, &e.UserID, &e.Name, &e.Type,
|
||||
&e.Enabled, &e.APIKey, &e.SecretKey, &e.Testnet,
|
||||
&e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
|
||||
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
e.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
e.APIKey = s.decrypt(e.APIKey)
|
||||
e.SecretKey = s.decrypt(e.SecretKey)
|
||||
e.AsterPrivateKey = s.decrypt(e.AsterPrivateKey)
|
||||
e.LighterPrivateKey = s.decrypt(e.LighterPrivateKey)
|
||||
e.LighterAPIKeyPrivateKey = s.decrypt(e.LighterAPIKeyPrivateKey)
|
||||
exchanges = append(exchanges, &e)
|
||||
}
|
||||
return exchanges, nil
|
||||
}
|
||||
|
||||
// Update 更新交易所配置
|
||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey string, testnet bool,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey string) error {
|
||||
|
||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
||||
|
||||
setClauses := []string{
|
||||
"enabled = ?",
|
||||
"testnet = ?",
|
||||
"hyperliquid_wallet_addr = ?",
|
||||
"aster_user = ?",
|
||||
"aster_signer = ?",
|
||||
"lighter_wallet_addr = ?",
|
||||
"updated_at = datetime('now')",
|
||||
}
|
||||
args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr}
|
||||
|
||||
if apiKey != "" {
|
||||
setClauses = append(setClauses, "api_key = ?")
|
||||
args = append(args, s.encrypt(apiKey))
|
||||
}
|
||||
if secretKey != "" {
|
||||
setClauses = append(setClauses, "secret_key = ?")
|
||||
args = append(args, s.encrypt(secretKey))
|
||||
}
|
||||
if asterPrivateKey != "" {
|
||||
setClauses = append(setClauses, "aster_private_key = ?")
|
||||
args = append(args, s.encrypt(asterPrivateKey))
|
||||
}
|
||||
if lighterPrivateKey != "" {
|
||||
setClauses = append(setClauses, "lighter_private_key = ?")
|
||||
args = append(args, s.encrypt(lighterPrivateKey))
|
||||
}
|
||||
|
||||
args = append(args, id, userID)
|
||||
query := fmt.Sprintf(`UPDATE exchanges SET %s WHERE id = ? AND user_id = ?`, strings.Join(setClauses, ", "))
|
||||
|
||||
result, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
// 创建新记录
|
||||
var name, typ string
|
||||
switch id {
|
||||
case "binance":
|
||||
name, typ = "Binance Futures", "cex"
|
||||
case "bybit":
|
||||
name, typ = "Bybit Futures", "cex"
|
||||
case "hyperliquid":
|
||||
name, typ = "Hyperliquid", "dex"
|
||||
case "aster":
|
||||
name, typ = "Aster DEX", "dex"
|
||||
case "lighter":
|
||||
name, typ = "LIGHTER DEX", "dex"
|
||||
default:
|
||||
name, typ = id+" Exchange", "cex"
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet,
|
||||
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
|
||||
lighter_wallet_addr, lighter_private_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey),
|
||||
lighterWalletAddr, s.encrypt(lighterPrivateKey))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建交易所配置
|
||||
func (s *ExchangeStore) Create(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet,
|
||||
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
|
||||
lighter_wallet_addr, lighter_private_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')
|
||||
`, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey))
|
||||
return err
|
||||
}
|
||||
511
store/order.go
Normal file
511
store/order.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraderOrder 交易员订单记录
|
||||
type TraderOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"` // 交易员ID
|
||||
OrderID string `json:"order_id"` // 交易所订单ID
|
||||
ClientOrderID string `json:"client_order_id"` // 客户端订单ID
|
||||
Symbol string `json:"symbol"` // 交易对
|
||||
Side string `json:"side"` // BUY/SELL
|
||||
PositionSide string `json:"position_side"` // LONG/SHORT/BOTH
|
||||
Action string `json:"action"` // open_long/close_long/open_short/close_short
|
||||
OrderType string `json:"order_type"` // MARKET/LIMIT
|
||||
Quantity float64 `json:"quantity"` // 订单数量
|
||||
Price float64 `json:"price"` // 订单价格
|
||||
AvgPrice float64 `json:"avg_price"` // 实际成交均价
|
||||
ExecutedQty float64 `json:"executed_qty"` // 已成交数量
|
||||
Leverage int `json:"leverage"` // 杠杆倍数
|
||||
Status string `json:"status"` // NEW/FILLED/CANCELED/EXPIRED
|
||||
Fee float64 `json:"fee"` // 手续费
|
||||
FeeAsset string `json:"fee_asset"` // 手续费资产
|
||||
RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏(平仓时)
|
||||
EntryPrice float64 `json:"entry_price"` // 开仓价(平仓时记录)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FilledAt time.Time `json:"filled_at"` // 成交时间
|
||||
}
|
||||
|
||||
// TraderStats 交易统计指标
|
||||
type TraderStats struct {
|
||||
TotalTrades int `json:"total_trades"` // 总交易数(已平仓)
|
||||
WinTrades int `json:"win_trades"` // 盈利交易数
|
||||
LossTrades int `json:"loss_trades"` // 亏损交易数
|
||||
WinRate float64 `json:"win_rate"` // 胜率 (%)
|
||||
ProfitFactor float64 `json:"profit_factor"` // 盈亏比
|
||||
SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比
|
||||
TotalPnL float64 `json:"total_pnl"` // 总盈亏
|
||||
TotalFee float64 `json:"total_fee"` // 总手续费
|
||||
AvgWin float64 `json:"avg_win"` // 平均盈利
|
||||
AvgLoss float64 `json:"avg_loss"` // 平均亏损
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"` // 最大回撤 (%)
|
||||
}
|
||||
|
||||
// CompletedOrder 已完成订单(用于AI输入)
|
||||
type CompletedOrder struct {
|
||||
Symbol string `json:"symbol"` // 交易对
|
||||
Action string `json:"action"` // close_long/close_short
|
||||
Side string `json:"side"` // long/short
|
||||
Quantity float64 `json:"quantity"` // 数量
|
||||
EntryPrice float64 `json:"entry_price"` // 开仓价
|
||||
ExitPrice float64 `json:"exit_price"` // 平仓价
|
||||
RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏
|
||||
PnLPct float64 `json:"pnl_pct"` // 盈亏百分比
|
||||
Fee float64 `json:"fee"` // 手续费
|
||||
Leverage int `json:"leverage"` // 杠杆
|
||||
FilledAt time.Time `json:"filled_at"` // 成交时间
|
||||
}
|
||||
|
||||
// OrderStore 订单存储
|
||||
type OrderStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewOrderStore 创建订单存储实例
|
||||
func NewOrderStore(db *sql.DB) *OrderStore {
|
||||
return &OrderStore{db: db}
|
||||
}
|
||||
|
||||
// InitTables 初始化订单表
|
||||
func (s *OrderStore) InitTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS trader_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trader_id TEXT NOT NULL,
|
||||
order_id TEXT NOT NULL,
|
||||
client_order_id TEXT DEFAULT '',
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
position_side TEXT DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
order_type TEXT DEFAULT 'MARKET',
|
||||
quantity REAL NOT NULL,
|
||||
price REAL DEFAULT 0,
|
||||
avg_price REAL DEFAULT 0,
|
||||
executed_qty REAL DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'NEW',
|
||||
fee REAL DEFAULT 0,
|
||||
fee_asset TEXT DEFAULT 'USDT',
|
||||
realized_pnl REAL DEFAULT 0,
|
||||
entry_price REAL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
filled_at DATETIME,
|
||||
UNIQUE(trader_id, order_id)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建trader_orders表失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
indices := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_trader ON trader_orders(trader_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_status ON trader_orders(trader_id, status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_symbol ON trader_orders(trader_id, symbol)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_filled ON trader_orders(trader_id, filled_at DESC)`,
|
||||
}
|
||||
for _, idx := range indices {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("创建索引失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建订单记录
|
||||
func (s *OrderStore) Create(order *TraderOrder) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO trader_orders (
|
||||
trader_id, order_id, client_order_id, symbol, side, position_side,
|
||||
action, order_type, quantity, price, avg_price, executed_qty,
|
||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
order.TraderID, order.OrderID, order.ClientOrderID, order.Symbol,
|
||||
order.Side, order.PositionSide, order.Action, order.OrderType,
|
||||
order.Quantity, order.Price, order.AvgPrice, order.ExecutedQty,
|
||||
order.Leverage, order.Status, order.Fee, order.FeeAsset,
|
||||
order.RealizedPnL, order.EntryPrice, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建订单记录失败: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
order.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 更新订单记录
|
||||
func (s *OrderStore) Update(order *TraderOrder) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
filledAt := ""
|
||||
if !order.FilledAt.IsZero() {
|
||||
filledAt = order.FilledAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE trader_orders SET
|
||||
avg_price = ?, executed_qty = ?, status = ?, fee = ?,
|
||||
realized_pnl = ?, entry_price = ?, updated_at = ?, filled_at = ?
|
||||
WHERE trader_id = ? AND order_id = ?
|
||||
`,
|
||||
order.AvgPrice, order.ExecutedQty, order.Status, order.Fee,
|
||||
order.RealizedPnL, order.EntryPrice, now, filledAt,
|
||||
order.TraderID, order.OrderID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新订单记录失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByOrderID 根据订单ID获取订单
|
||||
func (s *OrderStore) GetByOrderID(traderID, orderID string) (*TraderOrder, error) {
|
||||
var order TraderOrder
|
||||
var createdAt, updatedAt, filledAt sql.NullString
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
||||
action, order_type, quantity, price, avg_price, executed_qty,
|
||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
||||
created_at, updated_at, filled_at
|
||||
FROM trader_orders WHERE trader_id = ? AND order_id = ?
|
||||
`, traderID, orderID).Scan(
|
||||
&order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID,
|
||||
&order.Symbol, &order.Side, &order.PositionSide, &order.Action,
|
||||
&order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice,
|
||||
&order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee,
|
||||
&order.FeeAsset, &order.RealizedPnL, &order.EntryPrice,
|
||||
&createdAt, &updatedAt, &filledAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if createdAt.Valid {
|
||||
order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
if filledAt.Valid {
|
||||
order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
||||
}
|
||||
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// GetLatestOpenOrder 获取某币种最近的开仓订单(用于计算平仓盈亏)
|
||||
func (s *OrderStore) GetLatestOpenOrder(traderID, symbol, side string) (*TraderOrder, error) {
|
||||
// side: long -> 找 open_long, short -> 找 open_short
|
||||
action := "open_long"
|
||||
if side == "short" {
|
||||
action = "open_short"
|
||||
}
|
||||
|
||||
var order TraderOrder
|
||||
var createdAt, updatedAt, filledAt sql.NullString
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
||||
action, order_type, quantity, price, avg_price, executed_qty,
|
||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
||||
created_at, updated_at, filled_at
|
||||
FROM trader_orders
|
||||
WHERE trader_id = ? AND symbol = ? AND action = ? AND status = 'FILLED'
|
||||
ORDER BY filled_at DESC LIMIT 1
|
||||
`, traderID, symbol, action).Scan(
|
||||
&order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID,
|
||||
&order.Symbol, &order.Side, &order.PositionSide, &order.Action,
|
||||
&order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice,
|
||||
&order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee,
|
||||
&order.FeeAsset, &order.RealizedPnL, &order.EntryPrice,
|
||||
&createdAt, &updatedAt, &filledAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if createdAt.Valid {
|
||||
order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
if filledAt.Valid {
|
||||
order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
||||
}
|
||||
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// GetRecentCompletedOrders 获取最近已完成的平仓订单
|
||||
func (s *OrderStore) GetRecentCompletedOrders(traderID string, limit int) ([]CompletedOrder, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT symbol, action, side, executed_qty, entry_price, avg_price,
|
||||
realized_pnl, fee, leverage, filled_at
|
||||
FROM trader_orders
|
||||
WHERE trader_id = ? AND status = 'FILLED'
|
||||
AND (action = 'close_long' OR action = 'close_short')
|
||||
ORDER BY filled_at DESC
|
||||
LIMIT ?
|
||||
`, traderID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询已完成订单失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var orders []CompletedOrder
|
||||
for rows.Next() {
|
||||
var o CompletedOrder
|
||||
var filledAt sql.NullString
|
||||
var side sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&o.Symbol, &o.Action, &side, &o.Quantity, &o.EntryPrice, &o.ExitPrice,
|
||||
&o.RealizedPnL, &o.Fee, &o.Leverage, &filledAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据action推断side
|
||||
if o.Action == "close_long" {
|
||||
o.Side = "long"
|
||||
} else if o.Action == "close_short" {
|
||||
o.Side = "short"
|
||||
} else if side.Valid {
|
||||
o.Side = side.String
|
||||
}
|
||||
|
||||
// 计算盈亏百分比
|
||||
if o.EntryPrice > 0 {
|
||||
if o.Side == "long" {
|
||||
o.PnLPct = (o.ExitPrice - o.EntryPrice) / o.EntryPrice * 100 * float64(o.Leverage)
|
||||
} else {
|
||||
o.PnLPct = (o.EntryPrice - o.ExitPrice) / o.EntryPrice * 100 * float64(o.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
if filledAt.Valid {
|
||||
o.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
||||
}
|
||||
|
||||
orders = append(orders, o)
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetTraderStats 获取交易统计指标
|
||||
func (s *OrderStore) GetTraderStats(traderID string) (*TraderStats, error) {
|
||||
stats := &TraderStats{}
|
||||
|
||||
// 查询所有已完成的平仓订单
|
||||
rows, err := s.db.Query(`
|
||||
SELECT realized_pnl, fee, filled_at
|
||||
FROM trader_orders
|
||||
WHERE trader_id = ? AND status = 'FILLED'
|
||||
AND (action = 'close_long' OR action = 'close_short')
|
||||
ORDER BY filled_at ASC
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询订单统计失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pnls []float64
|
||||
var totalWin, totalLoss float64
|
||||
|
||||
for rows.Next() {
|
||||
var pnl, fee float64
|
||||
var filledAt sql.NullString
|
||||
if err := rows.Scan(&pnl, &fee, &filledAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += pnl
|
||||
stats.TotalFee += fee
|
||||
pnls = append(pnls, pnl)
|
||||
|
||||
if pnl > 0 {
|
||||
stats.WinTrades++
|
||||
totalWin += pnl
|
||||
} else if pnl < 0 {
|
||||
stats.LossTrades++
|
||||
totalLoss += math.Abs(pnl)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算胜率
|
||||
if stats.TotalTrades > 0 {
|
||||
stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100
|
||||
}
|
||||
|
||||
// 计算盈亏比
|
||||
if totalLoss > 0 {
|
||||
stats.ProfitFactor = totalWin / totalLoss
|
||||
}
|
||||
|
||||
// 计算平均盈亏
|
||||
if stats.WinTrades > 0 {
|
||||
stats.AvgWin = totalWin / float64(stats.WinTrades)
|
||||
}
|
||||
if stats.LossTrades > 0 {
|
||||
stats.AvgLoss = totalLoss / float64(stats.LossTrades)
|
||||
}
|
||||
|
||||
// 计算夏普比(使用盈亏序列)
|
||||
if len(pnls) > 1 {
|
||||
stats.SharpeRatio = calculateSharpeRatio(pnls)
|
||||
}
|
||||
|
||||
// 计算最大回撤
|
||||
if len(pnls) > 0 {
|
||||
stats.MaxDrawdownPct = calculateMaxDrawdown(pnls)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// calculateSharpeRatio 计算夏普比
|
||||
func calculateSharpeRatio(pnls []float64) float64 {
|
||||
if len(pnls) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 计算平均收益
|
||||
var sum float64
|
||||
for _, pnl := range pnls {
|
||||
sum += pnl
|
||||
}
|
||||
mean := sum / float64(len(pnls))
|
||||
|
||||
// 计算标准差
|
||||
var variance float64
|
||||
for _, pnl := range pnls {
|
||||
variance += (pnl - mean) * (pnl - mean)
|
||||
}
|
||||
stdDev := math.Sqrt(variance / float64(len(pnls)-1))
|
||||
|
||||
if stdDev == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 夏普比 = 平均收益 / 标准差
|
||||
return mean / stdDev
|
||||
}
|
||||
|
||||
// calculateMaxDrawdown 计算最大回撤
|
||||
func calculateMaxDrawdown(pnls []float64) float64 {
|
||||
if len(pnls) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 计算累计权益曲线
|
||||
var cumulative float64
|
||||
var peak float64
|
||||
var maxDD float64
|
||||
|
||||
for _, pnl := range pnls {
|
||||
cumulative += pnl
|
||||
if cumulative > peak {
|
||||
peak = cumulative
|
||||
}
|
||||
if peak > 0 {
|
||||
dd := (peak - cumulative) / peak * 100
|
||||
if dd > maxDD {
|
||||
maxDD = dd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxDD
|
||||
}
|
||||
|
||||
// GetPendingOrders 获取未成交的订单(用于轮询)
|
||||
func (s *OrderStore) GetPendingOrders(traderID string) ([]*TraderOrder, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
||||
action, order_type, quantity, price, avg_price, executed_qty,
|
||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
||||
created_at, updated_at, filled_at
|
||||
FROM trader_orders
|
||||
WHERE trader_id = ? AND status = 'NEW'
|
||||
ORDER BY created_at ASC
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询未成交订单失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanOrders(rows)
|
||||
}
|
||||
|
||||
// GetAllPendingOrders 获取所有未成交的订单(用于全局同步)
|
||||
func (s *OrderStore) GetAllPendingOrders() ([]*TraderOrder, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
||||
action, order_type, quantity, price, avg_price, executed_qty,
|
||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
||||
created_at, updated_at, filled_at
|
||||
FROM trader_orders
|
||||
WHERE status = 'NEW'
|
||||
ORDER BY trader_id, created_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询未成交订单失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanOrders(rows)
|
||||
}
|
||||
|
||||
// scanOrders 扫描订单行到结构体
|
||||
func (s *OrderStore) scanOrders(rows *sql.Rows) ([]*TraderOrder, error) {
|
||||
var orders []*TraderOrder
|
||||
for rows.Next() {
|
||||
var order TraderOrder
|
||||
var createdAt, updatedAt, filledAt sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID,
|
||||
&order.Symbol, &order.Side, &order.PositionSide, &order.Action,
|
||||
&order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice,
|
||||
&order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee,
|
||||
&order.FeeAsset, &order.RealizedPnL, &order.EntryPrice,
|
||||
&createdAt, &updatedAt, &filledAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if createdAt.Valid {
|
||||
order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
if filledAt.Valid {
|
||||
order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
||||
}
|
||||
|
||||
orders = append(orders, &order)
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
473
store/position.go
Normal file
473
store/position.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraderPosition 仓位记录(完整的开平仓追踪)
|
||||
type TraderPosition struct {
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // LONG/SHORT
|
||||
Quantity float64 `json:"quantity"` // 开仓数量
|
||||
EntryPrice float64 `json:"entry_price"` // 开仓均价
|
||||
EntryOrderID string `json:"entry_order_id"` // 开仓订单ID
|
||||
EntryTime time.Time `json:"entry_time"` // 开仓时间
|
||||
ExitPrice float64 `json:"exit_price"` // 平仓均价
|
||||
ExitOrderID string `json:"exit_order_id"` // 平仓订单ID
|
||||
ExitTime *time.Time `json:"exit_time"` // 平仓时间
|
||||
RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏
|
||||
Fee float64 `json:"fee"` // 手续费
|
||||
Leverage int `json:"leverage"` // 杠杆倍数
|
||||
Status string `json:"status"` // OPEN/CLOSED
|
||||
CloseReason string `json:"close_reason"` // 平仓原因: ai_decision/manual/stop_loss/take_profit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PositionStore 仓位存储
|
||||
type PositionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPositionStore 创建仓位存储实例
|
||||
func NewPositionStore(db *sql.DB) *PositionStore {
|
||||
return &PositionStore{db: db}
|
||||
}
|
||||
|
||||
// InitTables 初始化仓位表
|
||||
func (s *PositionStore) InitTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS trader_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trader_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
entry_order_id TEXT DEFAULT '',
|
||||
entry_time DATETIME NOT NULL,
|
||||
exit_price REAL DEFAULT 0,
|
||||
exit_order_id TEXT DEFAULT '',
|
||||
exit_time DATETIME,
|
||||
realized_pnl REAL DEFAULT 0,
|
||||
fee REAL DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'OPEN',
|
||||
close_reason TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建trader_positions表失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
indices := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_trader ON trader_positions(trader_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_status ON trader_positions(trader_id, status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`,
|
||||
}
|
||||
for _, idx := range indices {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("创建索引失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建仓位记录(开仓时调用)
|
||||
func (s *PositionStore) Create(pos *TraderPosition) error {
|
||||
now := time.Now()
|
||||
pos.CreatedAt = now
|
||||
pos.UpdatedAt = now
|
||||
pos.Status = "OPEN"
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO trader_positions (
|
||||
trader_id, symbol, side, quantity, entry_price, entry_order_id,
|
||||
entry_time, leverage, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
pos.TraderID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice,
|
||||
pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
||||
pos.Status, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建仓位记录失败: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
pos.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosePosition 平仓(更新仓位记录)
|
||||
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
||||
now := time.Now()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE trader_positions SET
|
||||
exit_price = ?, exit_order_id = ?, exit_time = ?,
|
||||
realized_pnl = ?, fee = ?, status = 'CLOSED',
|
||||
close_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
exitPrice, exitOrderID, now.Format(time.RFC3339),
|
||||
realizedPnL, fee, closeReason, now.Format(time.RFC3339), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新仓位记录失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOpenPositions 获取所有未平仓位
|
||||
func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id,
|
||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||
leverage, status, close_reason, created_at, updated_at
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'OPEN'
|
||||
ORDER BY entry_time DESC
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询未平仓位失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanPositions(rows)
|
||||
}
|
||||
|
||||
// GetOpenPositionBySymbol 获取指定币种方向的未平仓位
|
||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||
var pos TraderPosition
|
||||
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id,
|
||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||
leverage, status, close_reason, created_at, updated_at
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN'
|
||||
ORDER BY entry_time DESC LIMIT 1
|
||||
`, traderID, symbol, side).Scan(
|
||||
&pos.ID, &pos.TraderID, &pos.Symbol, &pos.Side, &pos.Quantity,
|
||||
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
return &pos, nil
|
||||
}
|
||||
|
||||
// GetClosedPositions 获取已平仓位(历史记录)
|
||||
func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id,
|
||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||
leverage, status, close_reason, created_at, updated_at
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC
|
||||
LIMIT ?
|
||||
`, traderID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询已平仓位失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanPositions(rows)
|
||||
}
|
||||
|
||||
// GetAllOpenPositions 获取所有trader的未平仓位(用于全局同步)
|
||||
func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id,
|
||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||
leverage, status, close_reason, created_at, updated_at
|
||||
FROM trader_positions
|
||||
WHERE status = 'OPEN'
|
||||
ORDER BY trader_id, entry_time DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询所有未平仓位失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanPositions(rows)
|
||||
}
|
||||
|
||||
// GetPositionStats 获取仓位统计(简单版)
|
||||
func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{}, error) {
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// 总交易数
|
||||
var totalTrades, winTrades int
|
||||
var totalPnL, totalFee float64
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as wins,
|
||||
COALESCE(SUM(realized_pnl), 0) as total_pnl,
|
||||
COALESCE(SUM(fee), 0) as total_fee
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
`, traderID).Scan(&totalTrades, &winTrades, &totalPnL, &totalFee)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats["total_trades"] = totalTrades
|
||||
stats["win_trades"] = winTrades
|
||||
stats["total_pnl"] = totalPnL
|
||||
stats["total_fee"] = totalFee
|
||||
if totalTrades > 0 {
|
||||
stats["win_rate"] = float64(winTrades) / float64(totalTrades) * 100
|
||||
} else {
|
||||
stats["win_rate"] = 0.0
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetFullStats 获取完整的交易统计(与 TraderStats 兼容)
|
||||
func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
|
||||
stats := &TraderStats{}
|
||||
|
||||
// 查询所有已平仓位
|
||||
rows, err := s.db.Query(`
|
||||
SELECT realized_pnl, fee, exit_time
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time ASC
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询仓位统计失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pnls []float64
|
||||
var totalWin, totalLoss float64
|
||||
|
||||
for rows.Next() {
|
||||
var pnl, fee float64
|
||||
var exitTime sql.NullString
|
||||
if err := rows.Scan(&pnl, &fee, &exitTime); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += pnl
|
||||
stats.TotalFee += fee
|
||||
pnls = append(pnls, pnl)
|
||||
|
||||
if pnl > 0 {
|
||||
stats.WinTrades++
|
||||
totalWin += pnl
|
||||
} else if pnl < 0 {
|
||||
stats.LossTrades++
|
||||
totalLoss += -pnl // 转为正数
|
||||
}
|
||||
}
|
||||
|
||||
// 计算胜率
|
||||
if stats.TotalTrades > 0 {
|
||||
stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100
|
||||
}
|
||||
|
||||
// 计算盈亏比
|
||||
if totalLoss > 0 {
|
||||
stats.ProfitFactor = totalWin / totalLoss
|
||||
}
|
||||
|
||||
// 计算平均盈亏
|
||||
if stats.WinTrades > 0 {
|
||||
stats.AvgWin = totalWin / float64(stats.WinTrades)
|
||||
}
|
||||
if stats.LossTrades > 0 {
|
||||
stats.AvgLoss = totalLoss / float64(stats.LossTrades)
|
||||
}
|
||||
|
||||
// 计算夏普比
|
||||
if len(pnls) > 1 {
|
||||
stats.SharpeRatio = calculateSharpeRatioFromPnls(pnls)
|
||||
}
|
||||
|
||||
// 计算最大回撤
|
||||
if len(pnls) > 0 {
|
||||
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// RecentTrade 最近的交易记录(用于AI输入)
|
||||
type RecentTrade struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // long/short
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
ExitPrice float64 `json:"exit_price"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
ExitTime string `json:"exit_time"`
|
||||
}
|
||||
|
||||
// GetRecentTrades 获取最近的已平仓交易
|
||||
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, exit_time
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC
|
||||
LIMIT ?
|
||||
`, traderID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询最近交易失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var trades []RecentTrade
|
||||
for rows.Next() {
|
||||
var t RecentTrade
|
||||
var leverage int
|
||||
var exitTime sql.NullString
|
||||
|
||||
err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &exitTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 转换 side 格式
|
||||
if t.Side == "LONG" {
|
||||
t.Side = "long"
|
||||
} else if t.Side == "SHORT" {
|
||||
t.Side = "short"
|
||||
}
|
||||
|
||||
// 计算盈亏百分比
|
||||
if t.EntryPrice > 0 {
|
||||
if t.Side == "long" {
|
||||
t.PnLPct = (t.ExitPrice - t.EntryPrice) / t.EntryPrice * 100 * float64(leverage)
|
||||
} else {
|
||||
t.PnLPct = (t.EntryPrice - t.ExitPrice) / t.EntryPrice * 100 * float64(leverage)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
if exitTime.Valid {
|
||||
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
|
||||
t.ExitTime = parsed.Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
trades = append(trades, t)
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// calculateSharpeRatioFromPnls 计算夏普比
|
||||
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
|
||||
if len(pnls) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var sum float64
|
||||
for _, pnl := range pnls {
|
||||
sum += pnl
|
||||
}
|
||||
mean := sum / float64(len(pnls))
|
||||
|
||||
var variance float64
|
||||
for _, pnl := range pnls {
|
||||
variance += (pnl - mean) * (pnl - mean)
|
||||
}
|
||||
stdDev := math.Sqrt(variance / float64(len(pnls)-1))
|
||||
|
||||
if stdDev == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return mean / stdDev
|
||||
}
|
||||
|
||||
// calculateMaxDrawdownFromPnls 计算最大回撤
|
||||
func calculateMaxDrawdownFromPnls(pnls []float64) float64 {
|
||||
if len(pnls) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var cumulative, peak, maxDD float64
|
||||
for _, pnl := range pnls {
|
||||
cumulative += pnl
|
||||
if cumulative > peak {
|
||||
peak = cumulative
|
||||
}
|
||||
if peak > 0 {
|
||||
dd := (peak - cumulative) / peak * 100
|
||||
if dd > maxDD {
|
||||
maxDD = dd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxDD
|
||||
}
|
||||
|
||||
// scanPositions 扫描仓位行到结构体
|
||||
func (s *PositionStore) scanPositions(rows *sql.Rows) ([]*TraderPosition, error) {
|
||||
var positions []*TraderPosition
|
||||
for rows.Next() {
|
||||
var pos TraderPosition
|
||||
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&pos.ID, &pos.TraderID, &pos.Symbol, &pos.Side, &pos.Quantity,
|
||||
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
positions = append(positions, &pos)
|
||||
}
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// parsePositionTimes 解析时间字段
|
||||
func (s *PositionStore) parsePositionTimes(pos *TraderPosition, entryTime, exitTime, createdAt, updatedAt sql.NullString) {
|
||||
if entryTime.Valid {
|
||||
pos.EntryTime, _ = time.Parse(time.RFC3339, entryTime.String)
|
||||
}
|
||||
if exitTime.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, exitTime.String)
|
||||
pos.ExitTime = &t
|
||||
}
|
||||
if createdAt.Valid {
|
||||
pos.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
pos.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
}
|
||||
86
store/signal_source.go
Normal file
86
store/signal_source.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SignalSourceStore 信号源存储
|
||||
type SignalSourceStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// SignalSource 用户信号源配置
|
||||
type SignalSource struct {
|
||||
ID int `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
CoinPoolURL string `json:"coin_pool_url"`
|
||||
OITopURL string `json:"oi_top_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *SignalSourceStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_signal_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
coin_pool_url TEXT DEFAULT '',
|
||||
oi_top_url TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发器
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_user_signal_sources_updated_at
|
||||
AFTER UPDATE ON user_signal_sources
|
||||
BEGIN
|
||||
UPDATE user_signal_sources SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create 创建信号源配置
|
||||
func (s *SignalSourceStore) Create(userID, coinPoolURL, oiTopURL string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR REPLACE INTO user_signal_sources (user_id, coin_pool_url, oi_top_url, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, userID, coinPoolURL, oiTopURL)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get 获取信号源配置
|
||||
func (s *SignalSourceStore) Get(userID string) (*SignalSource, error) {
|
||||
var source SignalSource
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, coin_pool_url, oi_top_url, created_at, updated_at
|
||||
FROM user_signal_sources WHERE user_id = ?
|
||||
`, userID).Scan(
|
||||
&source.ID, &source.UserID, &source.CoinPoolURL, &source.OITopURL,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
source.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &source, nil
|
||||
}
|
||||
|
||||
// Update 更新信号源配置
|
||||
func (s *SignalSourceStore) Update(userID, coinPoolURL, oiTopURL string) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE user_signal_sources SET coin_pool_url = ?, oi_top_url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?
|
||||
`, coinPoolURL, oiTopURL, userID)
|
||||
return err
|
||||
}
|
||||
319
store/store.go
Normal file
319
store/store.go
Normal file
@@ -0,0 +1,319 @@
|
||||
// Package store 提供统一的数据库存储层
|
||||
// 所有数据库操作都应该通过这个包进行
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"sync"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store 统一的数据存储接口
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
|
||||
// 子存储(延迟初始化)
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
systemConfig *SystemConfigStore
|
||||
betaCode *BetaCodeStore
|
||||
signalSource *SignalSourceStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
order *OrderStore
|
||||
position *PositionStore
|
||||
|
||||
// 加密函数
|
||||
encryptFunc func(string) string
|
||||
decryptFunc func(string) string
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New 创建新的 Store 实例
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// SQLite 配置
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
// 启用外键约束
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("启用外键失败: %w", err)
|
||||
}
|
||||
|
||||
// 使用 DELETE 模式(传统模式)以确保 Docker bind mount 兼容性
|
||||
// 注意:WAL 模式在 macOS Docker 下会导致数据同步问题
|
||||
if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("设置journal_mode失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置 synchronous=FULL
|
||||
if _, err := db.Exec("PRAGMA synchronous=FULL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("设置synchronous失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置 busy_timeout
|
||||
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("设置busy_timeout失败: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
|
||||
// 初始化所有表结构
|
||||
if err := s.initTables(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("初始化表结构失败: %w", err)
|
||||
}
|
||||
|
||||
// 初始化默认数据
|
||||
if err := s.initDefaultData(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("初始化默认数据失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("✅ 数据库已启用 DELETE 模式和 FULL 同步")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewFromDB 从现有数据库连接创建 Store
|
||||
func NewFromDB(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// SetCryptoFuncs 设置加密解密函数
|
||||
func (s *Store) SetCryptoFuncs(encrypt, decrypt func(string) string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.encryptFunc = encrypt
|
||||
s.decryptFunc = decrypt
|
||||
|
||||
// 更新已初始化的子存储
|
||||
if s.aiModel != nil {
|
||||
s.aiModel.encryptFunc = encrypt
|
||||
s.aiModel.decryptFunc = decrypt
|
||||
}
|
||||
if s.exchange != nil {
|
||||
s.exchange.encryptFunc = encrypt
|
||||
s.exchange.decryptFunc = decrypt
|
||||
}
|
||||
if s.trader != nil {
|
||||
s.trader.decryptFunc = decrypt
|
||||
}
|
||||
}
|
||||
|
||||
// initTables 初始化所有数据库表
|
||||
func (s *Store) initTables() error {
|
||||
// 按依赖顺序初始化
|
||||
if err := s.User().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化用户表失败: %w", err)
|
||||
}
|
||||
if err := s.AIModel().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化AI模型表失败: %w", err)
|
||||
}
|
||||
if err := s.Exchange().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化交易所表失败: %w", err)
|
||||
}
|
||||
if err := s.Trader().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化交易员表失败: %w", err)
|
||||
}
|
||||
if err := s.SystemConfig().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化系统配置表失败: %w", err)
|
||||
}
|
||||
if err := s.BetaCode().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化内测码表失败: %w", err)
|
||||
}
|
||||
if err := s.SignalSource().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化信号源表失败: %w", err)
|
||||
}
|
||||
if err := s.Decision().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化决策日志表失败: %w", err)
|
||||
}
|
||||
if err := s.Backtest().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化回测表失败: %w", err)
|
||||
}
|
||||
if err := s.Order().InitTables(); err != nil {
|
||||
return fmt.Errorf("初始化订单表失败: %w", err)
|
||||
}
|
||||
if err := s.Position().InitTables(); err != nil {
|
||||
return fmt.Errorf("初始化仓位表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initDefaultData 初始化默认数据
|
||||
func (s *Store) initDefaultData() error {
|
||||
if err := s.AIModel().initDefaultData(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Exchange().initDefaultData(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SystemConfig().initDefaultData(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// User 获取用户存储
|
||||
func (s *Store) User() *UserStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.user == nil {
|
||||
s.user = &UserStore{db: s.db}
|
||||
}
|
||||
return s.user
|
||||
}
|
||||
|
||||
// AIModel 获取AI模型存储
|
||||
func (s *Store) AIModel() *AIModelStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.aiModel == nil {
|
||||
s.aiModel = &AIModelStore{
|
||||
db: s.db,
|
||||
encryptFunc: s.encryptFunc,
|
||||
decryptFunc: s.decryptFunc,
|
||||
}
|
||||
}
|
||||
return s.aiModel
|
||||
}
|
||||
|
||||
// Exchange 获取交易所存储
|
||||
func (s *Store) Exchange() *ExchangeStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.exchange == nil {
|
||||
s.exchange = &ExchangeStore{
|
||||
db: s.db,
|
||||
encryptFunc: s.encryptFunc,
|
||||
decryptFunc: s.decryptFunc,
|
||||
}
|
||||
}
|
||||
return s.exchange
|
||||
}
|
||||
|
||||
// Trader 获取交易员存储
|
||||
func (s *Store) Trader() *TraderStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.trader == nil {
|
||||
s.trader = &TraderStore{
|
||||
db: s.db,
|
||||
decryptFunc: s.decryptFunc,
|
||||
}
|
||||
}
|
||||
return s.trader
|
||||
}
|
||||
|
||||
// SystemConfig 获取系统配置存储
|
||||
func (s *Store) SystemConfig() *SystemConfigStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.systemConfig == nil {
|
||||
s.systemConfig = &SystemConfigStore{db: s.db}
|
||||
}
|
||||
return s.systemConfig
|
||||
}
|
||||
|
||||
// BetaCode 获取内测码存储
|
||||
func (s *Store) BetaCode() *BetaCodeStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.betaCode == nil {
|
||||
s.betaCode = &BetaCodeStore{db: s.db}
|
||||
}
|
||||
return s.betaCode
|
||||
}
|
||||
|
||||
// SignalSource 获取信号源存储
|
||||
func (s *Store) SignalSource() *SignalSourceStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.signalSource == nil {
|
||||
s.signalSource = &SignalSourceStore{db: s.db}
|
||||
}
|
||||
return s.signalSource
|
||||
}
|
||||
|
||||
// Decision 获取决策日志存储
|
||||
func (s *Store) Decision() *DecisionStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.decision == nil {
|
||||
s.decision = &DecisionStore{db: s.db}
|
||||
}
|
||||
return s.decision
|
||||
}
|
||||
|
||||
// Backtest 获取回测数据存储
|
||||
func (s *Store) Backtest() *BacktestStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.backtest == nil {
|
||||
s.backtest = &BacktestStore{db: s.db}
|
||||
}
|
||||
return s.backtest
|
||||
}
|
||||
|
||||
// Order 获取订单存储
|
||||
func (s *Store) Order() *OrderStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.order == nil {
|
||||
s.order = NewOrderStore(s.db)
|
||||
}
|
||||
return s.order
|
||||
}
|
||||
|
||||
// Position 获取仓位存储
|
||||
func (s *Store) Position() *PositionStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.position == nil {
|
||||
s.position = NewPositionStore(s.db)
|
||||
}
|
||||
return s.position
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// DB 获取底层数据库连接(仅用于兼容旧代码,逐步废弃)
|
||||
// Deprecated: 使用 Store 的方法代替
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Transaction 执行事务
|
||||
func (s *Store) Transaction(fn func(tx *sql.Tx) error) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
70
store/system_config.go
Normal file
70
store/system_config.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// SystemConfigStore 系统配置存储
|
||||
type SystemConfigStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *SystemConfigStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发器
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_system_config_updated_at
|
||||
AFTER UPDATE ON system_config
|
||||
BEGIN
|
||||
UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE key = NEW.key;
|
||||
END
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SystemConfigStore) initDefaultData() error {
|
||||
configs := map[string]string{
|
||||
"beta_mode": "false",
|
||||
"api_server_port": "8080",
|
||||
"use_default_coins": "true",
|
||||
"default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`,
|
||||
"max_daily_loss": "10.0",
|
||||
"max_drawdown": "20.0",
|
||||
"stop_trading_minutes": "60",
|
||||
"btc_eth_leverage": "5",
|
||||
"altcoin_leverage": "5",
|
||||
"jwt_secret": "",
|
||||
"registration_enabled": "true",
|
||||
}
|
||||
|
||||
for key, value := range configs {
|
||||
_, err := s.db.Exec(`INSERT OR IGNORE INTO system_config (key, value) VALUES (?, ?)`, key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取配置值
|
||||
func (s *SystemConfigStore) Get(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow(`SELECT value FROM system_config WHERE key = ?`, key).Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
// Set 设置配置值
|
||||
func (s *SystemConfigStore) Set(key, value string) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)`, key, value)
|
||||
return err
|
||||
}
|
||||
344
store/trader.go
Normal file
344
store/trader.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraderStore 交易员存储
|
||||
type TraderStore struct {
|
||||
db *sql.DB
|
||||
decryptFunc func(string) string
|
||||
}
|
||||
|
||||
// Trader 交易员配置
|
||||
type Trader struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
||||
IsCrossMargin bool `json:"is_cross_margin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TraderFullConfig 交易员完整配置(包含AI模型和交易所)
|
||||
type TraderFullConfig struct {
|
||||
Trader *Trader
|
||||
AIModel *AIModel
|
||||
Exchange *Exchange
|
||||
}
|
||||
|
||||
func (s *TraderStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS traders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
exchange_id TEXT NOT NULL,
|
||||
initial_balance REAL NOT NULL,
|
||||
scan_interval_minutes INTEGER DEFAULT 3,
|
||||
is_running BOOLEAN DEFAULT 0,
|
||||
btc_eth_leverage INTEGER DEFAULT 5,
|
||||
altcoin_leverage INTEGER DEFAULT 5,
|
||||
trading_symbols TEXT DEFAULT '',
|
||||
use_coin_pool BOOLEAN DEFAULT 0,
|
||||
use_oi_top BOOLEAN DEFAULT 0,
|
||||
custom_prompt TEXT DEFAULT '',
|
||||
override_base_prompt BOOLEAN DEFAULT 0,
|
||||
system_prompt_template TEXT DEFAULT 'default',
|
||||
is_cross_margin BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发器
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_traders_updated_at
|
||||
AFTER UPDATE ON traders
|
||||
BEGIN
|
||||
UPDATE traders SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 向后兼容
|
||||
alterQueries := []string{
|
||||
`ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`,
|
||||
`ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`,
|
||||
`ALTER TABLE traders ADD COLUMN is_cross_margin BOOLEAN DEFAULT 1`,
|
||||
`ALTER TABLE traders ADD COLUMN btc_eth_leverage INTEGER DEFAULT 5`,
|
||||
`ALTER TABLE traders ADD COLUMN altcoin_leverage INTEGER DEFAULT 5`,
|
||||
`ALTER TABLE traders ADD COLUMN trading_symbols TEXT DEFAULT ''`,
|
||||
`ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`,
|
||||
`ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`,
|
||||
`ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`,
|
||||
}
|
||||
for _, q := range alterQueries {
|
||||
s.db.Exec(q)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TraderStore) decrypt(encrypted string) string {
|
||||
if s.decryptFunc != nil {
|
||||
return s.decryptFunc(encrypted)
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// Create 创建交易员
|
||||
func (s *TraderStore) Create(trader *Trader) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes,
|
||||
is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool,
|
||||
use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, is_cross_margin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance,
|
||||
trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage,
|
||||
trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt,
|
||||
trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin)
|
||||
return err
|
||||
}
|
||||
|
||||
// List 获取用户的交易员列表
|
||||
func (s *TraderStore) List(userID string) ([]*Trader, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running,
|
||||
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''),
|
||||
COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
|
||||
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'),
|
||||
COALESCE(is_cross_margin, 1), created_at, updated_at
|
||||
FROM traders WHERE user_id = ? ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var traders []*Trader
|
||||
for rows.Next() {
|
||||
var t Trader
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID,
|
||||
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning,
|
||||
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
|
||||
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
|
||||
&t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
traders = append(traders, &t)
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新交易员运行状态
|
||||
func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error {
|
||||
_, err := s.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ? AND user_id = ?`, isRunning, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update 更新交易员配置
|
||||
func (s *TraderStore) Update(trader *Trader) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE traders SET
|
||||
name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?,
|
||||
btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?,
|
||||
custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?,
|
||||
is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes,
|
||||
trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols,
|
||||
trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate,
|
||||
trader.IsCrossMargin, trader.ID, trader.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateInitialBalance 更新初始余额
|
||||
func (s *TraderStore) UpdateInitialBalance(userID, id string, newBalance float64) error {
|
||||
_, err := s.db.Exec(`UPDATE traders SET initial_balance = ? WHERE id = ? AND user_id = ?`, newBalance, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCustomPrompt 更新自定义提示词
|
||||
func (s *TraderStore) UpdateCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error {
|
||||
_, err := s.db.Exec(`UPDATE traders SET custom_prompt = ?, override_base_prompt = ? WHERE id = ? AND user_id = ?`,
|
||||
customPrompt, overrideBase, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除交易员
|
||||
func (s *TraderStore) Delete(userID, id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetFullConfig 获取交易员完整配置
|
||||
func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, error) {
|
||||
var trader Trader
|
||||
var aiModel AIModel
|
||||
var exchange Exchange
|
||||
var traderCreatedAt, traderUpdatedAt string
|
||||
var aiModelCreatedAt, aiModelUpdatedAt string
|
||||
var exchangeCreatedAt, exchangeUpdatedAt string
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT
|
||||
t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running,
|
||||
COALESCE(t.btc_eth_leverage, 5), COALESCE(t.altcoin_leverage, 5), COALESCE(t.trading_symbols, ''),
|
||||
COALESCE(t.use_coin_pool, 0), COALESCE(t.use_oi_top, 0), COALESCE(t.custom_prompt, ''),
|
||||
COALESCE(t.override_base_prompt, 0), COALESCE(t.system_prompt_template, 'default'),
|
||||
COALESCE(t.is_cross_margin, 1), t.created_at, t.updated_at,
|
||||
a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key,
|
||||
COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), a.created_at, a.updated_at,
|
||||
e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet,
|
||||
COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''),
|
||||
COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''),
|
||||
COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at
|
||||
FROM traders t
|
||||
JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id
|
||||
JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id
|
||||
WHERE t.id = ? AND t.user_id = ?
|
||||
`, traderID, userID).Scan(
|
||||
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
|
||||
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
|
||||
&trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols,
|
||||
&trader.UseCoinPool, &trader.UseOITop, &trader.CustomPrompt, &trader.OverrideBasePrompt,
|
||||
&trader.SystemPromptTemplate, &trader.IsCrossMargin, &traderCreatedAt, &traderUpdatedAt,
|
||||
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
|
||||
&aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt,
|
||||
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
|
||||
&exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, &exchange.HyperliquidWalletAddr,
|
||||
&exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
|
||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
|
||||
&exchangeCreatedAt, &exchangeUpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trader.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", traderCreatedAt)
|
||||
trader.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", traderUpdatedAt)
|
||||
aiModel.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelCreatedAt)
|
||||
aiModel.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelUpdatedAt)
|
||||
exchange.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeCreatedAt)
|
||||
exchange.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeUpdatedAt)
|
||||
|
||||
// 解密
|
||||
aiModel.APIKey = s.decrypt(aiModel.APIKey)
|
||||
exchange.APIKey = s.decrypt(exchange.APIKey)
|
||||
exchange.SecretKey = s.decrypt(exchange.SecretKey)
|
||||
exchange.AsterPrivateKey = s.decrypt(exchange.AsterPrivateKey)
|
||||
exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey)
|
||||
exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey)
|
||||
|
||||
return &TraderFullConfig{
|
||||
Trader: &trader,
|
||||
AIModel: &aiModel,
|
||||
Exchange: &exchange,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCustomCoins 获取所有交易员自定义币种
|
||||
func (s *TraderStore) GetCustomCoins() []string {
|
||||
var symbol string
|
||||
var symbols []string
|
||||
_ = s.db.QueryRow(`
|
||||
SELECT GROUP_CONCAT(trading_symbols, ',') as symbol
|
||||
FROM traders WHERE trading_symbols != ''
|
||||
`).Scan(&symbol)
|
||||
|
||||
// 如果没有自定义币种,返回默认币种
|
||||
if symbol == "" {
|
||||
var symbolJSON string
|
||||
_ = s.db.QueryRow(`SELECT value FROM system_config WHERE key = 'default_coins'`).Scan(&symbolJSON)
|
||||
if symbolJSON != "" {
|
||||
if err := json.Unmarshal([]byte(symbolJSON), &symbols); err != nil {
|
||||
logger.Warnf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err)
|
||||
symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"}
|
||||
}
|
||||
} else {
|
||||
symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"}
|
||||
}
|
||||
return symbols
|
||||
}
|
||||
|
||||
// 处理并去重币种列表
|
||||
for _, s := range strings.Split(symbol, ",") {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
coin := market.Normalize(s)
|
||||
if !slices.Contains(symbols, coin) {
|
||||
symbols = append(symbols, coin)
|
||||
}
|
||||
}
|
||||
return symbols
|
||||
}
|
||||
|
||||
// ListAll 获取所有用户的交易员列表
|
||||
func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running,
|
||||
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''),
|
||||
COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
|
||||
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'),
|
||||
COALESCE(is_cross_margin, 1), created_at, updated_at
|
||||
FROM traders ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var traders []*Trader
|
||||
for rows.Next() {
|
||||
var t Trader
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID,
|
||||
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning,
|
||||
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
|
||||
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
|
||||
&t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
traders = append(traders, &t)
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
164
store/user.go
Normal file
164
store/user.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base32"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserStore 用户存储
|
||||
type UserStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// User 用户
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
OTPSecret string `json:"-"`
|
||||
OTPVerified bool `json:"otp_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// GenerateOTPSecret 生成OTP密钥
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
func (s *UserStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
otp_secret TEXT,
|
||||
otp_verified BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发器
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_users_updated_at
|
||||
AFTER UPDATE ON users
|
||||
BEGIN
|
||||
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建用户
|
||||
func (s *UserStore) Create(user *User) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO users (id, email, password_hash, otp_secret, otp_verified)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, user.ID, user.Email, user.PasswordHash, user.OTPSecret, user.OTPVerified)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByEmail 通过邮箱获取用户
|
||||
func (s *UserStore) GetByEmail(email string) (*User, error) {
|
||||
var user User
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
|
||||
FROM users WHERE email = ?
|
||||
`, email).Scan(
|
||||
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
|
||||
&user.OTPVerified, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetByID 通过ID获取用户
|
||||
func (s *UserStore) GetByID(userID string) (*User, error) {
|
||||
var user User
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
|
||||
FROM users WHERE id = ?
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
|
||||
&user.OTPVerified, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetAllIDs 获取所有用户ID
|
||||
func (s *UserStore) GetAllIDs() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT id FROM users ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []string
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
// UpdateOTPVerified 更新OTP验证状态
|
||||
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET otp_verified = ? WHERE id = ?`, verified, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePassword 更新密码
|
||||
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`, passwordHash, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureAdmin 确保admin用户存在
|
||||
func (s *UserStore) EnsureAdmin() error {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = 'admin'`).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
return s.Create(&User{
|
||||
ID: "admin",
|
||||
Email: "admin@localhost",
|
||||
PasswordHash: "",
|
||||
OTPSecret: "",
|
||||
OTPVerified: true,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user