Added klines, liquidations, tickers and trades to be recorded. Bundled the stats as cli argument.
This commit is contained in:
+778
-69
@@ -11,10 +11,84 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// StreamStorage manages databases for a single stream (trades, ticker, etc.).
|
||||
// Each stream has its own subdirectory under the base data dir.
|
||||
type StreamStorage struct {
|
||||
streamName string
|
||||
dataDir string // full path: e.g. "data/trades"
|
||||
archiveDir string // full path: e.g. "data/trades/archive"
|
||||
}
|
||||
|
||||
// NewStreamStorage creates directories for a stream and returns a StreamStorage.
|
||||
func NewStreamStorage(baseDir, streamName string) (*StreamStorage, error) {
|
||||
dataDir := filepath.Join(baseDir, streamName)
|
||||
archiveDir := filepath.Join(dataDir, "archive")
|
||||
|
||||
if err := os.MkdirAll(archiveDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create %s dirs: %w", streamName, err)
|
||||
}
|
||||
|
||||
return &StreamStorage{
|
||||
streamName: streamName,
|
||||
dataDir: dataDir,
|
||||
archiveDir: archiveDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DBPath returns the full path for a database file within this stream's directory.
|
||||
func (ss *StreamStorage) DBPath(filename string) string {
|
||||
return filepath.Join(ss.dataDir, filename)
|
||||
}
|
||||
|
||||
// ArchivePath returns an archive file path for a given timestamp using ISO week format.
|
||||
func (ss *StreamStorage) ArchivePath(prefix string, timestampMs int64) string {
|
||||
t := time.UnixMilli(timestampMs)
|
||||
year, week := t.ISOWeek()
|
||||
filename := fmt.Sprintf("%s_%d_W%02d.db", prefix, year, week)
|
||||
return filepath.Join(ss.archiveDir, filename)
|
||||
}
|
||||
|
||||
// OpenDB opens a SQLite database at the given path with WAL mode and performance pragmas.
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// OpenDBWithAutoVacuum opens a SQLite database with WAL mode and incremental auto-vacuum.
|
||||
func OpenDBWithAutoVacuum(path string) (*sql.DB, error) {
|
||||
db, err := OpenDB(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA auto_vacuum=INCREMENTAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// StorageManager — coordinates per-stream storages and maintenance
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
// StorageManager handles all SQLite database operations: initialization,
|
||||
// raw tick writing, feature writing, hourly migration, and pruning.
|
||||
type StorageManager struct {
|
||||
cfg Config
|
||||
cfg Config
|
||||
streams map[string]*StreamStorage
|
||||
|
||||
// Legacy paths (used for backward-compatible migration only)
|
||||
hotDBPath string
|
||||
featDBPath string
|
||||
archiveDir string
|
||||
@@ -22,72 +96,112 @@ type StorageManager struct {
|
||||
|
||||
// NewStorageManager creates directories, initializes databases, and returns a ready manager.
|
||||
func NewStorageManager(cfg Config) (*StorageManager, error) {
|
||||
archiveDir := filepath.Join(cfg.DataDir, "archive")
|
||||
if err := os.MkdirAll(archiveDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create archive dir: %w", err)
|
||||
}
|
||||
|
||||
sm := &StorageManager{
|
||||
cfg: cfg,
|
||||
hotDBPath: filepath.Join(cfg.DataDir, "hot_ticks.db"),
|
||||
featDBPath: filepath.Join(cfg.DataDir, "features.db"),
|
||||
archiveDir: archiveDir,
|
||||
cfg: cfg,
|
||||
streams: make(map[string]*StreamStorage),
|
||||
}
|
||||
|
||||
if err := sm.initHotDB(); err != nil {
|
||||
return nil, fmt.Errorf("init hot db: %w", err)
|
||||
// Perform data migration from old flat layout to new per-stream layout
|
||||
if err := sm.migrateOldLayout(); err != nil {
|
||||
return nil, fmt.Errorf("migrate old layout: %w", err)
|
||||
}
|
||||
if err := sm.initFeaturesDB(); err != nil {
|
||||
return nil, fmt.Errorf("init features db: %w", err)
|
||||
|
||||
// Initialize stream storages for enabled streams
|
||||
streamNames := []string{}
|
||||
if cfg.Streams.Trades.Enabled {
|
||||
streamNames = append(streamNames, "trades")
|
||||
}
|
||||
if cfg.Streams.Ticker.Enabled {
|
||||
streamNames = append(streamNames, "ticker")
|
||||
}
|
||||
if cfg.Streams.Klines.Enabled {
|
||||
streamNames = append(streamNames, "klines")
|
||||
}
|
||||
if cfg.Streams.Orderbook.Enabled {
|
||||
streamNames = append(streamNames, "orderbook")
|
||||
}
|
||||
if cfg.Streams.Liquidations.Enabled {
|
||||
streamNames = append(streamNames, "liquidations")
|
||||
}
|
||||
|
||||
for _, name := range streamNames {
|
||||
ss, err := NewStreamStorage(cfg.DataDir, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sm.streams[name] = ss
|
||||
}
|
||||
|
||||
// Initialize databases per stream
|
||||
if cfg.Streams.Trades.Enabled {
|
||||
if err := sm.initTradesDBs(); err != nil {
|
||||
return nil, fmt.Errorf("init trades dbs: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.Streams.Ticker.Enabled {
|
||||
if err := sm.initTickerDBs(); err != nil {
|
||||
return nil, fmt.Errorf("init ticker dbs: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.Streams.Klines.Enabled {
|
||||
if err := sm.initKlineDBs(); err != nil {
|
||||
return nil, fmt.Errorf("init kline dbs: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.Streams.Orderbook.Enabled {
|
||||
if err := sm.initOrderbookDBs(); err != nil {
|
||||
return nil, fmt.Errorf("init orderbook dbs: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.Streams.Liquidations.Enabled {
|
||||
if err := sm.initLiquidationDBs(); err != nil {
|
||||
return nil, fmt.Errorf("init liquidation dbs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set legacy paths for backward compatibility with trade-specific methods
|
||||
if ss, ok := sm.streams["trades"]; ok {
|
||||
sm.hotDBPath = ss.DBPath("hot_ticks.db")
|
||||
sm.featDBPath = ss.DBPath("features.db")
|
||||
sm.archiveDir = ss.archiveDir
|
||||
}
|
||||
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
// GetStreamStorage returns the StreamStorage for a given stream name.
|
||||
func (sm *StorageManager) GetStreamStorage(name string) *StreamStorage {
|
||||
return sm.streams[name]
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Legacy compatibility methods (used by existing trade pipeline)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
// OpenHotDB returns a new connection to the hot ticks database.
|
||||
func (sm *StorageManager) OpenHotDB() (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", sm.hotDBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
return OpenDB(sm.hotDBPath)
|
||||
}
|
||||
|
||||
// OpenFeaturesDB returns a new connection to the features database.
|
||||
func (sm *StorageManager) OpenFeaturesDB() (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", sm.featDBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
return OpenDB(sm.featDBPath)
|
||||
}
|
||||
|
||||
func (sm *StorageManager) initHotDB() error {
|
||||
db, err := sm.OpenHotDB()
|
||||
// ----------------------------------------------------------------
|
||||
// Database initialization per stream
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
func (sm *StorageManager) initTradesDBs() error {
|
||||
ss := sm.streams["trades"]
|
||||
|
||||
db, err := OpenDBWithAutoVacuum(ss.DBPath("hot_ticks.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
PRAGMA auto_vacuum=INCREMENTAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS btc_ticks (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
|
||||
@@ -114,19 +228,17 @@ func (sm *StorageManager) initHotDB() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_seq
|
||||
ON btc_ticks(seq);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sm *StorageManager) initFeaturesDB() error {
|
||||
db, err := sm.OpenFeaturesDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
PRAGMA auto_vacuum=INCREMENTAL;
|
||||
featDB, err := OpenDBWithAutoVacuum(ss.DBPath("features.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer featDB.Close()
|
||||
|
||||
_, err = featDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS five_second_features (
|
||||
timestamp INTEGER PRIMARY KEY,
|
||||
log_return REAL NOT NULL,
|
||||
@@ -140,15 +252,206 @@ func (sm *StorageManager) initFeaturesDB() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// weeklyArchivePath resolves the archive DB file path for a given timestamp.
|
||||
func (sm *StorageManager) weeklyArchivePath(timestampMs int64) string {
|
||||
t := time.UnixMilli(timestampMs)
|
||||
year, week := t.ISOWeek()
|
||||
filename := fmt.Sprintf("btc_ticks_%d_W%02d.db", year, week)
|
||||
return filepath.Join(sm.archiveDir, filename)
|
||||
func (sm *StorageManager) initTickerDBs() error {
|
||||
ss := sm.streams["ticker"]
|
||||
|
||||
db, err := OpenDBWithAutoVacuum(ss.DBPath("hot_ticker.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS ticker_snapshots (
|
||||
timestamp INTEGER NOT NULL,
|
||||
last_price REAL NOT NULL,
|
||||
bid1_price REAL NOT NULL,
|
||||
bid1_size REAL NOT NULL,
|
||||
ask1_price REAL NOT NULL,
|
||||
ask1_size REAL NOT NULL,
|
||||
mark_price REAL NOT NULL,
|
||||
index_price REAL NOT NULL,
|
||||
open_interest REAL NOT NULL,
|
||||
funding_rate REAL NOT NULL,
|
||||
volume_24h REAL NOT NULL,
|
||||
turnover_24h REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticker_ts ON ticker_snapshots(timestamp);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
featDB, err := OpenDBWithAutoVacuum(ss.DBPath("features.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer featDB.Close()
|
||||
|
||||
_, err = featDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS ticker_features (
|
||||
timestamp INTEGER PRIMARY KEY,
|
||||
spread REAL NOT NULL,
|
||||
spread_bps REAL NOT NULL,
|
||||
mid_price REAL NOT NULL,
|
||||
oi_change REAL NOT NULL,
|
||||
funding_rate REAL NOT NULL,
|
||||
mark_index_basis REAL NOT NULL,
|
||||
bid_ask_imbalance REAL NOT NULL
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// initArchiveDB ensures the archive database has the correct schema.
|
||||
func (sm *StorageManager) initKlineDBs() error {
|
||||
ss := sm.streams["klines"]
|
||||
|
||||
for _, interval := range sm.cfg.Streams.Klines.Intervals {
|
||||
dbName := fmt.Sprintf("kline_%s.db", interval)
|
||||
db, err := OpenDBWithAutoVacuum(ss.DBPath(dbName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("init kline_%s: %w", interval, err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS klines (
|
||||
start_time INTEGER PRIMARY KEY,
|
||||
end_time INTEGER NOT NULL,
|
||||
interval TEXT NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL NOT NULL,
|
||||
turnover REAL NOT NULL,
|
||||
confirmed INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
db.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
featDB, err := OpenDBWithAutoVacuum(ss.DBPath("features.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer featDB.Close()
|
||||
|
||||
_, err = featDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS kline_features (
|
||||
timestamp INTEGER NOT NULL,
|
||||
interval TEXT NOT NULL,
|
||||
body_ratio REAL NOT NULL,
|
||||
upper_wick REAL NOT NULL,
|
||||
lower_wick REAL NOT NULL,
|
||||
log_return REAL NOT NULL,
|
||||
volume REAL NOT NULL,
|
||||
turnover REAL NOT NULL,
|
||||
PRIMARY KEY (interval, timestamp)
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sm *StorageManager) initOrderbookDBs() error {
|
||||
ss := sm.streams["orderbook"]
|
||||
|
||||
db, err := OpenDBWithAutoVacuum(ss.DBPath("hot_snapshots.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS ob_snapshots (
|
||||
timestamp INTEGER NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
bid_price REAL,
|
||||
bid_size REAL,
|
||||
ask_price REAL,
|
||||
ask_size REAL,
|
||||
PRIMARY KEY (timestamp, level)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
featDB, err := OpenDBWithAutoVacuum(ss.DBPath("features.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer featDB.Close()
|
||||
|
||||
_, err = featDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS ob_features (
|
||||
timestamp INTEGER PRIMARY KEY,
|
||||
spread REAL NOT NULL,
|
||||
mid_price REAL NOT NULL,
|
||||
bid_depth_5 REAL NOT NULL,
|
||||
ask_depth_5 REAL NOT NULL,
|
||||
bid_depth_20 REAL NOT NULL,
|
||||
ask_depth_20 REAL NOT NULL,
|
||||
depth_imbalance_5 REAL NOT NULL,
|
||||
depth_imbalance_20 REAL NOT NULL,
|
||||
weighted_mid REAL NOT NULL,
|
||||
vwap_10 REAL NOT NULL
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sm *StorageManager) initLiquidationDBs() error {
|
||||
ss := sm.streams["liquidations"]
|
||||
|
||||
db, err := OpenDBWithAutoVacuum(ss.DBPath("hot_liquidations.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS liquidations (
|
||||
timestamp INTEGER NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_liq_ts ON liquidations(timestamp);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
featDB, err := OpenDBWithAutoVacuum(ss.DBPath("features.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer featDB.Close()
|
||||
|
||||
_, err = featDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS liquidation_features (
|
||||
timestamp INTEGER PRIMARY KEY,
|
||||
count_total INTEGER NOT NULL,
|
||||
count_long INTEGER NOT NULL,
|
||||
count_short INTEGER NOT NULL,
|
||||
volume_total REAL NOT NULL,
|
||||
volume_long REAL NOT NULL,
|
||||
volume_short REAL NOT NULL,
|
||||
value_total REAL NOT NULL,
|
||||
value_long REAL NOT NULL,
|
||||
value_short REAL NOT NULL,
|
||||
avg_price REAL NOT NULL,
|
||||
net_value REAL NOT NULL
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// initArchiveDB ensures an archive database has the btc_ticks schema.
|
||||
func (sm *StorageManager) initArchiveDB(path string) error {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
@@ -189,28 +492,429 @@ func (sm *StorageManager) initArchiveDB(path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// RunHourlyMaintenance performs tick migration and feature pruning.
|
||||
// initLiquidationArchiveDB ensures a liquidation archive has the correct schema.
|
||||
func (sm *StorageManager) initLiquidationArchiveDB(path string) error {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS liquidations (
|
||||
timestamp INTEGER NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_liq_ts ON liquidations(timestamp);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Data migration from old flat layout
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
// migrateOldLayout moves data from the old flat data/ layout into data/trades/.
|
||||
func (sm *StorageManager) migrateOldLayout() error {
|
||||
oldHotDB := filepath.Join(sm.cfg.DataDir, "hot_ticks.db")
|
||||
tradesDir := filepath.Join(sm.cfg.DataDir, "trades")
|
||||
|
||||
// Check if old layout exists and new doesn't
|
||||
if _, err := os.Stat(oldHotDB); err != nil {
|
||||
return nil // No old layout, nothing to migrate
|
||||
}
|
||||
if _, err := os.Stat(tradesDir); err == nil {
|
||||
return nil // New layout already exists
|
||||
}
|
||||
|
||||
log.Println("[migration] Detected old flat data layout, migrating to data/trades/...")
|
||||
|
||||
// Create trades directory
|
||||
tradesArchiveDir := filepath.Join(tradesDir, "archive")
|
||||
if err := os.MkdirAll(tradesArchiveDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move hot_ticks.db and its WAL/SHM files
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
src := oldHotDB + suffix
|
||||
dst := filepath.Join(tradesDir, "hot_ticks.db"+suffix)
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("move %s: %w", src, err)
|
||||
}
|
||||
log.Printf("[migration] Moved %s -> %s", filepath.Base(src), dst)
|
||||
}
|
||||
}
|
||||
|
||||
// Move features.db and its WAL/SHM files
|
||||
oldFeatDB := filepath.Join(sm.cfg.DataDir, "features.db")
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
src := oldFeatDB + suffix
|
||||
dst := filepath.Join(tradesDir, "features.db"+suffix)
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("move %s: %w", src, err)
|
||||
}
|
||||
log.Printf("[migration] Moved %s -> %s", filepath.Base(src), dst)
|
||||
}
|
||||
}
|
||||
|
||||
// Move archive directory contents
|
||||
oldArchiveDir := filepath.Join(sm.cfg.DataDir, "archive")
|
||||
if entries, err := os.ReadDir(oldArchiveDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
src := filepath.Join(oldArchiveDir, entry.Name())
|
||||
dst := filepath.Join(tradesArchiveDir, entry.Name())
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("move archive %s: %w", entry.Name(), err)
|
||||
}
|
||||
log.Printf("[migration] Moved archive/%s -> trades/archive/%s", entry.Name(), entry.Name())
|
||||
}
|
||||
// Remove empty old archive dir
|
||||
os.Remove(oldArchiveDir)
|
||||
}
|
||||
|
||||
log.Println("[migration] Data migration to data/trades/ completed successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Maintenance — trade stream (backward compatible)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
// weeklyArchivePath resolves the archive DB file path for a given timestamp.
|
||||
func (sm *StorageManager) weeklyArchivePath(timestampMs int64) string {
|
||||
t := time.UnixMilli(timestampMs)
|
||||
year, week := t.ISOWeek()
|
||||
filename := fmt.Sprintf("btc_ticks_%d_W%02d.db", year, week)
|
||||
return filepath.Join(sm.archiveDir, filename)
|
||||
}
|
||||
|
||||
// RunHourlyMaintenance performs maintenance across all enabled streams.
|
||||
func (sm *StorageManager) RunHourlyMaintenance() {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
log.Println("[maintenance] Starting hourly database maintenance...")
|
||||
|
||||
// Part A: Migrate raw ticks older than retention window
|
||||
cutoff12h := nowMs - int64(sm.cfg.HotRetentionHours)*60*60*1000
|
||||
if err := sm.migrateRawTicks(cutoff12h); err != nil {
|
||||
log.Printf("[maintenance] Raw tick migration failed: %v", err)
|
||||
// --- Trades ---
|
||||
if sm.cfg.Streams.Trades.Enabled {
|
||||
retHours := sm.cfg.Streams.Trades.HotRetentionHours
|
||||
if retHours == 0 {
|
||||
retHours = sm.cfg.HotRetentionHours
|
||||
}
|
||||
cutoff := nowMs - int64(retHours)*60*60*1000
|
||||
if err := sm.migrateRawTicks(cutoff); err != nil {
|
||||
log.Printf("[maintenance] Trade tick migration failed: %v", err)
|
||||
}
|
||||
|
||||
retDays := sm.cfg.Streams.Trades.FeatureRetentionDays
|
||||
if retDays == 0 {
|
||||
retDays = sm.cfg.FeatureRetentionDays
|
||||
}
|
||||
cutoffFeat := nowMs - int64(retDays)*24*60*60*1000
|
||||
if err := sm.pruneFeatures(cutoffFeat); err != nil {
|
||||
log.Printf("[maintenance] Trade feature pruning failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Part B: Prune features older than retention window
|
||||
cutoffFeatures := nowMs - int64(sm.cfg.FeatureRetentionDays)*24*60*60*1000
|
||||
if err := sm.pruneFeatures(cutoffFeatures); err != nil {
|
||||
log.Printf("[maintenance] Feature pruning failed: %v", err)
|
||||
// --- Ticker ---
|
||||
if sm.cfg.Streams.Ticker.Enabled {
|
||||
sm.maintenanceTicker(nowMs)
|
||||
}
|
||||
|
||||
// --- Klines ---
|
||||
if sm.cfg.Streams.Klines.Enabled {
|
||||
sm.maintenanceKlines(nowMs)
|
||||
}
|
||||
|
||||
// --- Orderbook ---
|
||||
if sm.cfg.Streams.Orderbook.Enabled {
|
||||
sm.maintenanceOrderbook(nowMs)
|
||||
}
|
||||
|
||||
// --- Liquidations ---
|
||||
if sm.cfg.Streams.Liquidations.Enabled {
|
||||
sm.maintenanceLiquidations(nowMs)
|
||||
}
|
||||
|
||||
log.Println("[maintenance] Hourly maintenance complete.")
|
||||
}
|
||||
|
||||
// maintenanceTicker prunes old ticker snapshots and features.
|
||||
func (sm *StorageManager) maintenanceTicker(nowMs int64) {
|
||||
ss := sm.streams["ticker"]
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Prune hot snapshots
|
||||
cutoff := nowMs - int64(sm.cfg.Streams.Ticker.HotRetentionHours)*60*60*1000
|
||||
db, err := OpenDB(ss.DBPath("hot_ticker.db"))
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] ticker hot open: %v", err)
|
||||
return
|
||||
}
|
||||
result, err := db.Exec("DELETE FROM ticker_snapshots WHERE timestamp < ?", cutoff)
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] ticker prune: %v", err)
|
||||
} else {
|
||||
deleted, _ := result.RowsAffected()
|
||||
if deleted > 0 {
|
||||
log.Printf("[maintenance] Pruned %d old ticker snapshots.", deleted)
|
||||
db.Exec("PRAGMA incremental_vacuum(100)")
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Prune features
|
||||
cutoffFeat := nowMs - int64(sm.cfg.Streams.Ticker.FeatureRetentionDays)*24*60*60*1000
|
||||
sm.pruneStreamFeatures(ss, "ticker_features", "timestamp", cutoffFeat)
|
||||
}
|
||||
|
||||
// maintenanceKlines prunes old kline data per interval.
|
||||
func (sm *StorageManager) maintenanceKlines(nowMs int64) {
|
||||
ss := sm.streams["klines"]
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, interval := range sm.cfg.Streams.Klines.Intervals {
|
||||
dbName := fmt.Sprintf("kline_%s.db", interval)
|
||||
db, err := OpenDB(ss.DBPath(dbName))
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] kline_%s open: %v", interval, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if interval == "60" {
|
||||
// 60m klines: weekly archive rotation
|
||||
cutoff := nowMs - int64(sm.cfg.Streams.Klines.LongRetentionWeeks)*7*24*60*60*1000
|
||||
sm.migrateKlinesToArchive(db, ss, interval, cutoff)
|
||||
} else {
|
||||
// Short klines (5m, 15m): simple deletion after retention
|
||||
cutoff := nowMs - int64(sm.cfg.Streams.Klines.ShortRetentionHours)*60*60*1000
|
||||
result, err := db.Exec("DELETE FROM klines WHERE start_time < ?", cutoff)
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] kline_%s prune: %v", interval, err)
|
||||
} else {
|
||||
deleted, _ := result.RowsAffected()
|
||||
if deleted > 0 {
|
||||
log.Printf("[maintenance] Pruned %d old kline_%s rows.", deleted, interval)
|
||||
db.Exec("PRAGMA incremental_vacuum(100)")
|
||||
}
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
|
||||
// Prune kline features
|
||||
cutoffFeat := nowMs - int64(sm.cfg.Streams.Klines.FeatureRetentionDays)*24*60*60*1000
|
||||
sm.pruneStreamFeatures(ss, "kline_features", "timestamp", cutoffFeat)
|
||||
}
|
||||
|
||||
// migrateKlinesToArchive archives 60m kline data into weekly files.
|
||||
func (sm *StorageManager) migrateKlinesToArchive(db *sql.DB, ss *StreamStorage, interval string, cutoffMs int64) {
|
||||
var minTS, maxTS sql.NullInt64
|
||||
err := db.QueryRow("SELECT MIN(start_time), MAX(start_time) FROM klines WHERE start_time < ?", cutoffMs).Scan(&minTS, &maxTS)
|
||||
if err != nil || !minTS.Valid {
|
||||
return
|
||||
}
|
||||
|
||||
currentTs := minTS.Int64
|
||||
for currentTs <= maxTS.Int64 {
|
||||
archivePath := ss.ArchivePath(fmt.Sprintf("kline_%s", interval), currentTs)
|
||||
nextWeekStart := sm.nextISOWeekStartMs(currentTs)
|
||||
chunkEnd := nextWeekStart
|
||||
if cutoffMs < chunkEnd {
|
||||
chunkEnd = cutoffMs
|
||||
}
|
||||
|
||||
// Initialize archive
|
||||
adb, err := sql.Open("sqlite", archivePath)
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] kline archive open: %v", err)
|
||||
break
|
||||
}
|
||||
adb.Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
adb.Exec(`CREATE TABLE IF NOT EXISTS klines (
|
||||
start_time INTEGER PRIMARY KEY, end_time INTEGER NOT NULL,
|
||||
interval TEXT NOT NULL, open REAL NOT NULL, high REAL NOT NULL,
|
||||
low REAL NOT NULL, close REAL NOT NULL, volume REAL NOT NULL,
|
||||
turnover REAL NOT NULL, confirmed INTEGER NOT NULL
|
||||
)`)
|
||||
adb.Close()
|
||||
|
||||
// ATTACH and migrate atomically
|
||||
_, err = db.Exec("ATTACH DATABASE ? AS archive", archivePath)
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] kline attach: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
db.Exec("DETACH DATABASE archive")
|
||||
break
|
||||
}
|
||||
|
||||
tx.Exec(`INSERT OR IGNORE INTO archive.klines SELECT * FROM main.klines WHERE start_time >= ? AND start_time < ?`, currentTs, chunkEnd)
|
||||
result, _ := tx.Exec(`DELETE FROM main.klines WHERE start_time >= ? AND start_time < ?`, currentTs, chunkEnd)
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[maintenance] kline archive commit: %v", err)
|
||||
} else {
|
||||
migrated, _ := result.RowsAffected()
|
||||
log.Printf("[maintenance] Archived %d kline_%s rows.", migrated, interval)
|
||||
}
|
||||
|
||||
db.Exec("DETACH DATABASE archive")
|
||||
currentTs = nextWeekStart
|
||||
}
|
||||
|
||||
db.Exec("PRAGMA incremental_vacuum(100)")
|
||||
}
|
||||
|
||||
// maintenanceOrderbook prunes old orderbook snapshots and features.
|
||||
func (sm *StorageManager) maintenanceOrderbook(nowMs int64) {
|
||||
ss := sm.streams["orderbook"]
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := nowMs - int64(sm.cfg.Streams.Orderbook.HotRetentionHours)*60*60*1000
|
||||
db, err := OpenDB(ss.DBPath("hot_snapshots.db"))
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] orderbook hot open: %v", err)
|
||||
return
|
||||
}
|
||||
result, err := db.Exec("DELETE FROM ob_snapshots WHERE timestamp < ?", cutoff)
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] orderbook prune: %v", err)
|
||||
} else {
|
||||
deleted, _ := result.RowsAffected()
|
||||
if deleted > 0 {
|
||||
log.Printf("[maintenance] Pruned %d old orderbook snapshot rows.", deleted)
|
||||
db.Exec("PRAGMA incremental_vacuum(500)")
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
cutoffFeat := nowMs - int64(sm.cfg.Streams.Orderbook.FeatureRetentionDays)*24*60*60*1000
|
||||
sm.pruneStreamFeatures(ss, "ob_features", "timestamp", cutoffFeat)
|
||||
}
|
||||
|
||||
// maintenanceLiquidations prunes and archives old liquidation data.
|
||||
func (sm *StorageManager) maintenanceLiquidations(nowMs int64) {
|
||||
ss := sm.streams["liquidations"]
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Migrate old liquidations to weekly archives (like trades)
|
||||
cutoff := nowMs - int64(sm.cfg.Streams.Liquidations.HotRetentionHours)*60*60*1000
|
||||
if err := sm.migrateLiquidations(cutoff); err != nil {
|
||||
log.Printf("[maintenance] liquidation migration: %v", err)
|
||||
}
|
||||
|
||||
// Prune features
|
||||
cutoffFeat := nowMs - int64(sm.cfg.Streams.Liquidations.FeatureRetentionDays)*24*60*60*1000
|
||||
sm.pruneStreamFeatures(ss, "liquidation_features", "timestamp", cutoffFeat)
|
||||
}
|
||||
|
||||
// migrateLiquidations archives old liquidation events to weekly files.
|
||||
func (sm *StorageManager) migrateLiquidations(cutoffMs int64) error {
|
||||
ss := sm.streams["liquidations"]
|
||||
db, err := OpenDB(ss.DBPath("hot_liquidations.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var minTS, maxTS sql.NullInt64
|
||||
err = db.QueryRow("SELECT MIN(timestamp), MAX(timestamp) FROM liquidations WHERE timestamp < ?", cutoffMs).Scan(&minTS, &maxTS)
|
||||
if err != nil || !minTS.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentTs := minTS.Int64
|
||||
for currentTs <= maxTS.Int64 {
|
||||
archivePath := ss.ArchivePath("liquidations", currentTs)
|
||||
nextWeekStart := sm.nextISOWeekStartMs(currentTs)
|
||||
chunkEnd := nextWeekStart
|
||||
if cutoffMs < chunkEnd {
|
||||
chunkEnd = cutoffMs
|
||||
}
|
||||
|
||||
if err := sm.initLiquidationArchiveDB(archivePath); err != nil {
|
||||
return fmt.Errorf("init liq archive: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec("ATTACH DATABASE ? AS archive", archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attach liq archive: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
db.Exec("DETACH DATABASE archive")
|
||||
return err
|
||||
}
|
||||
|
||||
tx.Exec(`INSERT OR IGNORE INTO archive.liquidations SELECT * FROM main.liquidations WHERE timestamp >= ? AND timestamp < ?`, currentTs, chunkEnd)
|
||||
result, _ := tx.Exec(`DELETE FROM main.liquidations WHERE timestamp >= ? AND timestamp < ?`, currentTs, chunkEnd)
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[maintenance] liq archive commit: %v", err)
|
||||
} else {
|
||||
migrated, _ := result.RowsAffected()
|
||||
log.Printf("[maintenance] Archived %d liquidation events.", migrated)
|
||||
}
|
||||
|
||||
db.Exec("DETACH DATABASE archive")
|
||||
currentTs = nextWeekStart
|
||||
}
|
||||
|
||||
db.Exec("PRAGMA incremental_vacuum(100)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneStreamFeatures deletes old rows from a stream's features DB.
|
||||
func (sm *StorageManager) pruneStreamFeatures(ss *StreamStorage, tableName, tsColumn string, cutoffMs int64) {
|
||||
db, err := OpenDB(ss.DBPath("features.db"))
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] %s features open: %v", ss.streamName, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s < ?", tableName, tsColumn)
|
||||
result, err := db.Exec(query, cutoffMs)
|
||||
if err != nil {
|
||||
log.Printf("[maintenance] %s feature prune: %v", ss.streamName, err)
|
||||
return
|
||||
}
|
||||
|
||||
deleted, _ := result.RowsAffected()
|
||||
if deleted > 0 {
|
||||
log.Printf("[maintenance] Pruned %d old %s feature rows.", deleted, ss.streamName)
|
||||
db.Exec("PRAGMA incremental_vacuum(100)")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Startup recovery (trades stream)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
// StartupRecovery checks for stale ticks and migrates them before normal operation.
|
||||
func (sm *StorageManager) StartupRecovery() error {
|
||||
if !sm.cfg.Streams.Trades.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[startup] Checking for stale ticks in hot database...")
|
||||
|
||||
db, err := sm.OpenHotDB()
|
||||
@@ -219,7 +923,12 @@ func (sm *StorageManager) StartupRecovery() error {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cutoffMs := time.Now().UnixMilli() - int64(sm.cfg.HotRetentionHours)*60*60*1000
|
||||
retHours := sm.cfg.Streams.Trades.HotRetentionHours
|
||||
if retHours == 0 {
|
||||
retHours = sm.cfg.HotRetentionHours
|
||||
}
|
||||
cutoffMs := time.Now().UnixMilli() - int64(retHours)*60*60*1000
|
||||
|
||||
var count int64
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM btc_ticks WHERE trade_ts < ?", cutoffMs).Scan(&count)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user