Files
zeekayandHanzo Dev 10a084c0b2 refactor(dex): fold pkg/lx into pkg/dex — one matcher, one package
The matching engine (pkg/lx, 45.5K LOC incl. tests) and the deterministic
core layered on it (pkg/dex) are one matcher spread across two packages
(dex imported lx in 12 files; lx imported neither). Fold lx into dex and
drop the "lx" name entirely — pkg/dex is now the single matcher package.

Pure rename/fold, ZERO logic change. Behavior proven identical: the merged
pkg/dex runs 457 top-level tests (431 former-lx + 26 former-dex) and 489
test funcs (468 + 21), all green — matching, batch (BatchMatchEqualsSequential,
run-to-run determinism, tile boundary), GPU/CPU parity, truncation, and CL
swap math all pass unchanged.

What moved:
- git mv all 101 pkg/lx/*.go + 1 .c into pkg/dex/ (package lx -> package dex).
- lx's swap_test.go -> swap_math_test.go (only filename collision with dex's
  existing swap_test.go).
- Rewrote imports repo-wide: 54 pkg/lx import lines removed; 42 external
  importers (api, client, consensus, dchain, dpdk, lxgpu, marketdata, test/,
  tests/) repointed to pkg/dex with lx.X -> dex.X; the 12 in-package dex files
  had lx.X -> bare X. 717 selector lines rewritten. Stale pkg/lx comment refs
  updated (pkg/lxgpu left intact).

Collisions resolved (2, both package-level vars):
- ErrNoLiquidity: lx's pool-level error (dexError, used only in swap.go, never
  referenced via qualifier) renamed ErrPoolNoLiquidity to match the existing
  ErrPool* convention; dex's router-level ErrNoLiquidity (public, has errors.Is
  tests) kept.
- priceMultiplierBig: identical after fold (both big.NewInt(PriceMultiplier));
  deduped by deleting dex's copy.

Not folded — pkg/orderbook: it is NOT the GPU seam (that is orderbook_gpu.go,
which lives in the engine and folded in here). pkg/orderbook is a zero-importer,
placeholder-cgo island whose 13 core domain identifiers (Order, OrderBook, Trade,
OrderType, OrderStatus, PriceLevel, Buy, Sell, Market, Limit, Filled,
PartiallyFilled, NewOrderBook) collide incompatibly with the engine's. Folding
it verbatim breaks the build; folding via rename reintroduces the exact parallel
duplication this fold removes. Left for owner decision (recommend deletion as
dead code) — see refactor notes.

Follow-on (separate repos, not touched): 19 files under luxfi/dex import
pkg/lx externally — lux/precompile/dex, lux/precompile-dex/dex,
lux/consensus/examples/01-simple-bridge, lux/_consensus-v12537/examples,
lux/cex/pkg/engine — repoint to pkg/dex after this lands.

Verified: CGO_ENABLED=0 GOWORK=off go build ./... clean; go vet ./... clean;
go test ./... -count=1 all green (22 ok / 0 fail).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-02 00:57:08 -07:00

582 lines
13 KiB
Go

// Package marketdata provides market data aggregation and OHLCV generation
package marketdata
import (
"context"
"encoding/json"
"fmt"
"math"
"sync"
"time"
"github.com/luxfi/database"
"github.com/luxfi/dex/pkg/dex"
"github.com/luxfi/log"
)
// Aggregator collects trades and generates OHLCV data
type Aggregator struct {
logger log.Logger
db database.Database
// Candle storage by symbol and interval
candles map[string]map[Interval]*Candle
candlesMu sync.RWMutex
// Trade buffer
trades []*dex.Trade
tradesMu sync.Mutex
// Subscribers
subscribers map[string][]chan *Candle
subMu sync.RWMutex
// Stats
totalTrades uint64
totalCandles uint64
// Lifecycle
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// Candle represents OHLCV data
type Candle struct {
Symbol string `json:"symbol"`
Interval Interval `json:"interval"`
OpenTime time.Time `json:"openTime"`
CloseTime time.Time `json:"closeTime"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
Trades int `json:"trades"`
Complete bool `json:"complete"`
}
// Interval represents a time interval for candles
type Interval string
const (
Interval1s Interval = "1s"
Interval5s Interval = "5s"
Interval15s Interval = "15s"
Interval30s Interval = "30s"
Interval1m Interval = "1m"
Interval3m Interval = "3m"
Interval5m Interval = "5m"
Interval15m Interval = "15m"
Interval30m Interval = "30m"
Interval1h Interval = "1h"
Interval2h Interval = "2h"
Interval4h Interval = "4h"
Interval6h Interval = "6h"
Interval8h Interval = "8h"
Interval12h Interval = "12h"
Interval1d Interval = "1d"
Interval3d Interval = "3d"
Interval1w Interval = "1w"
Interval1M Interval = "1M"
)
// Duration returns the time.Duration for an interval
func (i Interval) Duration() time.Duration {
switch i {
case Interval1s:
return 1 * time.Second
case Interval5s:
return 5 * time.Second
case Interval15s:
return 15 * time.Second
case Interval30s:
return 30 * time.Second
case Interval1m:
return 1 * time.Minute
case Interval3m:
return 3 * time.Minute
case Interval5m:
return 5 * time.Minute
case Interval15m:
return 15 * time.Minute
case Interval30m:
return 30 * time.Minute
case Interval1h:
return 1 * time.Hour
case Interval2h:
return 2 * time.Hour
case Interval4h:
return 4 * time.Hour
case Interval6h:
return 6 * time.Hour
case Interval8h:
return 8 * time.Hour
case Interval12h:
return 12 * time.Hour
case Interval1d:
return 24 * time.Hour
case Interval3d:
return 3 * 24 * time.Hour
case Interval1w:
return 7 * 24 * time.Hour
case Interval1M:
return 30 * 24 * time.Hour
default:
return 1 * time.Minute
}
}
// AllIntervals returns all supported intervals
func AllIntervals() []Interval {
return []Interval{
Interval1s, Interval5s, Interval15s, Interval30s,
Interval1m, Interval3m, Interval5m, Interval15m, Interval30m,
Interval1h, Interval2h, Interval4h, Interval6h, Interval8h, Interval12h,
Interval1d, Interval3d, Interval1w, Interval1M,
}
}
// NewAggregator creates a new market data aggregator
func NewAggregator(logger log.Logger, db database.Database) *Aggregator {
ctx, cancel := context.WithCancel(context.Background())
return &Aggregator{
logger: logger,
db: db,
candles: make(map[string]map[Interval]*Candle),
trades: make([]*dex.Trade, 0, 1000),
subscribers: make(map[string][]chan *Candle),
ctx: ctx,
cancel: cancel,
}
}
// Start begins the aggregator
func (a *Aggregator) Start() error {
// Start candle generator for each interval
for _, interval := range AllIntervals() {
a.wg.Add(1)
go a.generateCandles(interval)
}
// Start trade processor
a.wg.Add(1)
go a.processTrades()
// Start cleanup routine
a.wg.Add(1)
go a.cleanup()
a.logger.Info("Market data aggregator started")
return nil
}
// Stop shuts down the aggregator
func (a *Aggregator) Stop() {
a.logger.Info("Stopping market data aggregator")
a.cancel()
a.wg.Wait()
}
// AddTrade adds a trade to be processed
func (a *Aggregator) AddTrade(trade *dex.Trade) {
a.tradesMu.Lock()
a.trades = append(a.trades, trade)
a.totalTrades++
a.tradesMu.Unlock()
}
// processTrades processes buffered trades
func (a *Aggregator) processTrades() {
defer a.wg.Done()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.processTradeBuffer()
}
}
}
// processTradeBuffer processes the trade buffer
func (a *Aggregator) processTradeBuffer() {
a.tradesMu.Lock()
if len(a.trades) == 0 {
a.tradesMu.Unlock()
return
}
trades := a.trades
a.trades = make([]*dex.Trade, 0, 1000)
a.tradesMu.Unlock()
// Update candles for each trade
for _, trade := range trades {
a.updateCandles(trade)
}
}
// updateCandles updates all interval candles with a trade
func (a *Aggregator) updateCandles(trade *dex.Trade) {
symbol := "BTC-USD" // TODO: Get from trade
tradeTime := trade.Timestamp
a.candlesMu.Lock()
defer a.candlesMu.Unlock()
// Ensure symbol map exists
if a.candles[symbol] == nil {
a.candles[symbol] = make(map[Interval]*Candle)
}
// Update candle for each interval
for _, interval := range AllIntervals() {
candle := a.candles[symbol][interval]
// Get candle period
openTime := a.getCandleOpenTime(tradeTime, interval)
closeTime := openTime.Add(interval.Duration())
// Create new candle if needed
if candle == nil || candle.OpenTime != openTime {
// Complete previous candle if exists
if candle != nil && !candle.Complete {
candle.Complete = true
a.publishCandle(candle)
a.storeCandle(candle)
}
// Create new candle
candle = &Candle{
Symbol: symbol,
Interval: interval,
OpenTime: openTime,
CloseTime: closeTime,
Open: trade.Price,
High: trade.Price,
Low: trade.Price,
Close: trade.Price,
Volume: trade.Size,
Trades: 1,
Complete: false,
}
a.candles[symbol][interval] = candle
a.totalCandles++
} else {
// Update existing candle
candle.High = math.Max(candle.High, trade.Price)
candle.Low = math.Min(candle.Low, trade.Price)
candle.Close = trade.Price
candle.Volume += trade.Size
candle.Trades++
}
}
}
// generateCandles generates candles at regular intervals
func (a *Aggregator) generateCandles(interval Interval) {
defer a.wg.Done()
ticker := time.NewTicker(interval.Duration())
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.completeCandles(interval)
}
}
}
// completeCandles marks candles as complete for an interval
func (a *Aggregator) completeCandles(interval Interval) {
a.candlesMu.Lock()
defer a.candlesMu.Unlock()
now := time.Now()
for _, intervalCandles := range a.candles {
candle := intervalCandles[interval]
if candle != nil && !candle.Complete && now.After(candle.CloseTime) {
candle.Complete = true
a.publishCandle(candle)
a.storeCandle(candle)
// Clear the candle
delete(intervalCandles, interval)
}
}
}
// getCandleOpenTime returns the open time for a candle
func (a *Aggregator) getCandleOpenTime(t time.Time, interval Interval) time.Time {
duration := interval.Duration()
// Special handling for monthly interval
if interval == Interval1M {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}
// Align to interval boundary
unix := t.Unix()
intervalSeconds := int64(duration.Seconds())
aligned := (unix / intervalSeconds) * intervalSeconds
return time.Unix(aligned, 0)
}
// publishCandle publishes a completed candle to subscribers
func (a *Aggregator) publishCandle(candle *Candle) {
key := fmt.Sprintf("%s:%s", candle.Symbol, candle.Interval)
a.subMu.RLock()
subscribers := a.subscribers[key]
a.subMu.RUnlock()
if len(subscribers) == 0 {
return
}
// Send to all subscribers
for _, ch := range subscribers {
select {
case ch <- candle:
default:
// Subscriber is not ready, skip
}
}
}
// storeCandle stores a candle in the database
func (a *Aggregator) storeCandle(candle *Candle) {
key := fmt.Sprintf("candle:%s:%s:%d", candle.Symbol, candle.Interval, candle.OpenTime.Unix())
value, err := json.Marshal(candle)
if err != nil {
a.logger.Error("Failed to marshal candle", "error", err)
return
}
if err := a.db.Put([]byte(key), value); err != nil {
a.logger.Error("Failed to store candle", "error", err)
}
}
// Subscribe subscribes to candle updates
func (a *Aggregator) Subscribe(symbol string, interval Interval) <-chan *Candle {
key := fmt.Sprintf("%s:%s", symbol, interval)
ch := make(chan *Candle, 100)
a.subMu.Lock()
a.subscribers[key] = append(a.subscribers[key], ch)
a.subMu.Unlock()
return ch
}
// GetCandles retrieves historical candles
func (a *Aggregator) GetCandles(symbol string, interval Interval, limit int) ([]*Candle, error) {
candles := make([]*Candle, 0, limit)
// Calculate time range
now := time.Now()
duration := interval.Duration()
startTime := now.Add(-duration * time.Duration(limit))
// Iterate through database
prefix := fmt.Sprintf("candle:%s:%s:", symbol, interval)
// TODO: Implement proper iterator for database.Database interface
// For now, return empty candles
// TODO: Implement when database iterator is available
_ = prefix
_ = startTime
return candles, nil
/*
iter := a.db.NewIterator([]byte(prefix), nil)
defer iter.Release()
for iter.Next() {
var candle Candle
if err := json.Unmarshal(iter.Value(), &candle); err != nil {
continue
}
if candle.OpenTime.After(startTime) {
candles = append(candles, &candle)
}
if len(candles) >= limit {
break
}
}
return candles, nil
*/
}
// GetLatestCandle returns the most recent candle
func (a *Aggregator) GetLatestCandle(symbol string, interval Interval) *Candle {
a.candlesMu.RLock()
defer a.candlesMu.RUnlock()
if symbolCandles, ok := a.candles[symbol]; ok {
return symbolCandles[interval]
}
return nil
}
// GetStats returns aggregator statistics
func (a *Aggregator) GetStats() map[string]interface{} {
a.candlesMu.RLock()
numSymbols := len(a.candles)
a.candlesMu.RUnlock()
return map[string]interface{}{
"total_trades": a.totalTrades,
"total_candles": a.totalCandles,
"symbols": numSymbols,
}
}
// cleanup removes old data periodically
func (a *Aggregator) cleanup() {
defer a.wg.Done()
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.cleanupOldCandles()
}
}
}
// cleanupOldCandles removes old candles from database
func (a *Aggregator) cleanupOldCandles() {
// Keep different retention periods for different intervals
retentionMap := map[Interval]time.Duration{
Interval1s: 24 * time.Hour,
Interval5s: 3 * 24 * time.Hour,
Interval15s: 7 * 24 * time.Hour,
Interval30s: 7 * 24 * time.Hour,
Interval1m: 30 * 24 * time.Hour,
Interval5m: 90 * 24 * time.Hour,
Interval15m: 180 * 24 * time.Hour,
Interval30m: 365 * 24 * time.Hour,
Interval1h: 365 * 24 * time.Hour,
Interval1d: 10 * 365 * 24 * time.Hour,
}
now := time.Now()
batch := a.db.NewBatch()
defer batch.Reset()
// TODO: Implement when database iterator is available
for interval, retention := range retentionMap {
cutoff := now.Add(-retention)
prefix := fmt.Sprintf("candle:*:%s:", interval)
_ = cutoff
_ = prefix
/*
iter := a.db.NewIterator([]byte(prefix), nil)
for iter.Next() {
var candle Candle
if err := json.Unmarshal(iter.Value(), &candle); err != nil {
continue
}
if candle.OpenTime.Before(cutoff) {
batch.Delete(iter.Key())
}
}
iter.Release()
*/
}
if err := batch.Write(); err != nil {
a.logger.Error("Failed to cleanup old candles", "error", err)
}
}
// VolumeWeightedAveragePrice calculates VWAP for a period
func (a *Aggregator) VolumeWeightedAveragePrice(symbol string, interval Interval, periods int) float64 {
candles, err := a.GetCandles(symbol, interval, periods)
if err != nil || len(candles) == 0 {
return 0
}
var totalVolume float64
var volumePrice float64
for _, candle := range candles {
avgPrice := (candle.High + candle.Low + candle.Close) / 3
volumePrice += avgPrice * candle.Volume
totalVolume += candle.Volume
}
if totalVolume == 0 {
return 0
}
return volumePrice / totalVolume
}
// MovingAverage calculates simple moving average
func (a *Aggregator) MovingAverage(symbol string, interval Interval, periods int) float64 {
candles, err := a.GetCandles(symbol, interval, periods)
if err != nil || len(candles) == 0 {
return 0
}
var sum float64
for _, candle := range candles {
sum += candle.Close
}
return sum / float64(len(candles))
}
// RSI calculates Relative Strength Index
func (a *Aggregator) RSI(symbol string, interval Interval, periods int) float64 {
candles, err := a.GetCandles(symbol, interval, periods+1)
if err != nil || len(candles) < 2 {
return 50 // Neutral RSI
}
var gains, losses float64
for i := 1; i < len(candles); i++ {
change := candles[i].Close - candles[i-1].Close
if change > 0 {
gains += change
} else {
losses += -change
}
}
if losses == 0 {
return 100 // Overbought
}
avgGain := gains / float64(periods)
avgLoss := losses / float64(periods)
rs := avgGain / avgLoss
return 100 - (100 / (1 + rs))
}