Dev backtest (#1134)

This commit is contained in:
Rick
2025-11-28 21:34:27 +08:00
committed by GitHub
parent 64a5734011
commit 7eebb4e218
39 changed files with 9293 additions and 125 deletions

View File

@@ -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, &notnull, &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()
}