Files
zeekayandHanzo Dev 45bff3f5b6 fix(dex): close price sources synchronously; build pkg/dpdk and pkg/orderbook on linux
`go test ./...` failed three ways on the runner. Two were build failures that
cannot happen on a mac, and one was a race that only loses under load.

pkg/dpdk — `undefined: syscall.SO_NOSIGPIPE`. DarwinOptimizations guarded itself
with `if runtime.GOOS != "darwin" { return }`, but SO_NOSIGPIPE is a COMPILE-time
constant that exists only on Darwin; a runtime check cannot save a reference the
linux build still has to resolve. Split into _darwin.go / _nodarwin.go. Verified
by cross-compiling both ways -- it failed for GOOS=linux before, both pass now.

pkg/orderbook — the cgo preamble uses uint64_t while including only <stdlib.h>.
macOS pulls stdint.h in transitively and glibc does not, so this built here and
never on the runner. Added the include.

pkg/price — TestXChainSourceStalePrice: "Expected price to be stale".

Not a flaky test; Close() was not a barrier. It closed the done channel and
returned WITHOUT waiting for the polling goroutine. A loop selecting on done
against a ticker picks a ready case AT RANDOM, so a poll could still be running
after Close() returned, and poll writes last[symbol] = time.Now(). The test
closes the source, backdates that timestamp by 10s, and asserts staleness -- an
in-flight poll overwrote it. Wide window on a loaded runner, near-zero on a
laptop, which is why it reproduces 0/30 here.

All EIGHT sources had it, so the fix is one shared `lifecycle` (embedded, so
`s.done` still resolves) whose stop() joins before returning and is safe to call
twice. Oracle had the same defect written as

    // Give time for goroutines to exit
    time.Sleep(100 * time.Millisecond)

which could not work either -- calcAverages blocks on a 1s ticker, so it ran up
to a full second after Stop() returned. Now on the same lifecycle, and its loops
select on done instead of polling a flag, so they leave promptly rather than
after one more tick. Nothing restarts a stopped source (checked: 0 tests), and
the other sources already could not.

    go build ./...                        OK
    GOOS=linux go build ./...             OK   (failed before)
    go vet price/dpdk/orderbook           OK
    go test ./pkg/price/ -race            ok, 3/3 runs
    go test -run StalePrice -count=30     ok

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

545 lines
11 KiB
Go

package price
import (
"errors"
"math"
"sort"
"sync"
"sync/atomic"
"time"
)
// Oracle aggregates prices from multiple sources with configurable strategies.
type Oracle struct {
lifecycle
sources map[string]Source
strategy Aggregator
symbolMap *SymbolMap
watching map[string]bool
current map[string]*Data
history map[string][]*Data
twap map[string]float64
vwap map[string]float64
breakers map[string]*CircuitBreaker
updates chan *Update
alerts chan *Alert
interval time.Duration
staleLimit time.Duration
minSources int
running atomic.Bool
mu sync.RWMutex
// Q-Chain quantum finality verifier (optional)
verifier *QChainVerifier
}
// NewOracle creates a price oracle with default configuration.
func NewOracle() *Oracle {
return &Oracle{
lifecycle: newLifecycle(),
sources: make(map[string]Source),
strategy: &WeightedMedian{
MinSources: 1,
MaxDeviation: 0.10,
},
symbolMap: NewSymbolMap(),
watching: make(map[string]bool),
current: make(map[string]*Data),
history: make(map[string][]*Data),
twap: make(map[string]float64),
vwap: make(map[string]float64),
breakers: make(map[string]*CircuitBreaker),
updates: make(chan *Update, 10000),
alerts: make(chan *Alert, 1000),
interval: 50 * time.Millisecond,
staleLimit: 5 * time.Second,
minSources: 1,
}
}
// Watch adds a symbol to the watchlist.
func (o *Oracle) Watch(symbol string) {
o.mu.Lock()
o.watching[o.symbolMap.Map(symbol)] = true
o.mu.Unlock()
}
// AddSource registers a price source.
func (o *Oracle) AddSource(name string, src Source) {
o.mu.Lock()
o.sources[name] = src
o.mu.Unlock()
}
// Start begins price aggregation.
func (o *Oracle) Start() error {
// Use atomic compare-and-swap to avoid race
if !o.running.CompareAndSwap(false, true) {
return nil // Already running
}
o.run(o.loop)
o.run(o.calcAverages)
return nil
}
func (o *Oracle) loop() {
ticker := time.NewTicker(o.interval)
defer ticker.Stop()
for {
select {
case <-o.done:
return
case <-ticker.C:
o.update()
}
}
}
func (o *Oracle) update() {
o.mu.RLock()
syms := make([]string, 0, len(o.watching))
for sym := range o.watching {
syms = append(syms, sym)
}
o.mu.RUnlock()
for _, normalized := range syms {
prices := make([]*Data, 0)
// Get all variants that map to this normalized symbol
variants := o.symbolMap.Reverse(normalized)
for _, src := range o.sources {
if !src.Healthy() {
continue
}
// Try each variant
for _, sym := range variants {
p, err := src.Price(sym)
if err != nil {
continue
}
// Normalize the symbol in the result
p.Symbol = normalized
// Check circuit breaker
if cb, ok := o.breakers[normalized]; ok {
if !cb.Check(p.Price) {
o.alert(normalized, AlertCircuitBreaker, SeverityCrit, "circuit breaker tripped")
continue
}
}
prices = append(prices, p)
break // Got price from this source
}
}
if len(prices) < o.minSources {
continue
}
agg, err := o.strategy.Aggregate(prices)
if err != nil {
continue
}
agg.Symbol = normalized
o.store(normalized, agg)
}
}
func (o *Oracle) store(symbol string, price *Data) {
o.mu.Lock()
defer o.mu.Unlock()
old := o.current[symbol]
o.current[symbol] = price
// History
if o.history[symbol] == nil {
o.history[symbol] = make([]*Data, 0)
}
o.history[symbol] = append(o.history[symbol], price)
if len(o.history[symbol]) > 10000 {
o.history[symbol] = o.history[symbol][1:]
}
// Update notification
if old != nil {
change := (price.Price - old.Price) / old.Price * 100
select {
case o.updates <- &Update{
Symbol: symbol,
OldPrice: old.Price,
NewPrice: price.Price,
Source: price.Source,
Timestamp: price.Timestamp,
Change: change,
}:
default:
}
}
}
func (o *Oracle) calcAverages() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-o.done:
return
case <-ticker.C:
}
o.mu.Lock()
for symbol, hist := range o.history {
if len(hist) == 0 {
continue
}
// TWAP (5 min)
o.twap[symbol] = calcTWAP(hist, 5*time.Minute)
// VWAP (5 min)
o.vwap[symbol] = calcVWAP(hist, 5*time.Minute)
}
o.mu.Unlock()
}
}
// Price returns the aggregated price for a symbol.
func (o *Oracle) Price(symbol string) float64 {
o.mu.RLock()
defer o.mu.RUnlock()
if p, ok := o.current[symbol]; ok {
if time.Since(p.Timestamp) > o.staleLimit {
return 0
}
return p.Price
}
return 0
}
// Data returns full price data for a symbol.
func (o *Oracle) Data(symbol string) (*Data, error) {
o.mu.RLock()
defer o.mu.RUnlock()
p, ok := o.current[symbol]
if !ok {
return nil, ErrNotFound
}
copy := *p
return &copy, nil
}
// TWAP returns time-weighted average price.
func (o *Oracle) TWAP(symbol string) float64 {
o.mu.RLock()
defer o.mu.RUnlock()
return o.twap[symbol]
}
// VWAP returns volume-weighted average price.
func (o *Oracle) VWAP(symbol string) float64 {
o.mu.RLock()
defer o.mu.RUnlock()
return o.vwap[symbol]
}
// Updates returns the update channel.
func (o *Oracle) Updates() <-chan *Update {
return o.updates
}
// Alerts returns the alert channel.
func (o *Oracle) Alerts() <-chan *Alert {
return o.alerts
}
// Stop halts the oracle.
func (o *Oracle) Stop() {
// Use atomic compare-and-swap to avoid race
if !o.running.CompareAndSwap(true, false) {
return // Already stopped
}
// Stop verifier if running
if o.verifier != nil {
o.verifier.Close()
}
// Join the goroutines. This was a 100ms sleep, which could not work:
// calcAverages blocks on a 1s ticker, so it ran up to a full second after
// Stop() returned.
o.stop()
}
// SetVerifier attaches a Q-Chain quantum finality verifier.
// When set, the Oracle automatically verifies prices against Q-Chain finality.
func (o *Oracle) SetVerifier(v *QChainVerifier) {
o.mu.Lock()
o.verifier = v
o.mu.Unlock()
}
// Verifier returns the attached Q-Chain verifier (or nil if not set).
func (o *Oracle) Verifier() *QChainVerifier {
o.mu.RLock()
defer o.mu.RUnlock()
return o.verifier
}
// VerifiedPrice returns price data with quantum finality status.
// If a verifier is attached, includes finality verification.
func (o *Oracle) VerifiedPrice(symbol string) (*VerifiedData, error) {
data, err := o.Data(symbol)
if err != nil {
return nil, err
}
vd := &VerifiedData{
Data: data,
Finalized: false,
}
// If verifier attached, check finality
if o.verifier != nil {
// Determine which chain this price came from
chain := sourceToChain(data.Source)
if chain != "" {
if fin, err := o.verifier.Finality(chain); err == nil && fin.Finalized {
vd.Finalized = true
vd.Finality = fin
}
}
}
return vd, nil
}
// sourceToChain maps source names to chain identifiers for finality verification.
func sourceToChain(source string) string {
switch source {
case "x-chain":
return "x-chain"
case "c-chain":
return "c-chain"
case "zoo-chain":
return "zoo-chain"
case "a-chain":
return "a-chain"
default:
return "" // External oracles don't have on-chain finality
}
}
func (o *Oracle) alert(symbol string, t AlertType, sev Severity, msg string) {
select {
case o.alerts <- &Alert{
Symbol: symbol,
Type: t,
Severity: sev,
Message: msg,
Timestamp: time.Now(),
}:
default:
}
}
// WeightedMedian aggregates using weighted median.
type WeightedMedian struct {
MinSources int
MaxDeviation float64
}
// Aggregate combines prices using weighted median.
func (w *WeightedMedian) Aggregate(prices []*Data) (*Data, error) {
if len(prices) < w.MinSources {
return nil, errors.New("insufficient sources")
}
// Sort by price
sort.Slice(prices, func(i, j int) bool {
return prices[i].Price < prices[j].Price
})
// Calculate median
median := prices[len(prices)/2].Price
// Filter outliers
filtered := make([]*Data, 0)
for _, p := range prices {
dev := math.Abs(p.Price-median) / median
if dev <= w.MaxDeviation {
filtered = append(filtered, p)
}
}
if len(filtered) < w.MinSources {
return nil, errors.New("too many outliers")
}
// Weighted average
totalWeight := 0.0
weightedSum := 0.0
totalVolume := 0.0
for _, p := range filtered {
weight := 1.0 // Could use source weight
weightedSum += p.Price * weight
totalWeight += weight
totalVolume += p.Volume
}
return &Data{
Symbol: prices[0].Symbol,
Price: weightedSum / totalWeight,
Volume: totalVolume,
Confidence: w.confidence(filtered),
Timestamp: time.Now(),
Source: "aggregated",
}, nil
}
// Validate checks price sanity.
func (w *WeightedMedian) Validate(prices []*Data) error {
if len(prices) == 0 {
return errors.New("no prices")
}
return nil
}
func (w *WeightedMedian) confidence(prices []*Data) float64 {
if len(prices) == 0 {
return 0
}
sourceScore := float64(len(prices)) / float64(w.MinSources*2)
if sourceScore > 1.0 {
sourceScore = 1.0
}
// Calculate agreement
values := make([]float64, len(prices))
for i, p := range prices {
values[i] = p.Price
}
mean := avg(values)
stdDev := stddev(values, mean)
devScore := 1.0 - (stdDev / mean)
if devScore < 0 {
devScore = 0
}
return sourceScore*0.6 + devScore*0.4
}
// CircuitBreaker prevents erroneous prices.
type CircuitBreaker struct {
Symbol string
MaxChange float64
LastPrice float64
LastTime time.Time
Tripped bool
TripTime time.Time
Reset time.Duration
}
// Check validates a new price.
func (cb *CircuitBreaker) Check(price float64) bool {
if cb.LastPrice == 0 {
cb.LastPrice = price
cb.LastTime = time.Now()
return true
}
// Auto-reset
if cb.Tripped && time.Since(cb.TripTime) > cb.Reset {
cb.Tripped = false
}
if cb.Tripped {
return false
}
change := math.Abs(price-cb.LastPrice) / cb.LastPrice * 100
if change > cb.MaxChange {
cb.Tripped = true
cb.TripTime = time.Now()
return false
}
cb.LastPrice = price
cb.LastTime = time.Now()
return true
}
// Helpers
func calcTWAP(history []*Data, window time.Duration) float64 {
cutoff := time.Now().Add(-window)
var sum float64
var count int
for _, p := range history {
if p.Timestamp.After(cutoff) {
sum += p.Price
count++
}
}
if count == 0 {
return 0
}
return sum / float64(count)
}
func calcVWAP(history []*Data, window time.Duration) float64 {
cutoff := time.Now().Add(-window)
var totalValue, totalVolume float64
for _, p := range history {
if p.Timestamp.After(cutoff) {
totalValue += p.Price * p.Volume
totalVolume += p.Volume
}
}
if totalVolume == 0 {
return 0
}
return totalValue / totalVolume
}
func avg(values []float64) float64 {
if len(values) == 0 {
return 0
}
var sum float64
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
func stddev(values []float64, mean float64) float64 {
if len(values) == 0 {
return 0
}
var sum float64
for _, v := range values {
d := v - mean
sum += d * d
}
return math.Sqrt(sum / float64(len(values)))
}