mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
`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]>
298 lines
5.8 KiB
Go
298 lines
5.8 KiB
Go
package price
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// PythSource provides real-time prices from Pyth Network.
|
|
// Sub-second updates via WebSocket for low-latency trading.
|
|
type PythSource struct {
|
|
wsURL string
|
|
apiURL string
|
|
conn *websocket.Conn
|
|
client *http.Client
|
|
|
|
priceIDs map[string]string
|
|
prices map[string]*Data
|
|
subs map[string]bool
|
|
|
|
healthy bool
|
|
heartbeat time.Time
|
|
|
|
mu sync.RWMutex
|
|
lifecycle
|
|
}
|
|
|
|
// NewPythSource creates a Pyth Network price source.
|
|
func NewPythSource(wsURL, apiURL string) *PythSource {
|
|
return &PythSource{
|
|
wsURL: wsURL,
|
|
apiURL: apiURL,
|
|
client: &http.Client{Timeout: 5 * time.Second},
|
|
priceIDs: pythPriceIDs(),
|
|
prices: make(map[string]*Data),
|
|
subs: make(map[string]bool),
|
|
lifecycle: newLifecycle(),
|
|
}
|
|
}
|
|
|
|
func pythPriceIDs() map[string]string {
|
|
return map[string]string{
|
|
"BTC-USD": "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
|
|
"ETH-USD": "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
|
|
"SOL-USD": "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d",
|
|
"AVAX-USD": "0x93da3352f9f1d105fdfe4971cfa80e9dd777bfc5d0f683ebb6e1294b92137bb7",
|
|
"LUX-USD": "0x0000000000000000000000000000000000000000000000000000000000000001", // Placeholder
|
|
}
|
|
}
|
|
|
|
// Connect establishes WebSocket connection.
|
|
func (s *PythSource) Connect() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.conn != nil {
|
|
s.conn.Close()
|
|
}
|
|
|
|
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
|
conn, _, err := dialer.Dial(s.wsURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.conn = conn
|
|
s.healthy = true
|
|
s.heartbeat = time.Now()
|
|
|
|
s.run(s.read)
|
|
s.run(s.ping)
|
|
|
|
// Subscribe to all price feeds
|
|
for symbol := range s.priceIDs {
|
|
s.subscribe(symbol)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *PythSource) read() {
|
|
defer func() {
|
|
s.mu.Lock()
|
|
s.healthy = false
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
default:
|
|
var msg map[string]interface{}
|
|
if err := s.conn.ReadJSON(&msg); err != nil {
|
|
return
|
|
}
|
|
s.handle(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *PythSource) handle(msg map[string]interface{}) {
|
|
msgType, _ := msg["type"].(string)
|
|
|
|
switch msgType {
|
|
case "price_update":
|
|
s.handlePrice(msg)
|
|
case "heartbeat":
|
|
s.mu.Lock()
|
|
s.heartbeat = time.Now()
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (s *PythSource) handlePrice(msg map[string]interface{}) {
|
|
data, ok := msg["data"].(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
priceID, _ := data["price_id"].(string)
|
|
|
|
// Find symbol
|
|
var symbol string
|
|
for sym, id := range s.priceIDs {
|
|
if id == priceID {
|
|
symbol = sym
|
|
break
|
|
}
|
|
}
|
|
if symbol == "" {
|
|
return
|
|
}
|
|
|
|
price, _ := data["price"].(float64)
|
|
conf, _ := data["confidence"].(float64)
|
|
ts, _ := data["publish_time"].(float64)
|
|
|
|
if expo, ok := data["expo"].(float64); ok {
|
|
price = price * math.Pow(10, expo)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.prices[symbol] = &Data{
|
|
Symbol: symbol,
|
|
Price: price,
|
|
Confidence: 1.0 - (conf / price),
|
|
Timestamp: time.Unix(int64(ts), 0),
|
|
Source: "pyth",
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *PythSource) subscribe(symbol string) error {
|
|
id, ok := s.priceIDs[symbol]
|
|
if !ok {
|
|
return fmt.Errorf("unknown symbol: %s", symbol)
|
|
}
|
|
|
|
if s.conn == nil {
|
|
return nil
|
|
}
|
|
|
|
return s.conn.WriteJSON(map[string]interface{}{
|
|
"type": "subscribe",
|
|
"ids": []string{id},
|
|
})
|
|
}
|
|
|
|
func (s *PythSource) ping() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
case <-ticker.C:
|
|
s.mu.RLock()
|
|
conn := s.conn
|
|
s.mu.RUnlock()
|
|
if conn != nil {
|
|
conn.WriteJSON(map[string]string{"type": "heartbeat"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Price returns the latest price for a symbol.
|
|
func (s *PythSource) Price(symbol string) (*Data, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
p, ok := s.prices[symbol]
|
|
if !ok {
|
|
return s.fetchHTTP(symbol)
|
|
}
|
|
|
|
copy := *p
|
|
return ©, nil
|
|
}
|
|
|
|
func (s *PythSource) fetchHTTP(symbol string) (*Data, error) {
|
|
id, ok := s.priceIDs[symbol]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/latest_price_feeds?ids[]=%s", s.apiURL, id)
|
|
resp, err := s.client.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result []struct {
|
|
Price float64 `json:"price"`
|
|
Confidence float64 `json:"conf"`
|
|
Expo int32 `json:"expo"`
|
|
Time int64 `json:"publish_time"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
r := result[0]
|
|
price := r.Price * math.Pow(10, float64(r.Expo))
|
|
|
|
return &Data{
|
|
Symbol: symbol,
|
|
Price: price,
|
|
Confidence: 1.0 - (r.Confidence / price),
|
|
Timestamp: time.Unix(r.Time, 0),
|
|
Source: "pyth",
|
|
}, nil
|
|
}
|
|
|
|
// Prices returns prices for multiple symbols.
|
|
func (s *PythSource) Prices(symbols []string) (map[string]*Data, error) {
|
|
result := make(map[string]*Data)
|
|
for _, sym := range symbols {
|
|
if p, err := s.Price(sym); err == nil {
|
|
result[sym] = p
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Subscribe registers for updates.
|
|
func (s *PythSource) Subscribe(symbol string) error {
|
|
s.mu.Lock()
|
|
s.subs[symbol] = true
|
|
s.mu.Unlock()
|
|
return s.subscribe(symbol)
|
|
}
|
|
|
|
// Unsubscribe removes subscription.
|
|
func (s *PythSource) Unsubscribe(symbol string) error {
|
|
s.mu.Lock()
|
|
delete(s.subs, symbol)
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Healthy returns true if connected.
|
|
func (s *PythSource) Healthy() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.healthy && time.Since(s.heartbeat) < 30*time.Second
|
|
}
|
|
|
|
// Name returns "pyth".
|
|
func (s *PythSource) Name() string { return "pyth" }
|
|
|
|
// Weight returns 1.2 (external oracle).
|
|
func (s *PythSource) Weight() float64 { return 1.2 }
|
|
|
|
// Close shuts down the source.
|
|
func (s *PythSource) Close() error {
|
|
s.stop()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.conn != nil {
|
|
return s.conn.Close()
|
|
}
|
|
return nil
|
|
}
|