mirror of
https://github.com/luxfi/dex.git
synced 2026-10-30 14:45:46 +00:00
Drop 31 demo/benchmark/experiment one-off cmds whose coverage lives in
package tests (bench-all, dag-network, dclient, dexe2e, e2e-fix-zmq,
fix-benchmark, hybrid-auto, latency-benchmark, memory-*, mlx-memory-analysis,
multi-node, multi-node-test, nats-*, perf-test, stress-test, test-client,
zmq-*, dvenue-cancel, dvenue-custody, venueprobe, empty api-server/server/trader).
Keep production binaries: dvenue, dchain, dex-server, dex-api-server, gateway,
price-feed, monitor, persistent-server, demo (each referenced by a
Dockerfile/Makefile/script/k8s manifest or a documented entrypoint).
Consolidate cmd/dvenue + cmd/dvenue-cpu into one tag-free cmd/dvenue: the two
mains were byte-identical except a build tag and a log label; the GPU-vs-CPU
matcher is already selected inside pkg/lx by its own cgo-tagged files, so the
main needs no tag. CGO_ENABLED alone picks the matcher; one binary builds in
both modes. engine_{cgo,nocgo}.go carry only the engine display label. dvenue
now builds under the default CGO_ENABLED=0 gate (was tag-excluded before).
Dockerfile.dvenue: drop the dvenue-custody build+copy (cmd removed).
Makefile build-tools: point at real tools (price-feed, monitor) instead of the
removed cmd/trader and never-existent cmd/lx-metrics.
pkg/price: fix the three CChainSource tests. They were gated on requireLuxd
(a luxd TCP dial) but CChainSource never dials luxd — fetch() is pure simulate().
The gate skipped only when luxd was down, masking the real defect on hosts where
luxd runs. Rewrite the tests to drive the source's true contract deterministically
via src.poll() (no ticker race) and assert against the symbols the source actually
polls (cchainTokens), plus not-found for untracked symbols. Remove the dead
requireLuxd helper and its net import.
1334 lines
28 KiB
Go
1334 lines
28 KiB
Go
package price
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNormalize(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"LUX/USD", "LUX-USD"},
|
|
{"LUX-USD", "LUX-USD"},
|
|
{"LUXUSD", "LUX-USD"},
|
|
{"ETH/USDC", "ETH-USDC"},
|
|
{"btc-usd", "BTC-USD"},
|
|
{"AVAX_USDT", "AVAX-USDT"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := Normalize(tc.input)
|
|
if got != tc.expected {
|
|
t.Errorf("Normalize(%q) = %q, want %q", tc.input, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSymbolMap(t *testing.T) {
|
|
m := NewSymbolMap()
|
|
|
|
// Test mapping
|
|
if got := m.Map("LUX/USD"); got != "LUX-USD" {
|
|
t.Errorf("Map(LUX/USD) = %q, want LUX-USD", got)
|
|
}
|
|
|
|
// Test reverse
|
|
variants := m.Reverse("LUX-USD")
|
|
if len(variants) == 0 {
|
|
t.Error("Reverse(LUX-USD) returned empty")
|
|
}
|
|
}
|
|
|
|
func TestBaseQuote(t *testing.T) {
|
|
base, quote := BaseQuote("LUX-USDC")
|
|
if base != "LUX" || quote != "USDC" {
|
|
t.Errorf("BaseQuote(LUX-USDC) = %q, %q, want LUX, USDC", base, quote)
|
|
}
|
|
}
|
|
|
|
func TestIsUSD(t *testing.T) {
|
|
if !IsUSD("LUX-USD") {
|
|
t.Error("IsUSD(LUX-USD) = false, want true")
|
|
}
|
|
if !IsUSD("ETH-USDC") {
|
|
t.Error("IsUSD(ETH-USDC) = false, want true")
|
|
}
|
|
if IsUSD("ETH-BTC") {
|
|
t.Error("IsUSD(ETH-BTC) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestXChainSource(t *testing.T) {
|
|
src := NewXChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
if err := src.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer src.Close()
|
|
|
|
// Wait for data
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Check health
|
|
if !src.Healthy() {
|
|
t.Error("Source not healthy")
|
|
}
|
|
|
|
// Check name
|
|
if src.Name() != "x-chain" {
|
|
t.Errorf("Name() = %q, want x-chain", src.Name())
|
|
}
|
|
|
|
// Get price
|
|
p, err := src.Price("LUX-USDC")
|
|
if err != nil {
|
|
t.Fatalf("Price failed: %v", err)
|
|
}
|
|
|
|
if p.Price <= 0 {
|
|
t.Error("Price <= 0")
|
|
}
|
|
if p.Symbol != "LUX-USDC" {
|
|
t.Errorf("Symbol = %q, want LUX-USDC", p.Symbol)
|
|
}
|
|
}
|
|
|
|
func TestAChainSource(t *testing.T) {
|
|
src := NewAChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
if err := src.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer src.Close()
|
|
|
|
// Wait for data
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
// Check health
|
|
if !src.Healthy() {
|
|
t.Error("Source not healthy")
|
|
}
|
|
|
|
// Get attestation
|
|
att, err := src.Attestation("LUX-USD")
|
|
if err != nil {
|
|
t.Fatalf("Attestation failed: %v", err)
|
|
}
|
|
|
|
if att.Price <= 0 {
|
|
t.Error("Attestation price <= 0")
|
|
}
|
|
if !att.Finalized {
|
|
t.Error("Attestation not finalized")
|
|
}
|
|
}
|
|
|
|
func TestCChainSource(t *testing.T) {
|
|
src := NewCChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
|
|
// poll() drives the source's fetch path deterministically; calling it
|
|
// directly avoids racing the background ticker started by Start().
|
|
src.poll()
|
|
|
|
if !src.Healthy() {
|
|
t.Error("Source not healthy after poll")
|
|
}
|
|
|
|
// The source serves the pairs it is configured to poll (cchainTokens).
|
|
// Pick the first configured pair and assert it is now populated.
|
|
sym := firstPolledSymbol(t, src)
|
|
p, err := src.Price(sym)
|
|
if err != nil {
|
|
t.Fatalf("Price(%q) after poll: %v", sym, err)
|
|
}
|
|
if p.Symbol != sym {
|
|
t.Errorf("Price symbol = %q, want %q", p.Symbol, sym)
|
|
}
|
|
if p.Source != "c-chain" {
|
|
t.Errorf("Price source = %q, want %q", p.Source, "c-chain")
|
|
}
|
|
|
|
// A symbol the source does not track must report not-found.
|
|
if _, err := src.Price("NOT-A-PAIR"); err == nil {
|
|
t.Error("Price for untracked symbol: want error, got nil")
|
|
}
|
|
}
|
|
|
|
// firstPolledSymbol returns one symbol the source is configured to poll.
|
|
func firstPolledSymbol(t *testing.T, src *CChainSource) string {
|
|
t.Helper()
|
|
for sym := range src.tokens {
|
|
return sym
|
|
}
|
|
t.Fatal("CChainSource has no configured token pairs")
|
|
return ""
|
|
}
|
|
|
|
func TestOracle(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Add sources
|
|
xchain := NewXChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
xchain.Start()
|
|
|
|
chainlink := NewChainlinkSource()
|
|
chainlink.Start()
|
|
|
|
oracle.AddSource("xchain", xchain)
|
|
oracle.AddSource("chainlink", chainlink)
|
|
|
|
// Watch symbol
|
|
oracle.Watch("LUX-USD")
|
|
|
|
// Start
|
|
if err := oracle.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Wait for aggregation
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Get price
|
|
price := oracle.Price("LUX-USD")
|
|
if price <= 0 {
|
|
t.Error("Aggregated price <= 0")
|
|
}
|
|
|
|
// Stop oracle first, then sources
|
|
oracle.Stop()
|
|
xchain.Close()
|
|
chainlink.Close()
|
|
}
|
|
|
|
func TestCircuitBreaker(t *testing.T) {
|
|
cb := &CircuitBreaker{
|
|
Symbol: "TEST",
|
|
MaxChange: 10.0, // 10%
|
|
Reset: time.Second,
|
|
}
|
|
|
|
// First price
|
|
if !cb.Check(100.0) {
|
|
t.Error("First check should pass")
|
|
}
|
|
|
|
// Normal change
|
|
if !cb.Check(105.0) {
|
|
t.Error("5% change should pass")
|
|
}
|
|
|
|
// Excessive change
|
|
if cb.Check(150.0) {
|
|
t.Error("50% change should trip breaker")
|
|
}
|
|
|
|
// Should be tripped
|
|
if !cb.Tripped {
|
|
t.Error("Breaker should be tripped")
|
|
}
|
|
|
|
// Wait for reset
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
// Should reset
|
|
if !cb.Check(100.0) {
|
|
t.Error("Should pass after reset")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedian(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 1,
|
|
MaxDeviation: 0.10,
|
|
}
|
|
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Source: "a"},
|
|
{Symbol: "TEST", Price: 101.0, Source: "b"},
|
|
{Symbol: "TEST", Price: 99.0, Source: "c"},
|
|
}
|
|
|
|
agg, err := wm.Aggregate(prices)
|
|
if err != nil {
|
|
t.Fatalf("Aggregate failed: %v", err)
|
|
}
|
|
|
|
// Should be close to median
|
|
if agg.Price < 99 || agg.Price > 101 {
|
|
t.Errorf("Aggregated price %f outside expected range", agg.Price)
|
|
}
|
|
}
|
|
|
|
// === Additional Oracle Tests ===
|
|
|
|
func TestOracleData(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// No data - should error
|
|
_, err := oracle.Data("LUX-USD")
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent symbol")
|
|
}
|
|
}
|
|
|
|
func TestOracleTWAP(t *testing.T) {
|
|
oracle := NewOracle()
|
|
twap := oracle.TWAP("LUX-USD")
|
|
if twap != 0 {
|
|
t.Errorf("TWAP for unknown symbol should be 0, got %f", twap)
|
|
}
|
|
}
|
|
|
|
func TestOracleVWAP(t *testing.T) {
|
|
oracle := NewOracle()
|
|
vwap := oracle.VWAP("LUX-USD")
|
|
if vwap != 0 {
|
|
t.Errorf("VWAP for unknown symbol should be 0, got %f", vwap)
|
|
}
|
|
}
|
|
|
|
func TestOracleUpdates(t *testing.T) {
|
|
oracle := NewOracle()
|
|
updates := oracle.Updates()
|
|
if updates == nil {
|
|
t.Error("Updates channel should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestOracleAlerts(t *testing.T) {
|
|
oracle := NewOracle()
|
|
alerts := oracle.Alerts()
|
|
if alerts == nil {
|
|
t.Error("Alerts channel should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestOracleStartStop(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Start multiple times should be idempotent
|
|
if err := oracle.Start(); err != nil {
|
|
t.Fatalf("First start failed: %v", err)
|
|
}
|
|
if err := oracle.Start(); err != nil {
|
|
t.Fatalf("Second start should be no-op: %v", err)
|
|
}
|
|
|
|
// Stop multiple times should be idempotent
|
|
oracle.Stop()
|
|
oracle.Stop() // Should not panic
|
|
}
|
|
|
|
func TestOracleWatch(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Watch symbol
|
|
oracle.Watch("BTC-USD")
|
|
oracle.Watch("ETH-USD")
|
|
|
|
// Check internal state (via watching map)
|
|
if oracle.Price("BTC-USD") != 0 {
|
|
t.Error("Price should be 0 before data")
|
|
}
|
|
}
|
|
|
|
// === WeightedMedian Extended Tests ===
|
|
|
|
func TestWeightedMedianInsufficientSources(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 3,
|
|
MaxDeviation: 0.10,
|
|
}
|
|
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Source: "a"},
|
|
}
|
|
|
|
_, err := wm.Aggregate(prices)
|
|
if err == nil {
|
|
t.Error("Should fail with insufficient sources")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedianOutliers(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 2,
|
|
MaxDeviation: 0.05, // 5%
|
|
}
|
|
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Source: "a"},
|
|
{Symbol: "TEST", Price: 101.0, Source: "b"},
|
|
{Symbol: "TEST", Price: 200.0, Source: "c"}, // Outlier
|
|
}
|
|
|
|
agg, err := wm.Aggregate(prices)
|
|
if err != nil {
|
|
t.Fatalf("Aggregate failed: %v", err)
|
|
}
|
|
|
|
// Should exclude the outlier (200)
|
|
if agg.Price > 105 {
|
|
t.Errorf("Expected price near 100, got %f (outlier not filtered)", agg.Price)
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedianTooManyOutliers(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 3,
|
|
MaxDeviation: 0.01, // Very strict 1%
|
|
}
|
|
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Source: "a"},
|
|
{Symbol: "TEST", Price: 150.0, Source: "b"}, // Outlier
|
|
{Symbol: "TEST", Price: 200.0, Source: "c"}, // Outlier
|
|
}
|
|
|
|
_, err := wm.Aggregate(prices)
|
|
if err == nil {
|
|
t.Error("Should fail with too many outliers")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedianValidate(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 1,
|
|
MaxDeviation: 0.10,
|
|
}
|
|
|
|
// Empty prices
|
|
err := wm.Validate([]*Data{})
|
|
if err == nil {
|
|
t.Error("Validate should fail for empty prices")
|
|
}
|
|
|
|
// Valid prices
|
|
err = wm.Validate([]*Data{{Symbol: "TEST", Price: 100.0}})
|
|
if err != nil {
|
|
t.Errorf("Validate should pass: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedianWithVolume(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 1,
|
|
MaxDeviation: 0.10,
|
|
}
|
|
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Volume: 1000.0, Source: "a"},
|
|
{Symbol: "TEST", Price: 101.0, Volume: 500.0, Source: "b"},
|
|
}
|
|
|
|
agg, err := wm.Aggregate(prices)
|
|
if err != nil {
|
|
t.Fatalf("Aggregate failed: %v", err)
|
|
}
|
|
|
|
// Total volume should be sum
|
|
if agg.Volume != 1500.0 {
|
|
t.Errorf("Expected volume 1500, got %f", agg.Volume)
|
|
}
|
|
}
|
|
|
|
// === Circuit Breaker Extended Tests ===
|
|
|
|
func TestCircuitBreakerTrippedState(t *testing.T) {
|
|
cb := &CircuitBreaker{
|
|
Symbol: "TEST",
|
|
MaxChange: 10.0,
|
|
Reset: 100 * time.Millisecond,
|
|
}
|
|
|
|
// Set initial price
|
|
cb.Check(100.0)
|
|
|
|
// Trip the breaker
|
|
cb.Check(150.0)
|
|
|
|
if !cb.Tripped {
|
|
t.Error("Breaker should be tripped")
|
|
}
|
|
|
|
// Should reject prices while tripped
|
|
if cb.Check(105.0) {
|
|
t.Error("Should reject prices while tripped")
|
|
}
|
|
}
|
|
|
|
func TestCircuitBreakerAutoReset(t *testing.T) {
|
|
cb := &CircuitBreaker{
|
|
Symbol: "TEST",
|
|
MaxChange: 10.0,
|
|
Reset: 50 * time.Millisecond,
|
|
}
|
|
|
|
// Set initial and trip
|
|
cb.Check(100.0)
|
|
cb.Check(150.0)
|
|
|
|
if !cb.Tripped {
|
|
t.Error("Breaker should be tripped")
|
|
}
|
|
|
|
// Wait for auto-reset
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Should accept now
|
|
if !cb.Check(100.0) {
|
|
t.Error("Breaker should auto-reset")
|
|
}
|
|
}
|
|
|
|
func TestCircuitBreakerUpdateLastPrice(t *testing.T) {
|
|
cb := &CircuitBreaker{
|
|
Symbol: "TEST",
|
|
MaxChange: 10.0,
|
|
Reset: time.Second,
|
|
}
|
|
|
|
cb.Check(100.0)
|
|
cb.Check(105.0)
|
|
|
|
if cb.LastPrice != 105.0 {
|
|
t.Errorf("LastPrice should be 105, got %f", cb.LastPrice)
|
|
}
|
|
}
|
|
|
|
// === Helper Function Tests ===
|
|
|
|
func TestCalcTWAP(t *testing.T) {
|
|
// Empty history
|
|
result := calcTWAP([]*Data{}, 5*time.Minute)
|
|
if result != 0 {
|
|
t.Errorf("TWAP of empty history should be 0, got %f", result)
|
|
}
|
|
|
|
// Recent data
|
|
now := time.Now()
|
|
history := []*Data{
|
|
{Price: 100.0, Timestamp: now.Add(-1 * time.Minute)},
|
|
{Price: 102.0, Timestamp: now.Add(-2 * time.Minute)},
|
|
{Price: 98.0, Timestamp: now.Add(-3 * time.Minute)},
|
|
}
|
|
|
|
result = calcTWAP(history, 5*time.Minute)
|
|
expected := 100.0 // (100 + 102 + 98) / 3
|
|
if result != expected {
|
|
t.Errorf("TWAP should be %f, got %f", expected, result)
|
|
}
|
|
}
|
|
|
|
func TestCalcTWAPWindowFiltering(t *testing.T) {
|
|
now := time.Now()
|
|
history := []*Data{
|
|
{Price: 100.0, Timestamp: now.Add(-1 * time.Minute)},
|
|
{Price: 200.0, Timestamp: now.Add(-10 * time.Minute)}, // Outside window
|
|
}
|
|
|
|
result := calcTWAP(history, 5*time.Minute)
|
|
if result != 100.0 {
|
|
t.Errorf("TWAP should only include recent data, got %f", result)
|
|
}
|
|
}
|
|
|
|
func TestCalcVWAP(t *testing.T) {
|
|
// Empty history
|
|
result := calcVWAP([]*Data{}, 5*time.Minute)
|
|
if result != 0 {
|
|
t.Errorf("VWAP of empty history should be 0, got %f", result)
|
|
}
|
|
|
|
// With volume data
|
|
now := time.Now()
|
|
history := []*Data{
|
|
{Price: 100.0, Volume: 1000.0, Timestamp: now.Add(-1 * time.Minute)},
|
|
{Price: 110.0, Volume: 500.0, Timestamp: now.Add(-2 * time.Minute)},
|
|
}
|
|
|
|
result = calcVWAP(history, 5*time.Minute)
|
|
// VWAP = (100*1000 + 110*500) / (1000 + 500) = 155000 / 1500 = 103.33
|
|
expected := 103.33
|
|
if result < 103.0 || result > 104.0 {
|
|
t.Errorf("VWAP should be ~%f, got %f", expected, result)
|
|
}
|
|
}
|
|
|
|
func TestCalcVWAPZeroVolume(t *testing.T) {
|
|
now := time.Now()
|
|
history := []*Data{
|
|
{Price: 100.0, Volume: 0.0, Timestamp: now.Add(-1 * time.Minute)},
|
|
}
|
|
|
|
result := calcVWAP(history, 5*time.Minute)
|
|
if result != 0 {
|
|
t.Errorf("VWAP with zero volume should be 0, got %f", result)
|
|
}
|
|
}
|
|
|
|
func TestAvg(t *testing.T) {
|
|
// Empty
|
|
if avg([]float64{}) != 0 {
|
|
t.Error("avg of empty should be 0")
|
|
}
|
|
|
|
// Normal
|
|
result := avg([]float64{1.0, 2.0, 3.0})
|
|
if result != 2.0 {
|
|
t.Errorf("avg should be 2.0, got %f", result)
|
|
}
|
|
}
|
|
|
|
func TestStddev(t *testing.T) {
|
|
// Empty
|
|
if stddev([]float64{}, 0) != 0 {
|
|
t.Error("stddev of empty should be 0")
|
|
}
|
|
|
|
// No variance
|
|
values := []float64{5.0, 5.0, 5.0}
|
|
result := stddev(values, 5.0)
|
|
if result != 0 {
|
|
t.Errorf("stddev of same values should be 0, got %f", result)
|
|
}
|
|
|
|
// With variance
|
|
values = []float64{1.0, 2.0, 3.0}
|
|
mean := 2.0
|
|
result = stddev(values, mean)
|
|
// sqrt((1 + 0 + 1) / 3) = sqrt(2/3) ≈ 0.816
|
|
if result < 0.8 || result > 0.9 {
|
|
t.Errorf("stddev should be ~0.816, got %f", result)
|
|
}
|
|
}
|
|
|
|
// === Data Type Tests ===
|
|
|
|
func TestDataFields(t *testing.T) {
|
|
now := time.Now()
|
|
data := &Data{
|
|
Symbol: "BTC-USD",
|
|
Price: 50000.0,
|
|
Bid: 49999.0,
|
|
Ask: 50001.0,
|
|
Volume: 1000000.0,
|
|
High24h: 51000.0,
|
|
Low24h: 49000.0,
|
|
Change24h: 2.5,
|
|
Timestamp: now,
|
|
Source: "exchange",
|
|
Confidence: 0.95,
|
|
Stale: false,
|
|
}
|
|
|
|
if data.Symbol != "BTC-USD" {
|
|
t.Error("Symbol mismatch")
|
|
}
|
|
if data.Price != 50000.0 {
|
|
t.Error("Price mismatch")
|
|
}
|
|
if data.Confidence != 0.95 {
|
|
t.Error("Confidence mismatch")
|
|
}
|
|
}
|
|
|
|
func TestUpdateFields(t *testing.T) {
|
|
now := time.Now()
|
|
update := &Update{
|
|
Symbol: "ETH-USD",
|
|
OldPrice: 3000.0,
|
|
NewPrice: 3100.0,
|
|
Source: "aggregator",
|
|
Timestamp: now,
|
|
Change: 3.33,
|
|
}
|
|
|
|
if update.Symbol != "ETH-USD" {
|
|
t.Error("Symbol mismatch")
|
|
}
|
|
if update.Change != 3.33 {
|
|
t.Error("Change mismatch")
|
|
}
|
|
}
|
|
|
|
func TestAlertFields(t *testing.T) {
|
|
now := time.Now()
|
|
alert := &Alert{
|
|
ID: "alert-1",
|
|
Symbol: "BTC-USD",
|
|
Type: AlertCircuitBreaker,
|
|
Message: "Price movement too large",
|
|
Severity: SeverityCrit,
|
|
Price: 50000.0,
|
|
Timestamp: now,
|
|
}
|
|
|
|
if alert.Type != AlertCircuitBreaker {
|
|
t.Error("Type mismatch")
|
|
}
|
|
if alert.Severity != SeverityCrit {
|
|
t.Error("Severity mismatch")
|
|
}
|
|
}
|
|
|
|
func TestAlertTypes(t *testing.T) {
|
|
if AlertStale != 0 {
|
|
t.Error("AlertStale should be 0")
|
|
}
|
|
if AlertDeviation != 1 {
|
|
t.Error("AlertDeviation should be 1")
|
|
}
|
|
if AlertSourceDown != 2 {
|
|
t.Error("AlertSourceDown should be 2")
|
|
}
|
|
if AlertCircuitBreaker != 3 {
|
|
t.Error("AlertCircuitBreaker should be 3")
|
|
}
|
|
if AlertLowSources != 4 {
|
|
t.Error("AlertLowSources should be 4")
|
|
}
|
|
}
|
|
|
|
func TestSeverityLevels(t *testing.T) {
|
|
if SeverityInfo != 0 {
|
|
t.Error("SeverityInfo should be 0")
|
|
}
|
|
if SeverityWarn != 1 {
|
|
t.Error("SeverityWarn should be 1")
|
|
}
|
|
if SeverityCrit != 2 {
|
|
t.Error("SeverityCrit should be 2")
|
|
}
|
|
}
|
|
|
|
// === Normalize Extended Tests ===
|
|
|
|
func TestNormalizeEdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"", ""},
|
|
{"btcusd", "BTC-USD"},
|
|
{"BTC_USD", "BTC-USD"},
|
|
{"sol/usdt", "SOL-USDT"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := Normalize(tc.input)
|
|
if got != tc.expected {
|
|
t.Errorf("Normalize(%q) = %q, want %q", tc.input, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Benchmarks ===
|
|
|
|
func BenchmarkWeightedMedianAggregate(b *testing.B) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 1,
|
|
MaxDeviation: 0.10,
|
|
}
|
|
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Source: "a", Volume: 1000.0},
|
|
{Symbol: "TEST", Price: 101.0, Source: "b", Volume: 800.0},
|
|
{Symbol: "TEST", Price: 99.5, Source: "c", Volume: 1200.0},
|
|
{Symbol: "TEST", Price: 100.5, Source: "d", Volume: 500.0},
|
|
{Symbol: "TEST", Price: 99.8, Source: "e", Volume: 900.0},
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = wm.Aggregate(prices)
|
|
}
|
|
}
|
|
|
|
func BenchmarkCircuitBreakerCheck(b *testing.B) {
|
|
cb := &CircuitBreaker{
|
|
Symbol: "BENCH",
|
|
MaxChange: 10.0,
|
|
Reset: time.Second,
|
|
}
|
|
cb.Check(100.0) // Initialize
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
cb.Check(100.0 + float64(i%5))
|
|
}
|
|
}
|
|
|
|
func BenchmarkCalcTWAP(b *testing.B) {
|
|
now := time.Now()
|
|
history := make([]*Data, 1000)
|
|
for i := 0; i < 1000; i++ {
|
|
history[i] = &Data{
|
|
Price: 100.0 + float64(i%10),
|
|
Timestamp: now.Add(-time.Duration(i) * time.Second),
|
|
}
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
calcTWAP(history, 5*time.Minute)
|
|
}
|
|
}
|
|
|
|
func BenchmarkNormalize(b *testing.B) {
|
|
symbols := []string{"LUX/USD", "ETH-USDC", "btcusd", "AVAX_USDT"}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
Normalize(symbols[i%len(symbols)])
|
|
}
|
|
}
|
|
|
|
// === Additional Normalize Function Tests for 100% Coverage ===
|
|
|
|
func TestAddAlias(t *testing.T) {
|
|
m := NewSymbolMap()
|
|
|
|
// Add custom alias
|
|
m.AddAlias("WLUX/USD", "LUX-USD")
|
|
|
|
// Verify alias works
|
|
result := m.Map("WLUX/USD")
|
|
if result != "LUX-USD" {
|
|
t.Errorf("AddAlias failed: got %q, want LUX-USD", result)
|
|
}
|
|
}
|
|
|
|
func TestBase(t *testing.T) {
|
|
tests := []struct {
|
|
symbol string
|
|
expected string
|
|
}{
|
|
{"LUX-USD", "LUX"},
|
|
{"ETH-USDC", "ETH"},
|
|
{"BTC/USDT", "BTC"},
|
|
{"SOL", "SOL"}, // No separator
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := Base(tc.symbol)
|
|
if got != tc.expected {
|
|
t.Errorf("Base(%q) = %q, want %q", tc.symbol, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSameBase(t *testing.T) {
|
|
tests := []struct {
|
|
a, b string
|
|
expected bool
|
|
}{
|
|
{"LUX-USD", "LUX-USDC", true},
|
|
{"ETH-USD", "ETH-BTC", true},
|
|
{"BTC-USD", "ETH-USD", false},
|
|
{"AVAX-USDT", "avax/usd", true}, // Case insensitive after normalize
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := SameBase(tc.a, tc.b)
|
|
if got != tc.expected {
|
|
t.Errorf("SameBase(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSameQuote(t *testing.T) {
|
|
tests := []struct {
|
|
a, b string
|
|
expected bool
|
|
}{
|
|
{"LUX-USD", "ETH-USD", true},
|
|
{"BTC-USDC", "SOL-USDC", true},
|
|
{"LUX-USD", "LUX-USDC", false},
|
|
{"eth/usdt", "BTC-USDT", true}, // Case insensitive after normalize
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := SameQuote(tc.a, tc.b)
|
|
if got != tc.expected {
|
|
t.Errorf("SameQuote(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSymbolMapWithAlias(t *testing.T) {
|
|
m := NewSymbolMap()
|
|
|
|
// Test existing alias
|
|
result := m.Map("LUX/USD")
|
|
if result != "LUX-USD" {
|
|
t.Errorf("Map(LUX/USD) = %q, want LUX-USD", result)
|
|
}
|
|
|
|
// Test non-aliased symbol goes through normalize
|
|
result = m.Map("DOGE-BTC")
|
|
if result != "DOGE-BTC" {
|
|
t.Errorf("Map(DOGE-BTC) = %q, want DOGE-BTC", result)
|
|
}
|
|
}
|
|
|
|
func TestOracleAlert(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Start to enable alert sending
|
|
oracle.Start()
|
|
defer oracle.Stop()
|
|
|
|
// Add a circuit breaker
|
|
oracle.breakers["TEST-USD"] = &CircuitBreaker{
|
|
Symbol: "TEST-USD",
|
|
MaxChange: 5.0,
|
|
Reset: time.Second,
|
|
}
|
|
|
|
// The alert function is internal, but we can verify alerts channel works
|
|
alerts := oracle.Alerts()
|
|
if alerts == nil {
|
|
t.Error("Alerts channel should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestXChainSourceName(t *testing.T) {
|
|
src := NewXChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
defer src.Close()
|
|
|
|
if src.Name() != "x-chain" {
|
|
t.Errorf("Name() = %q, want x-chain", src.Name())
|
|
}
|
|
}
|
|
|
|
func TestXChainSourceWeight(t *testing.T) {
|
|
src := NewXChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
defer src.Close()
|
|
|
|
weight := src.Weight()
|
|
if weight <= 0 {
|
|
t.Errorf("Weight() = %f, want > 0", weight)
|
|
}
|
|
}
|
|
|
|
func TestAChainSourceName(t *testing.T) {
|
|
src := NewAChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
defer src.Close()
|
|
|
|
if src.Name() != "a-chain" {
|
|
t.Errorf("Name() = %q, want a-chain", src.Name())
|
|
}
|
|
}
|
|
|
|
func TestAChainSourceWeight(t *testing.T) {
|
|
src := NewAChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
defer src.Close()
|
|
|
|
weight := src.Weight()
|
|
if weight <= 0 {
|
|
t.Errorf("Weight() = %f, want > 0", weight)
|
|
}
|
|
}
|
|
|
|
func TestCChainSourceName(t *testing.T) {
|
|
src := NewCChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
defer src.Close()
|
|
|
|
if src.Name() != "c-chain" {
|
|
t.Errorf("Name() = %q, want c-chain", src.Name())
|
|
}
|
|
}
|
|
|
|
func TestCChainSourceWeight(t *testing.T) {
|
|
src := NewCChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
defer src.Close()
|
|
|
|
weight := src.Weight()
|
|
if weight <= 0 {
|
|
t.Errorf("Weight() = %f, want > 0", weight)
|
|
}
|
|
}
|
|
|
|
func TestChainlinkSourceName(t *testing.T) {
|
|
src := NewChainlinkSource()
|
|
defer src.Close()
|
|
|
|
if src.Name() != "chainlink" {
|
|
t.Errorf("Name() = %q, want chainlink", src.Name())
|
|
}
|
|
}
|
|
|
|
func TestChainlinkSourceWeight(t *testing.T) {
|
|
src := NewChainlinkSource()
|
|
defer src.Close()
|
|
|
|
weight := src.Weight()
|
|
if weight <= 0 {
|
|
t.Errorf("Weight() = %f, want > 0", weight)
|
|
}
|
|
}
|
|
|
|
func TestOracleDataNotFound(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
_, err := oracle.Data("NONEXISTENT")
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent symbol")
|
|
}
|
|
if err != ErrNotFound {
|
|
t.Errorf("Expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOraclePriceStale(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Manually inject stale price
|
|
oracle.mu.Lock()
|
|
oracle.current["TEST-USD"] = &Data{
|
|
Symbol: "TEST-USD",
|
|
Price: 100.0,
|
|
Timestamp: time.Now().Add(-10 * time.Second), // Very stale
|
|
}
|
|
oracle.staleLimit = 1 * time.Second
|
|
oracle.mu.Unlock()
|
|
|
|
// Should return 0 for stale price
|
|
price := oracle.Price("TEST-USD")
|
|
if price != 0 {
|
|
t.Errorf("Expected 0 for stale price, got %f", price)
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedianConfidence(t *testing.T) {
|
|
wm := &WeightedMedian{
|
|
MinSources: 1,
|
|
MaxDeviation: 0.10,
|
|
}
|
|
|
|
// Test confidence with multiple sources (high agreement)
|
|
prices := []*Data{
|
|
{Symbol: "TEST", Price: 100.0, Source: "a"},
|
|
{Symbol: "TEST", Price: 100.1, Source: "b"},
|
|
{Symbol: "TEST", Price: 99.9, Source: "c"},
|
|
}
|
|
|
|
agg, err := wm.Aggregate(prices)
|
|
if err != nil {
|
|
t.Fatalf("Aggregate failed: %v", err)
|
|
}
|
|
|
|
if agg.Confidence <= 0 || agg.Confidence > 1.0 {
|
|
t.Errorf("Confidence should be between 0 and 1, got %f", agg.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestDetectPairUnknown(t *testing.T) {
|
|
// Test symbol that doesn't match any known quote
|
|
result := detectPair("ABCXYZ")
|
|
if result != "ABCXYZ" {
|
|
t.Errorf("detectPair(ABCXYZ) = %q, want ABCXYZ", result)
|
|
}
|
|
}
|
|
|
|
func TestDetectPairShort(t *testing.T) {
|
|
// Test symbol too short to have base+quote
|
|
result := detectPair("USD")
|
|
if result != "USD" {
|
|
t.Errorf("detectPair(USD) = %q, want USD", result)
|
|
}
|
|
}
|
|
|
|
func TestBaseQuoteNoSeparator(t *testing.T) {
|
|
// Test with symbol that can't be split
|
|
base, quote := BaseQuote("INVALID")
|
|
if quote != "" {
|
|
t.Errorf("BaseQuote(INVALID) quote = %q, want empty", quote)
|
|
}
|
|
if base != "INVALID" {
|
|
t.Errorf("BaseQuote(INVALID) base = %q, want INVALID", base)
|
|
}
|
|
}
|
|
|
|
// === Q-Chain Verifier Integration Tests ===
|
|
|
|
func TestOracleSetVerifier(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Initially no verifier
|
|
if oracle.Verifier() != nil {
|
|
t.Error("Expected no verifier initially")
|
|
}
|
|
|
|
// Create and attach verifier
|
|
verifier := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
oracle.SetVerifier(verifier)
|
|
|
|
// Verify it's attached
|
|
if oracle.Verifier() == nil {
|
|
t.Error("Expected verifier to be attached")
|
|
}
|
|
}
|
|
|
|
func TestOracleVerifiedPrice(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Add a source
|
|
src := NewXChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
src.Start()
|
|
defer src.Close()
|
|
oracle.AddSource("x-chain", src)
|
|
|
|
// Watch symbol
|
|
oracle.Watch("LUX-USDC")
|
|
|
|
// Start oracle
|
|
oracle.Start()
|
|
defer oracle.Stop()
|
|
|
|
// Wait for data
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Test without verifier
|
|
vd, err := oracle.VerifiedPrice("LUX-USDC")
|
|
if err != nil {
|
|
t.Fatalf("VerifiedPrice failed: %v", err)
|
|
}
|
|
|
|
if vd.Finalized {
|
|
t.Error("Expected Finalized=false without verifier")
|
|
}
|
|
if vd.Finality != nil {
|
|
t.Error("Expected nil Finality without verifier")
|
|
}
|
|
if vd.Data == nil {
|
|
t.Error("Expected Data to be set")
|
|
}
|
|
}
|
|
|
|
func TestOracleVerifiedPriceWithVerifier(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Add a source
|
|
src := NewXChainSource("http://localhost:9650", "ws://localhost:9650")
|
|
src.Start()
|
|
oracle.AddSource("x-chain", src)
|
|
|
|
// Add verifier
|
|
verifier := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
verifier.Start()
|
|
oracle.SetVerifier(verifier)
|
|
|
|
// Watch symbol
|
|
oracle.Watch("LUX-USDC")
|
|
|
|
// Start oracle
|
|
oracle.Start()
|
|
|
|
// Wait for data
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
// Test with verifier
|
|
vd, err := oracle.VerifiedPrice("LUX-USDC")
|
|
if err != nil {
|
|
t.Fatalf("VerifiedPrice failed: %v", err)
|
|
}
|
|
|
|
// Data should exist
|
|
if vd.Data == nil {
|
|
t.Fatal("Expected Data to be set")
|
|
}
|
|
|
|
// Verifier should have finality (simulated) if source is x-chain
|
|
if vd.Data.Source == "x-chain" {
|
|
if !vd.Finalized {
|
|
t.Error("Expected Finalized=true for x-chain source with active verifier")
|
|
}
|
|
if vd.Finality == nil {
|
|
t.Error("Expected Finality to be set for x-chain source with active verifier")
|
|
}
|
|
}
|
|
|
|
// Cleanup - oracle.Stop() will close verifier, so don't double-close
|
|
oracle.Stop()
|
|
src.Close()
|
|
}
|
|
|
|
func TestOracleStopWithVerifier(t *testing.T) {
|
|
oracle := NewOracle()
|
|
|
|
// Add verifier
|
|
verifier := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
verifier.Start()
|
|
oracle.SetVerifier(verifier)
|
|
|
|
// Start oracle
|
|
oracle.Start()
|
|
|
|
// Stop should close verifier too
|
|
oracle.Stop()
|
|
|
|
// Verify verifier was closed (no panic)
|
|
}
|
|
|
|
func TestSourceToChain(t *testing.T) {
|
|
tests := []struct {
|
|
source string
|
|
expected string
|
|
}{
|
|
{"x-chain", "x-chain"},
|
|
{"c-chain", "c-chain"},
|
|
{"zoo-chain", "zoo-chain"},
|
|
{"a-chain", "a-chain"},
|
|
{"pyth", ""},
|
|
{"chainlink", ""},
|
|
{"unknown", ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := sourceToChain(tc.source)
|
|
if got != tc.expected {
|
|
t.Errorf("sourceToChain(%q) = %q, want %q", tc.source, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifiedDataFields(t *testing.T) {
|
|
now := time.Now()
|
|
vd := &VerifiedData{
|
|
Data: &Data{
|
|
Symbol: "LUX-USDC",
|
|
Price: 15.50,
|
|
Timestamp: now,
|
|
Source: "x-chain",
|
|
Confidence: 0.95,
|
|
},
|
|
Finalized: true,
|
|
Finality: &QuantumFinality{
|
|
BlockHash: "abc123",
|
|
BlockHeight: 12345,
|
|
Finalized: true,
|
|
Latency: 50 * time.Millisecond,
|
|
},
|
|
}
|
|
|
|
if vd.Symbol != "LUX-USDC" {
|
|
t.Error("Symbol mismatch")
|
|
}
|
|
if !vd.Finalized {
|
|
t.Error("Finalized should be true")
|
|
}
|
|
if vd.Finality == nil {
|
|
t.Error("Finality should not be nil")
|
|
}
|
|
if vd.Finality.BlockHeight != 12345 {
|
|
t.Error("BlockHeight mismatch")
|
|
}
|
|
}
|
|
|
|
func TestQChainVerifierBasic(t *testing.T) {
|
|
v := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
|
|
// Start verifier
|
|
if err := v.Start(); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
defer v.Close()
|
|
|
|
// Wait for finality data
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Check health
|
|
if !v.Healthy() {
|
|
t.Error("Verifier should be healthy")
|
|
}
|
|
|
|
// Check supported chains
|
|
chains := v.Chains()
|
|
if len(chains) == 0 {
|
|
t.Error("Expected supported chains")
|
|
}
|
|
|
|
// Check validators
|
|
validators := v.Validators()
|
|
if len(validators) == 0 {
|
|
t.Error("Expected validators")
|
|
}
|
|
}
|
|
|
|
func TestQChainVerifierFinality(t *testing.T) {
|
|
v := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
v.Start()
|
|
defer v.Close()
|
|
|
|
// Wait for finality data
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Get finality for x-chain
|
|
fin, err := v.Finality("x-chain")
|
|
if err != nil {
|
|
t.Fatalf("Finality failed: %v", err)
|
|
}
|
|
|
|
if fin == nil {
|
|
t.Fatal("Expected finality data")
|
|
}
|
|
|
|
if fin.BlockHash == "" {
|
|
t.Error("Expected block hash")
|
|
}
|
|
if fin.Latency == 0 {
|
|
t.Error("Expected latency > 0")
|
|
}
|
|
}
|
|
|
|
func TestQChainVerifierCrossChain(t *testing.T) {
|
|
v := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
v.Start()
|
|
defer v.Close()
|
|
|
|
// Wait for finality data
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Cross-chain verify
|
|
chains := []string{"x-chain", "c-chain", "zoo-chain"}
|
|
allFinalized, results := v.CrossChainVerify(chains)
|
|
|
|
if !allFinalized {
|
|
t.Error("Expected all chains finalized (simulated)")
|
|
}
|
|
if len(results) != len(chains) {
|
|
t.Errorf("Expected %d results, got %d", len(chains), len(results))
|
|
}
|
|
}
|
|
|
|
func TestQChainVerifierSetQuorum(t *testing.T) {
|
|
v := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
|
|
// Default quorum
|
|
v.SetQuorum(5)
|
|
|
|
// This shouldn't panic
|
|
v.Close()
|
|
}
|
|
|
|
func TestQChainVerifierFinalityLatency(t *testing.T) {
|
|
v := NewQChainVerifier("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
v.Start()
|
|
defer v.Close()
|
|
|
|
// Wait for finality data
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
latency := v.FinalityLatency()
|
|
if latency == 0 {
|
|
t.Error("Expected non-zero finality latency")
|
|
}
|
|
}
|
|
|
|
func TestQChainBackwardsCompatibility(t *testing.T) {
|
|
// Test that QChainSource alias works
|
|
var _ *QChainSource = NewQChainSource("http://localhost:9650/ext/bc/Q", "ws://localhost:9650/ext/bc/Q/ws")
|
|
}
|