215 lines
5.4 KiB
Go
215 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// TickerHandler processes Bybit tickers stream data, snapshotting to hot_ticker.db and computing features for features.db.
|
|
type TickerHandler struct {
|
|
cfg Config
|
|
storage *StreamStorage
|
|
mu sync.Mutex
|
|
latest TickerSnapshot
|
|
hasData bool
|
|
prevOI float64
|
|
hasPrevOI bool
|
|
hotDB *sql.DB
|
|
featDB *sql.DB
|
|
stopChan chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func NewTickerHandler(cfg Config, sm *StorageManager) (*TickerHandler, error) {
|
|
ss := sm.GetStreamStorage("ticker")
|
|
if ss == nil {
|
|
return nil, fmt.Errorf("ticker stream storage not found")
|
|
}
|
|
|
|
hotDB, err := OpenDBWithAutoVacuum(ss.DBPath("hot_ticker.db"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open hot_ticker db: %w", err)
|
|
}
|
|
|
|
featDB, err := OpenDBWithAutoVacuum(ss.DBPath("features.db"))
|
|
if err != nil {
|
|
hotDB.Close()
|
|
return nil, fmt.Errorf("open ticker features db: %w", err)
|
|
}
|
|
|
|
th := &TickerHandler{
|
|
cfg: cfg,
|
|
storage: ss,
|
|
hotDB: hotDB,
|
|
featDB: featDB,
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
// Load last open interest for continuity
|
|
var lastOI float64
|
|
err = featDB.QueryRow("SELECT open_interest FROM ticker_snapshots ORDER BY timestamp DESC LIMIT 1").Scan(&lastOI)
|
|
if err == nil {
|
|
th.prevOI = lastOI
|
|
th.hasPrevOI = true
|
|
}
|
|
|
|
// Start periodic snapshot worker
|
|
interval := time.Duration(cfg.Streams.Ticker.SnapshotIntervalMs) * time.Millisecond
|
|
if interval <= 0 {
|
|
interval = 5 * time.Second
|
|
}
|
|
|
|
th.wg.Add(1)
|
|
go th.runSnapshotLoop(interval)
|
|
|
|
return th, nil
|
|
}
|
|
|
|
func (th *TickerHandler) Topics() []string {
|
|
return []string{fmt.Sprintf("tickers.%s", th.cfg.Symbol)}
|
|
}
|
|
|
|
func (th *TickerHandler) HandleMessage(data []byte) {
|
|
var msg BybitTickerMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return
|
|
}
|
|
|
|
raw := msg.Data
|
|
if raw.Symbol == "" && th.cfg.Symbol != "" {
|
|
raw.Symbol = th.cfg.Symbol
|
|
}
|
|
|
|
th.mu.Lock()
|
|
defer th.mu.Unlock()
|
|
|
|
// Update existing state with non-empty delta fields
|
|
if p, err := strconv.ParseFloat(raw.LastPrice, 64); err == nil && p > 0 {
|
|
th.latest.LastPrice = p
|
|
}
|
|
if p, err := strconv.ParseFloat(raw.Bid1Price, 64); err == nil && p > 0 {
|
|
th.latest.Bid1Price = p
|
|
}
|
|
if s, err := strconv.ParseFloat(raw.Bid1Size, 64); err == nil && s >= 0 {
|
|
th.latest.Bid1Size = s
|
|
}
|
|
if p, err := strconv.ParseFloat(raw.Ask1Price, 64); err == nil && p > 0 {
|
|
th.latest.Ask1Price = p
|
|
}
|
|
if s, err := strconv.ParseFloat(raw.Ask1Size, 64); err == nil && s >= 0 {
|
|
th.latest.Ask1Size = s
|
|
}
|
|
if p, err := strconv.ParseFloat(raw.MarkPrice, 64); err == nil && p > 0 {
|
|
th.latest.MarkPrice = p
|
|
}
|
|
if p, err := strconv.ParseFloat(raw.IndexPrice, 64); err == nil && p > 0 {
|
|
th.latest.IndexPrice = p
|
|
}
|
|
if oi, err := strconv.ParseFloat(raw.OpenInterest, 64); err == nil && oi >= 0 {
|
|
th.latest.OpenInterest = oi
|
|
}
|
|
if fr, err := strconv.ParseFloat(raw.FundingRate, 64); err == nil {
|
|
th.latest.FundingRate = fr
|
|
}
|
|
if v, err := strconv.ParseFloat(raw.Volume24h, 64); err == nil && v >= 0 {
|
|
th.latest.Volume24h = v
|
|
}
|
|
if t, err := strconv.ParseFloat(raw.Turnover24h, 64); err == nil && t >= 0 {
|
|
th.latest.Turnover24h = t
|
|
}
|
|
th.latest.Timestamp = msg.TS
|
|
if th.latest.Timestamp == 0 {
|
|
th.latest.Timestamp = time.Now().UnixMilli()
|
|
}
|
|
th.hasData = true
|
|
}
|
|
|
|
func (th *TickerHandler) runSnapshotLoop(interval time.Duration) {
|
|
defer th.wg.Done()
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-th.stopChan:
|
|
th.takeSnapshot()
|
|
return
|
|
case <-ticker.C:
|
|
th.takeSnapshot()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (th *TickerHandler) takeSnapshot() {
|
|
th.mu.Lock()
|
|
if !th.hasData {
|
|
th.mu.Unlock()
|
|
return
|
|
}
|
|
snap := th.latest
|
|
prevOI := th.prevOI
|
|
hasPrevOI := th.hasPrevOI
|
|
th.prevOI = snap.OpenInterest
|
|
th.hasPrevOI = true
|
|
th.mu.Unlock()
|
|
|
|
ts := (snap.Timestamp / 5000) * 5000
|
|
|
|
// Write snapshot to hot DB
|
|
_, err := th.hotDB.Exec(`
|
|
INSERT INTO ticker_snapshots (
|
|
timestamp, last_price, bid1_price, bid1_size, ask1_price, ask1_size,
|
|
mark_price, index_price, open_interest, funding_rate, volume_24h, turnover_24h
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ts, snap.LastPrice, snap.Bid1Price, snap.Bid1Size, snap.Ask1Price, snap.Ask1Size,
|
|
snap.MarkPrice, snap.IndexPrice, snap.OpenInterest, snap.FundingRate, snap.Volume24h, snap.Turnover24h)
|
|
if err != nil {
|
|
log.Printf("[ticker_handler] hot db insert error: %v", err)
|
|
}
|
|
|
|
// Calculate and write feature
|
|
spread := snap.Ask1Price - snap.Bid1Price
|
|
midPrice := (snap.Bid1Price + snap.Ask1Price) / 2.0
|
|
spreadBps := 0.0
|
|
if midPrice > 0 {
|
|
spreadBps = (spread / midPrice) * 10000.0
|
|
}
|
|
|
|
oiChange := 0.0
|
|
if hasPrevOI {
|
|
oiChange = snap.OpenInterest - prevOI
|
|
}
|
|
|
|
markIndexBasis := snap.MarkPrice - snap.IndexPrice
|
|
totalSize := snap.Bid1Size + snap.Ask1Size
|
|
bidAskImbalance := 0.0
|
|
if totalSize > 0 {
|
|
bidAskImbalance = snap.Bid1Size / totalSize
|
|
}
|
|
|
|
_, err = th.featDB.Exec(`
|
|
INSERT OR IGNORE INTO ticker_features (
|
|
timestamp, spread, spread_bps, mid_price, oi_change, funding_rate, mark_index_basis, bid_ask_imbalance
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ts, spread, spreadBps, midPrice, oiChange, snap.FundingRate, markIndexBasis, bidAskImbalance)
|
|
if err != nil {
|
|
log.Printf("[ticker_handler] features db insert error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (th *TickerHandler) Close() {
|
|
close(th.stopChan)
|
|
th.wg.Wait()
|
|
if th.hotDB != nil {
|
|
th.hotDB.Close()
|
|
}
|
|
if th.featDB != nil {
|
|
th.featDB.Close()
|
|
}
|
|
}
|