package db import ( "database/sql" "log" "os" "path/filepath" "time" _ "modernc.org/sqlite" ) // DB wraps the sql.DB connection with our schema. type DB struct { *sql.DB Path string } // Open opens or creates the SQLite database at the given path. func Open(path string) (*DB, error) { if path == "" { home, _ := os.UserHomeDir() path = filepath.Join(home, "Project", "exchange-monitor-go", "data", "trades.db") } // Ensure directory exists os.MkdirAll(filepath.Dir(path), 0755) db, err := sql.Open("sqlite", path) if err != nil { return nil, err } db.SetMaxOpenConns(1) // SQLite single-writer db.SetMaxIdleConns(1) d := &DB{DB: db, Path: path} if err := d.migrate(); err != nil { return nil, err } return d, nil } func (d *DB) migrate() error { schema := ` CREATE TABLE IF NOT EXISTS trades ( id INTEGER PRIMARY KEY AUTOINCREMENT, coin TEXT NOT NULL, direction TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', entry_spread REAL, exit_spread REAL, long_exchange TEXT, short_exchange TEXT, long_entry REAL, long_exit REAL, short_entry REAL, short_exit REAL, long_pnl REAL, short_pnl REAL, fee_entry REAL, fee_exit REAL, net_pnl REAL, amount_usd REAL, scale_count INTEGER DEFAULT 0, exit_reason TEXT, convergence TEXT, opened_at DATETIME NOT NULL, closed_at DATETIME ); CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, trade_id INTEGER NOT NULL REFERENCES trades(id), leg TEXT NOT NULL, type TEXT NOT NULL, exchange TEXT NOT NULL, side TEXT NOT NULL, price REAL, size REAL, fee REAL, order_id TEXT, status TEXT, created_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS config_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, value TEXT NOT NULL, changed_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_trades_coin ON trades(coin); CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status); CREATE INDEX IF NOT EXISTS idx_trades_opened ON trades(opened_at); CREATE INDEX IF NOT EXISTS idx_orders_trade_id ON orders(trade_id); CREATE TABLE IF NOT EXISTS system_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, trade_id INTEGER NOT NULL REFERENCES trades(id), type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'filled', spread REAL, long_price REAL, short_price REAL, long_order_id INTEGER REFERENCES orders(id), short_order_id INTEGER REFERENCES orders(id), created_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_system_orders_trade ON system_orders(trade_id); CREATE TABLE IF NOT EXISTS trend_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, coin TEXT NOT NULL, prev_state TEXT NOT NULL, new_state TEXT NOT NULL, direction TEXT NOT NULL, z_score REAL, volatility REAL, bg_change REAL, hl_change REAL, bn_change REAL, okx_change REAL, ex_agree INTEGER, ex_total INTEGER, created_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_trend_events_coin ON trend_events(coin); CREATE INDEX IF NOT EXISTS idx_trend_events_created ON trend_events(created_at); CREATE TABLE IF NOT EXISTS cm_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, coin TEXT NOT NULL, prev_state TEXT NOT NULL, new_state TEXT NOT NULL, direction TEXT NOT NULL, score REAL, avg_change REAL, ex_agree INTEGER, ex_total INTEGER, bg_1m REAL, hl_1m REAL, bn_1m REAL, okx_1m REAL, bg_5m REAL, hl_5m REAL, bn_5m REAL, okx_5m REAL, created_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_cm_events_coin ON cm_events(coin); CREATE INDEX IF NOT EXISTS idx_cm_events_created ON cm_events(created_at); ` _, err := d.Exec(schema) if err != nil { return err } // Migration v2: add per-exchange fee/pnl columns (idempotent) for _, col := range []string{"pnl_long_usd", "pnl_short_usd", "fee_long_usd", "fee_short_usd"} { var found int d.QueryRow("SELECT COUNT(*) FROM pragma_table_info('trades') WHERE name=?", col).Scan(&found) if found == 0 { if _, err := d.Exec("ALTER TABLE trades ADD COLUMN " + col + " REAL"); err != nil { log.Printf("[DB] Migration: add column %s: %v", col, err) } } } log.Printf("[DB] SQLite ready: %s", d.Path) return nil } // Now returns the current time in UTC. func Now() time.Time { return time.Now().UTC() }