Save all data available from the ws stream.

This commit is contained in:
Kalzu Rekku
2026-07-16 21:16:43 +03:00
parent e22dc05352
commit 7579c68524
6 changed files with 248 additions and 49 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ func (a *Aggregator) ProcessTick(tick Tick) {
defer a.mu.Unlock() defer a.mu.Unlock()
// Determine which 5-second bucket this tick belongs to // Determine which 5-second bucket this tick belongs to
tickBucket := (tick.Timestamp / bucketDurationMs) * bucketDurationMs tickBucket := (tick.TradeTS / bucketDurationMs) * bucketDurationMs
if a.currentBucket == 0 { if a.currentBucket == 0 {
// First tick ever — initialize the bucket // First tick ever — initialize the bucket
+31
View File
@@ -22,6 +22,37 @@ func main() {
log.Printf("Config: symbol=%s, hot_retention=%dh, feature_retention=%dd", log.Printf("Config: symbol=%s, hot_retention=%dh, feature_retention=%dd",
cfg.Symbol, cfg.HotRetentionHours, cfg.FeatureRetentionDays) cfg.Symbol, cfg.HotRetentionHours, cfg.FeatureRetentionDays)
if len(os.Args) > 1 {
switch os.Args[1] {
case "recover":
sm, err := NewStorageManager(cfg)
if err != nil {
log.Fatal(err)
}
if err := sm.StartupRecovery(); err != nil {
log.Fatal(err)
}
log.Println("Recovery completed.")
return
case "maintain":
sm, err := NewStorageManager(cfg)
if err != nil {
log.Fatal(err)
}
sm.RunHourlyMaintenance()
log.Println("Maintenance completed.")
return
}
}
// Initialize storage (creates dirs, databases, tables) // Initialize storage (creates dirs, databases, tables)
sm, err := NewStorageManager(cfg) sm, err := NewStorageManager(cfg)
if err != nil { if err != nil {
+72 -13
View File
@@ -89,13 +89,30 @@ func (sm *StorageManager) initHotDB() error {
PRAGMA auto_vacuum=INCREMENTAL; PRAGMA auto_vacuum=INCREMENTAL;
CREATE TABLE IF NOT EXISTS btc_ticks ( CREATE TABLE IF NOT EXISTS btc_ticks (
timestamp INTEGER NOT NULL, seq INTEGER PRIMARY KEY,
trade_id TEXT NOT NULL,
trade_ts INTEGER NOT NULL,
message_ts INTEGER NOT NULL,
recv_ts INTEGER NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
price REAL NOT NULL, price REAL NOT NULL,
volume REAL NOT NULL, volume REAL NOT NULL,
side TEXT NOT NULL
tick_dir TEXT,
block_trade INTEGER NOT NULL,
rpi INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_timestamp ON btc_ticks(timestamp); CREATE INDEX IF NOT EXISTS idx_trade_ts
ON btc_ticks(trade_ts);
CREATE INDEX IF NOT EXISTS idx_seq
ON btc_ticks(seq);
`) `)
return err return err
} }
@@ -144,13 +161,30 @@ func (sm *StorageManager) initArchiveDB(path string) error {
PRAGMA synchronous=NORMAL; PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS btc_ticks ( CREATE TABLE IF NOT EXISTS btc_ticks (
timestamp INTEGER NOT NULL, seq INTEGER PRIMARY KEY,
trade_id TEXT NOT NULL,
trade_ts INTEGER NOT NULL,
message_ts INTEGER NOT NULL,
recv_ts INTEGER NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
price REAL NOT NULL, price REAL NOT NULL,
volume REAL NOT NULL, volume REAL NOT NULL,
side TEXT NOT NULL
tick_dir TEXT,
block_trade INTEGER NOT NULL,
rpi INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_timestamp ON btc_ticks(timestamp); CREATE INDEX IF NOT EXISTS idx_trade_ts
ON btc_ticks(trade_ts);
CREATE INDEX IF NOT EXISTS idx_seq
ON btc_ticks(seq);
`) `)
return err return err
} }
@@ -187,7 +221,7 @@ func (sm *StorageManager) StartupRecovery() error {
cutoffMs := time.Now().UnixMilli() - int64(sm.cfg.HotRetentionHours)*60*60*1000 cutoffMs := time.Now().UnixMilli() - int64(sm.cfg.HotRetentionHours)*60*60*1000
var count int64 var count int64
err = db.QueryRow("SELECT COUNT(*) FROM btc_ticks WHERE timestamp < ?", cutoffMs).Scan(&count) err = db.QueryRow("SELECT COUNT(*) FROM btc_ticks WHERE trade_ts < ?", cutoffMs).Scan(&count)
if err != nil { if err != nil {
return fmt.Errorf("count stale ticks: %w", err) return fmt.Errorf("count stale ticks: %w", err)
} }
@@ -214,9 +248,9 @@ func (sm *StorageManager) migrateRawTicks(cutoffMs int64) error {
// Find the range of ticks that need migration // Find the range of ticks that need migration
var minTS, maxTS sql.NullInt64 var minTS, maxTS sql.NullInt64
err = db.QueryRow(` err = db.QueryRow(`
SELECT MIN(timestamp), MAX(timestamp) SELECT MIN(trade_ts), MAX(trade_ts)
FROM btc_ticks FROM btc_ticks
WHERE timestamp < ? WHERE trade_ts < ?
`, cutoffMs).Scan(&minTS, &maxTS) `, cutoffMs).Scan(&minTS, &maxTS)
if err != nil { if err != nil {
return fmt.Errorf("query migration range: %w", err) return fmt.Errorf("query migration range: %w", err)
@@ -296,10 +330,35 @@ func (sm *StorageManager) atomicMigrate(hotDB *sql.DB, archivePath string, fromM
// Insert into archive // Insert into archive
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO archive.btc_ticks (timestamp, price, volume, side) INSERT OR IGNORE INTO archive.btc_ticks (
SELECT timestamp, price, volume, side seq,
trade_id,
trade_ts,
message_ts,
recv_ts,
symbol,
side,
price,
volume,
tick_dir,
block_trade,
rpi
)
SELECT
seq,
trade_id,
trade_ts,
message_ts,
recv_ts,
symbol,
side,
price,
volume,
tick_dir,
block_trade,
rpi
FROM main.btc_ticks FROM main.btc_ticks
WHERE timestamp >= ? AND timestamp < ? WHERE trade_ts >= ? AND trade_ts < ?
`, fromMs, toMs) `, fromMs, toMs)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
@@ -309,7 +368,7 @@ func (sm *StorageManager) atomicMigrate(hotDB *sql.DB, archivePath string, fromM
// Delete from hot // Delete from hot
result, err := tx.Exec(` result, err := tx.Exec(`
DELETE FROM main.btc_ticks DELETE FROM main.btc_ticks
WHERE timestamp >= ? AND timestamp < ? WHERE trade_ts >= ? AND trade_ts < ?
`, fromMs, toMs) `, fromMs, toMs)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
+47 -22
View File
@@ -1,25 +1,41 @@
package main package main
// Tick represents a single raw trade event from the Bybit WebSocket. // Tick represents a single trade event persisted into hot storage.
type Tick struct { type Tick struct {
Timestamp int64 // Epoch millisecond timestamp // Exchange identifiers
Price float64 // Transacted trade price TradeID string
Volume float64 // Trade quantity Seq int64
Side string // "Buy" or "Sell"
// Timing
TradeTS int64 // Bybit trade timestamp (T)
MessageTS int64
RecvTS int64 // Local receive timestamp
// Trade data
Symbol string
Side string
Price float64
Volume float64
// Exchange metadata
TickDir string // L
BlockTrade bool // BT
RPI bool // RPI
} }
// FeatureBucket holds aggregated 5-second feature data ready for insertion into features.db.
// FeatureBucket holds aggregated 5-second feature data.
type FeatureBucket struct { type FeatureBucket struct {
Timestamp int64 // Epoch millisecond (start of 5s bucket) Timestamp int64
LogReturn float64 // ln(Price_end / Price_start) LogReturn float64
RealizedVol float64 // Volatility of ticks inside the bucket RealizedVol float64
OFI float64 // Net volume (Buy volume - Sell volume) OFI float64
VolumeSum float64 // Total volume exchanged VolumeSum float64
ClosePrice float64 // Final transaction price in the bucket ClosePrice float64
VWAP float64 // Volume-Weighted Average Price in the bucket VWAP float64
} }
// BybitWSMessage represents the top-level WebSocket message from Bybit V5 publicTrade. // Top-level websocket message.
type BybitWSMessage struct { type BybitWSMessage struct {
Topic string `json:"topic"` Topic string `json:"topic"`
Type string `json:"type"` Type string `json:"type"`
@@ -27,13 +43,22 @@ type BybitWSMessage struct {
Data []BybitTradeRaw `json:"data"` Data []BybitTradeRaw `json:"data"`
} }
// BybitTradeRaw represents a single trade object within the Bybit WebSocket data array. // Raw trade from Bybit publicTrade stream.
// Price and Volume arrive as strings from the API and need conversion.
type BybitTradeRaw struct { type BybitTradeRaw struct {
T int64 `json:"T"` // Timestamp (ms) that the order is filled T int64 `json:"T"`
S string `json:"s"` // Symbol name
SD string `json:"S"` // Side of taker: "Buy" or "Sell" S string `json:"s"`
V string `json:"v"` // Trade size (string) SD string `json:"S"`
P string `json:"p"` // Trade price (string)
I string `json:"i"` // Trade ID V string `json:"v"`
P string `json:"p"`
L string `json:"L"`
I string `json:"i"`
BT bool `json:"BT"`
RPI bool `json:"RPI"`
Seq int64 `json:"seq"`
} }
+26 -7
View File
@@ -101,22 +101,26 @@ func (ing *Ingestor) connectAndConsume(ctx context.Context) error {
func (ing *Ingestor) handleMessage(data []byte) { func (ing *Ingestor) handleMessage(data []byte) {
var msg BybitWSMessage var msg BybitWSMessage
if err := json.Unmarshal(data, &msg); err != nil { if err := json.Unmarshal(data, &msg); err != nil {
// Could be a subscription confirmation or ping/pong — ignore // subscription confirmations, pings, etc.
return return
} }
// Only process trade data messages if len(msg.Data) == 0 {
if msg.Data == nil || len(msg.Data) == 0 {
return return
} }
recvTS := time.Now().UnixMilli()
for _, raw := range msg.Data { for _, raw := range msg.Data {
price, err := strconv.ParseFloat(raw.P, 64) price, err := strconv.ParseFloat(raw.P, 64)
if err != nil { if err != nil {
log.Printf("[ingestor] bad price %q: %v", raw.P, err) log.Printf("[ingestor] bad price %q: %v", raw.P, err)
continue continue
} }
volume, err := strconv.ParseFloat(raw.V, 64) volume, err := strconv.ParseFloat(raw.V, 64)
if err != nil { if err != nil {
log.Printf("[ingestor] bad volume %q: %v", raw.V, err) log.Printf("[ingestor] bad volume %q: %v", raw.V, err)
@@ -124,16 +128,31 @@ func (ing *Ingestor) handleMessage(data []byte) {
} }
tick := Tick{ tick := Tick{
Timestamp: raw.T, // IDs
TradeID: raw.I,
Seq: raw.Seq,
// Timing
TradeTS: raw.T,
MessageTS: msg.TS,
RecvTS: recvTS,
// Trade data
Symbol: raw.S,
Side: raw.SD,
Price: price, Price: price,
Volume: volume, Volume: volume,
Side: raw.SD,
// Metadata
TickDir: raw.L,
BlockTrade: raw.BT,
RPI: raw.RPI,
} }
// Feed to aggregator (5s feature bucketing) inline // Feed feature generator
ing.aggregator.ProcessTick(tick) ing.aggregator.ProcessTick(tick)
// Send to writer channel (non-blocking drop if channel full) // Feed writer
select { select {
case ing.tickCh <- tick: case ing.tickCh <- tick:
default: default:
+67 -2
View File
@@ -7,12 +7,31 @@ import (
"time" "time"
) )
const insertTickSQL = `
INSERT OR IGNORE INTO btc_ticks (
seq,
trade_id,
trade_ts,
message_ts,
recv_ts,
symbol,
side,
price,
volume,
tick_dir,
block_trade,
rpi
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
// Writer drains the tick channel and batch-writes to hot_ticks.db. // Writer drains the tick channel and batch-writes to hot_ticks.db.
type Writer struct { type Writer struct {
tickCh <-chan Tick tickCh <-chan Tick
hotDB *sql.DB hotDB *sql.DB
batchSize int batchSize int
flushMs int flushMs int
lastSeq int64
} }
// NewWriter creates a Writer with its own hot DB connection. // NewWriter creates a Writer with its own hot DB connection.
@@ -76,12 +95,30 @@ func (w *Writer) flush(batch []Tick) {
if len(batch) == 0 { if len(batch) == 0 {
return return
} }
tx, err := w.hotDB.Begin() tx, err := w.hotDB.Begin()
if err != nil { if err != nil {
log.Printf("[writer] begin tx failed: %v", err) log.Printf("[writer] begin tx failed: %v", err)
return return
} }
stmt, err := tx.Prepare("INSERT INTO btc_ticks (timestamp, price, volume, side) VALUES (?, ?, ?, ?)")
stmt, err := tx.Prepare(`
INSERT OR IGNORE INTO btc_ticks (
seq,
trade_id,
trade_ts,
message_ts,
recv_ts,
symbol,
side,
price,
volume,
tick_dir,
block_trade,
rpi
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil { if err != nil {
log.Printf("[writer] prepare failed: %v", err) log.Printf("[writer] prepare failed: %v", err)
tx.Rollback() tx.Rollback()
@@ -90,15 +127,43 @@ func (w *Writer) flush(batch []Tick) {
defer stmt.Close() defer stmt.Close()
for _, t := range batch { for _, t := range batch {
if _, err := stmt.Exec(t.Timestamp, t.Price, t.Volume, t.Side); err != nil {
if t.Seq < w.lastSeq {
log.Printf(
"[writer] OUT OF ORDER SEQ: prev=%d current=%d",
w.lastSeq,
t.Seq,
)
}
w.lastSeq = t.Seq
_, err := stmt.Exec(
t.Seq,
t.TradeID,
t.TradeTS,
t.MessageTS,
t.RecvTS,
t.Symbol,
t.Side,
t.Price,
t.Volume,
t.TickDir,
t.BlockTrade,
t.RPI,
)
if err != nil {
log.Printf("[writer] insert failed: %v", err) log.Printf("[writer] insert failed: %v", err)
tx.Rollback() tx.Rollback()
return return
} }
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Printf("[writer] commit failed: %v", err) log.Printf("[writer] commit failed: %v", err)
return return
} }
log.Printf("[writer] Flushed %d ticks to hot_ticks.db", len(batch)) log.Printf("[writer] Flushed %d ticks to hot_ticks.db", len(batch))
} }