mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 07:16:56 +08:00
Dev backtest (#1134)
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"nofx/crypto"
|
||||
@@ -64,6 +65,14 @@ func NewDatabase(dbPath string) (*Database, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
return nil, fmt.Errorf("启用外键失败: %w", err)
|
||||
}
|
||||
if err := tuneSQLiteConnection(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 🔒 启用 WAL 模式,提高并发性能和崩溃恢复能力
|
||||
// WAL (Write-Ahead Logging) 模式的优势:
|
||||
@@ -87,6 +96,17 @@ func NewDatabase(dbPath string) (*Database, error) {
|
||||
if err := database.createTables(); err != nil {
|
||||
return nil, fmt.Errorf("创建表失败: %w", err)
|
||||
}
|
||||
if err := database.ensureBacktestRunColumns(); err != nil {
|
||||
return nil, fmt.Errorf("初始化回测表结构失败: %w", err)
|
||||
}
|
||||
|
||||
// 确保存在默认用户(用于外键约束和默认配置种子)
|
||||
if _, err := db.Exec(`
|
||||
INSERT OR IGNORE INTO users (id, email, password_hash, otp_secret, otp_verified)
|
||||
VALUES ('default', 'default@local', '__default__', '', 1)
|
||||
`); err != nil {
|
||||
return nil, fmt.Errorf("创建默认用户失败: %w", err)
|
||||
}
|
||||
|
||||
if err := database.initDefaultData(); err != nil {
|
||||
return nil, fmt.Errorf("初始化默认数据失败: %w", err)
|
||||
@@ -189,6 +209,99 @@ func (d *Database) createTables() error {
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// 回测运行主表
|
||||
`CREATE TABLE IF NOT EXISTS backtest_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL DEFAULT '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)`,
|
||||
|
||||
// 内测码表
|
||||
`CREATE TABLE IF NOT EXISTS beta_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
@@ -280,6 +393,72 @@ func (d *Database) createTables() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ensureBacktestRunColumns() error {
|
||||
addColumn := func(table, column, definition string) error {
|
||||
exists, err := columnExists(d.db, table, column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
_, err = d.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
|
||||
return err
|
||||
}
|
||||
if err := addColumn("backtest_runs", "label", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addColumn("backtest_runs", "last_error", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addColumn("backtest_trades", "leverage", "INTEGER DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dfltValue any
|
||||
primaryKey int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &primaryKey); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func tuneSQLiteConnection(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("db is nil")
|
||||
}
|
||||
statements := []string{
|
||||
`PRAGMA busy_timeout = 5000`,
|
||||
`PRAGMA journal_mode = WAL`,
|
||||
`PRAGMA synchronous = NORMAL`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("执行 %s 失败: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initDefaultData 初始化默认数据
|
||||
func (d *Database) initDefaultData() error {
|
||||
// 初始化AI模型(使用default用户)
|
||||
@@ -663,6 +842,103 @@ func (d *Database) GetAIModels(userID string) ([]*AIModelConfig, error) {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// GetAIModel 根据模型ID和用户ID获取单个AI模型配置,若用户下不存在则回退到default用户。
|
||||
func (d *Database) GetAIModel(userID, modelID string) (*AIModelConfig, 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 AIModelConfig
|
||||
err := d.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,
|
||||
&model.CreatedAt,
|
||||
&model.UpdatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
// 解密API Key(与 GetAIModels 行为保持一致)
|
||||
model.APIKey = d.decryptSensitiveData(model.APIKey)
|
||||
return &model, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// GetDefaultAIModel 获取指定用户(或默认用户)的首个启用的AI模型。
|
||||
func (d *Database) GetDefaultAIModel(userID string) (*AIModelConfig, error) {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
model, err := d.firstEnabledAIModel(userID)
|
||||
if err == nil {
|
||||
return model, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if userID != "default" {
|
||||
return d.firstEnabledAIModel("default")
|
||||
}
|
||||
return nil, fmt.Errorf("请先在系统中配置可用的AI模型")
|
||||
}
|
||||
|
||||
func (d *Database) firstEnabledAIModel(userID string) (*AIModelConfig, error) {
|
||||
var model AIModelConfig
|
||||
err := d.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,
|
||||
&model.CreatedAt,
|
||||
&model.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解密API Key,避免上层拿到加密串导致下游认证失败
|
||||
model.APIKey = d.decryptSensitiveData(model.APIKey)
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置
|
||||
func (d *Database) UpdateAIModel(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
// 先尝试精确匹配 ID(新版逻辑,支持多个相同 provider 的模型)
|
||||
@@ -1172,6 +1448,11 @@ func (d *Database) GetCustomCoins() []string {
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
// Conn 返回底层 *sql.DB,供需要执行自定义查询的模块使用。
|
||||
func (d *Database) Conn() *sql.DB {
|
||||
return d.db
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user