package market import ( "os" "strings" "testing" "time" "git.mleku.dev/mleku/dendrite/pkg/axiom" "git.mleku.dev/mleku/dendrite/pkg/nostr" ) func TestOrderBookSpread(t *testing.T) { ob := &OrderBook{ Asset: "BTC", Venue: "venue-a", Bids: []OrderEntry{ {Price: 99000, Volume: 1.0, Side: Bid}, {Price: 98900, Volume: 2.0, Side: Bid}, }, Asks: []OrderEntry{ {Price: 99100, Volume: 1.5, Side: Ask}, {Price: 99200, Volume: 0.5, Side: Ask}, }, Time: time.Now(), } if got := ob.BestBid(); got != 99000 { t.Errorf("BestBid = %f, want 99000", got) } if got := ob.BestAsk(); got != 99100 { t.Errorf("BestAsk = %f, want 99100", got) } if got := ob.Spread(); got != 100 { t.Errorf("Spread = %f, want 100", got) } if got := ob.MidPrice(); got != 99050 { t.Errorf("MidPrice = %f, want 99050", got) } } func TestOrderBookDepthImbalance(t *testing.T) { ob := &OrderBook{ Bids: []OrderEntry{ {Volume: 10}, {Volume: 5}, }, Asks: []OrderEntry{ {Volume: 5}, {Volume: 5}, }, } imb := ob.DepthImbalance(5) // Bid vol = 15, Ask vol = 10, total = 25. Ratio = 15/25 = 0.6 if imb < 0.59 || imb > 0.61 { t.Errorf("DepthImbalance = %f, want ~0.6", imb) } } func TestOrderBookEmpty(t *testing.T) { ob := &OrderBook{} if ob.BestBid() != 0 { t.Errorf("empty BestBid should be 0") } if ob.BestAsk() != 0 { t.Errorf("empty BestAsk should be 0") } if ob.Spread() != 0 { t.Errorf("empty Spread should be 0") } if ob.DepthImbalance(5) != 0.5 { t.Errorf("empty DepthImbalance should be 0.5") } } func TestOrderBookToElements(t *testing.T) { ob := &OrderBook{ Asset: "ETH", Venue: "venue-b", Bids: []OrderEntry{ {Price: 3000, Volume: 10, Side: Bid}, }, Asks: []OrderEntry{ {Price: 3010, Volume: 5, Side: Ask}, }, Time: time.Unix(1700000000, 0), } elems := OrderBookToElements(ob) // Verify all elements satisfy axiom.Element. for _, e := range elems { var _ axiom.Element = e // compile-time check if e.Type() == "" { t.Error("element has empty type tag") } if e.Value() == nil { t.Error("element has nil value") } } // Check expected element types are present. types := make(map[string]bool) for _, e := range elems { types[e.Type()] = true } for _, want := range []string{"asset", "venue", "timestamp", "bid", "ask", "spread", "depth-imbalance"} { if !types[want] { t.Errorf("missing element type %q", want) } } } // TestCoherence verifies the Stage 8 coherence criterion: price elements // and event elements are structurally indistinguishable. Both produce // []axiom.Element through the same interface. The lattice cannot tell // them apart. func TestCoherence(t *testing.T) { // Decompose a Nostr event. ev := &nostr.Event{ ID: "abcdef1234567890", Pubkey: "pubkeyhex", CreatedAt: 1700000000, Kind: 1, Content: "Bitcoin is going up $BTC", Tags: [][]string{{"t", "bitcoin"}}, } eventElems := nostr.EventToElements(ev) // Decompose an order book. ob := &OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 99000, Volume: 1, Side: Bid}}, Asks: []OrderEntry{{Price: 99100, Volume: 1, Side: Ask}}, Time: time.Unix(1700000000, 0), } priceElems := OrderBookToElements(ob) // Both must produce non-empty element slices. if len(eventElems) == 0 { t.Fatal("event decomposition produced no elements") } if len(priceElems) == 0 { t.Fatal("price decomposition produced no elements") } // All elements must satisfy the same interface. // The lattice sees them identically. allElems := make([]axiom.Element, 0, len(eventElems)+len(priceElems)) allElems = append(allElems, eventElems...) allElems = append(allElems, priceElems...) for i, e := range allElems { if e.Type() == "" { t.Errorf("element %d has empty type", i) } if e.Value() == nil { t.Errorf("element %d has nil value", i) } } // Both domains share the "timestamp" element type. // This is the bonding surface where events and prices meet. hasTimestamp := func(elems []axiom.Element) bool { for _, e := range elems { if e.Type() == "timestamp" { return true } } return false } if !hasTimestamp(eventElems) { t.Error("event elements missing timestamp") } if !hasTimestamp(priceElems) { t.Error("price elements missing timestamp") } } func TestCrossVenueSpread(t *testing.T) { bookA := &OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 99100, Volume: 1}}, Asks: []OrderEntry{{Price: 99200, Volume: 1}}, } bookB := &OrderBook{ Asset: "BTC", Venue: "exchange-b", Bids: []OrderEntry{{Price: 99000, Volume: 1}}, Asks: []OrderEntry{{Price: 99050, Volume: 1}}, } cs := CompareBooksForSpread(bookA, bookB) // A's bid (99100) > B's ask (99050) → profit of 50 buying on B, selling on A. ab := cs.SpreadAB() if ab != 50 { t.Errorf("SpreadAB = %f, want 50", ab) } // B's bid (99000) < A's ask (99200) → no profit the other way. ba := cs.SpreadBA() if ba >= 0 { t.Errorf("SpreadBA = %f, want negative", ba) } spread, buyV, sellV := cs.BestSpread() if spread != 50 { t.Errorf("BestSpread = %f, want 50", spread) } if buyV != "exchange-b" { t.Errorf("buy venue = %s, want exchange-b", buyV) } if sellV != "exchange-a" { t.Errorf("sell venue = %s, want exchange-a", sellV) } } func TestExtractAssetMentions(t *testing.T) { ev := &nostr.Event{ ID: "test123", Pubkey: "author1", CreatedAt: 1700000000, Content: "I'm bullish on $BTC and $ETH right now", } known := map[string]bool{"BTC": true, "ETH": true, "SOL": true} mentions := ExtractAssetMentions(ev, known) if len(mentions) != 2 { t.Fatalf("expected 2 mentions, got %d", len(mentions)) } assets := make(map[string]bool) for _, m := range mentions { assets[m.Asset] = true if m.EventID != "test123" { t.Errorf("mention event ID = %s, want test123", m.EventID) } } if !assets["BTC"] || !assets["ETH"] { t.Errorf("expected BTC and ETH mentions, got %v", assets) } } func TestAuthorWeight(t *testing.T) { g := nostr.NewEventGraph() // Author with no references → baseline weight. w := AuthorWeight("unknown-pubkey", g) if w != 0.1 { t.Errorf("unknown author weight = %f, want 0.1", w) } // Add events that reference a pubkey. for i := range 10 { ev := &nostr.Event{ ID: "event" + string(rune('A'+i)), Pubkey: "other-author", Kind: 1, Tags: [][]string{{"p", "referenced-author"}}, } g.Add(ev) } g.Resolve() w = AuthorWeight("referenced-author", g) if w <= 0.1 { t.Errorf("referenced author weight should be > 0.1, got %f", w) } if w > 1.0 { t.Errorf("author weight should be <= 1.0, got %f", w) } } func TestAggregateSentiment(t *testing.T) { signals := []SentimentSignal{ {Weight: 1.0, Direction: 0.8}, // bullish, high weight {Weight: 0.5, Direction: -0.5}, // bearish, lower weight {Weight: 0.3, Direction: 0.3}, // slightly bullish, low weight } s := AggregateSentiment(signals) // Weighted: (0.8*1.0 + -0.5*0.5 + 0.3*0.3) / (1.0+0.5+0.3) // = (0.8 - 0.25 + 0.09) / 1.8 = 0.64 / 1.8 ≈ 0.356 if s < 0.3 || s > 0.4 { t.Errorf("AggregateSentiment = %f, want ~0.356", s) } } func TestDetectDislocation(t *testing.T) { ob := &OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 99000, Volume: 10}}, // heavy bids Asks: []OrderEntry{{Price: 99100, Volume: 1}}, // light asks } // Imbalance: 10/(10+1) ≈ 0.91 → price direction ≈ +0.82 // Sentiment: strongly bearish signals := []SentimentSignal{ {Weight: 1.0, Direction: -0.8}, {Weight: 0.8, Direction: -0.7}, } d := DetectDislocation("BTC", ob, signals) if d == nil { t.Fatal("expected dislocation, got nil") } if d.Magnitude <= 0 { t.Error("dislocation magnitude should be > 0") } if d.Direction != -1 { t.Errorf("direction = %d, want -1 (sentiment bearish, price bullish)", d.Direction) } } func TestDislocationToElements(t *testing.T) { d := &Dislocation{ Asset: "ETH", PriceMid: 3000, Sentiment: 0.5, Magnitude: 0.7, Direction: 1, Time: time.Now(), } elems := DislocationToElements(d) if len(elems) == 0 { t.Fatal("dislocation produced no elements") } for _, e := range elems { var _ axiom.Element = e if e.Type() == "" { t.Error("element has empty type") } } types := make(map[string]bool) for _, e := range elems { types[e.Type()] = true } if !types["dislocation-magnitude"] { t.Error("missing dislocation-magnitude element") } if !types["sentiment"] { t.Error("missing sentiment element") } } func TestCrossSpreadToElements(t *testing.T) { cs := &CrossSpread{ Asset: "BTC", VenueA: "exchange-a", VenueB: "exchange-b", BidA: 99100, AskB: 99050, BidB: 99000, AskA: 99200, Time: time.Now(), } elems := CrossSpreadToElements(cs) if len(elems) == 0 { t.Fatal("cross-spread produced no elements") } types := make(map[string]bool) for _, e := range elems { var _ axiom.Element = e types[e.Type()] = true } if !types["cross-spread"] { t.Error("missing cross-spread element") } if !types["buy-venue"] { t.Error("missing buy-venue element") } } func TestOrderEntryToElements(t *testing.T) { entry := &OrderEntry{ Price: 42000, Volume: 2.5, Side: Bid, Venue: "exchange-c", Time: time.Unix(1700000000, 0), } elems := OrderEntryToElements(entry) if len(elems) != 5 { t.Fatalf("expected 5 elements, got %d", len(elems)) } for _, e := range elems { var _ axiom.Element = e } } func TestVenueGraphUpdate(t *testing.T) { vg := NewVenueGraph() ob := &OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 99000, Volume: 1}}, Asks: []OrderEntry{{Price: 99100, Volume: 1}}, Time: time.Now(), } vg.Update(ob) assets := vg.Assets() if len(assets) != 1 || assets[0] != "BTC" { t.Errorf("expected [BTC], got %v", assets) } venues := vg.VenuesForAsset("BTC") if len(venues) != 1 || venues[0] != "exchange-a" { t.Errorf("expected [exchange-a], got %v", venues) } got := vg.Book("BTC", "exchange-a") if got == nil { t.Fatal("expected book, got nil") } if got.BestBid() != 99000 { t.Errorf("book best bid = %f, want 99000", got.BestBid()) } // Missing asset/venue returns nil. if vg.Book("ETH", "exchange-a") != nil { t.Error("expected nil for missing asset") } if vg.Book("BTC", "exchange-z") != nil { t.Error("expected nil for missing venue") } } func TestVenueGraphDetectDislocations(t *testing.T) { vg := NewVenueGraph() // Exchange A: BTC bid 99100, ask 99200 vg.Update(&OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 99100, Volume: 1}}, Asks: []OrderEntry{{Price: 99200, Volume: 1}}, Time: time.Now(), }) // Exchange B: BTC bid 99000, ask 99050 // A's bid (99100) > B's ask (99050) → spread of 50 vg.Update(&OrderBook{ Asset: "BTC", Venue: "exchange-b", Bids: []OrderEntry{{Price: 99000, Volume: 1}}, Asks: []OrderEntry{{Price: 99050, Volume: 1}}, Time: time.Now(), }) // Threshold 0 → should detect the 50-spread dislocation. dislocations := vg.DetectDislocations(0) if len(dislocations) != 1 { t.Fatalf("expected 1 dislocation, got %d", len(dislocations)) } spread, _, _ := dislocations[0].BestSpread() if spread != 50 { t.Errorf("dislocation spread = %f, want 50", spread) } // Threshold above the spread → no dislocations. dislocations = vg.DetectDislocations(100) if len(dislocations) != 0 { t.Errorf("expected 0 dislocations with high threshold, got %d", len(dislocations)) } } func TestVenueGraphNoDislocationsOnSingleVenue(t *testing.T) { vg := NewVenueGraph() vg.Update(&OrderBook{ Asset: "ETH", Venue: "solo-exchange", Bids: []OrderEntry{{Price: 3000, Volume: 1}}, Asks: []OrderEntry{{Price: 3010, Volume: 1}}, }) dislocations := vg.DetectDislocations(0) if len(dislocations) != 0 { t.Errorf("single venue should produce no dislocations, got %d", len(dislocations)) } } func TestDislocationLog(t *testing.T) { dl := NewDislocationLog("") // no file, in-memory only cs := &CrossSpread{ Asset: "BTC", VenueA: "exchange-a", VenueB: "exchange-b", BidA: 99100, AskB: 99050, BidB: 99000, AskA: 99200, Time: time.Now(), } dl.Record(cs) dl.Record(cs) if dl.Count() != 2 { t.Errorf("expected 2 entries, got %d", dl.Count()) } entries := dl.Entries() if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].Asset != "BTC" { t.Errorf("entry asset = %s, want BTC", entries[0].Asset) } if entries[0].Spread != 50 { t.Errorf("entry spread = %f, want 50", entries[0].Spread) } if entries[0].BuyVenue != "exchange-b" { t.Errorf("entry buy venue = %s, want exchange-b", entries[0].BuyVenue) } if entries[0].SellVenue != "exchange-a" { t.Errorf("entry sell venue = %s, want exchange-a", entries[0].SellVenue) } } func TestDislocationLogFileWrite(t *testing.T) { path := t.TempDir() + "/dislocations.log" dl := NewDislocationLog(path) cs := &CrossSpread{ Asset: "ETH", VenueA: "a", VenueB: "b", BidA: 3010, AskB: 3000, BidB: 2990, AskA: 3020, Time: time.Now(), } dl.Record(cs) // Verify file was written. data, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read log file: %v", err) } if len(data) == 0 { t.Error("log file is empty") } content := string(data) if !strings.Contains(content, "ETH") { t.Errorf("log file should contain ETH, got: %s", content) } if !strings.Contains(content, "spread=") { t.Errorf("log file should contain spread=, got: %s", content) } } func TestFeedParseMessage(t *testing.T) { f := NewFeed("wss://example.com/ws", "test-venue", "BTC") msg := []byte(`{ "bids": [["99000", "1.5"], ["98900", "2.0"]], "asks": [["99100", "1.0"], ["99200", "0.5"]] }`) ob, err := f.parseMessage(msg) if err != nil { t.Fatalf("parseMessage error: %v", err) } if ob == nil { t.Fatal("expected order book, got nil") } if ob.Asset != "BTC" { t.Errorf("asset = %s, want BTC", ob.Asset) } if ob.Venue != "test-venue" { t.Errorf("venue = %s, want test-venue", ob.Venue) } if len(ob.Bids) != 2 { t.Errorf("expected 2 bids, got %d", len(ob.Bids)) } if len(ob.Asks) != 2 { t.Errorf("expected 2 asks, got %d", len(ob.Asks)) } if ob.BestBid() != 99000 { t.Errorf("best bid = %f, want 99000", ob.BestBid()) } if ob.BestAsk() != 99100 { t.Errorf("best ask = %f, want 99100", ob.BestAsk()) } } func TestFeedParseMessageEmpty(t *testing.T) { f := NewFeed("wss://example.com/ws", "test-venue", "BTC") // Non-order-book message returns nil, nil. ob, err := f.parseMessage([]byte(`{"type": "heartbeat"}`)) if err != nil { t.Fatalf("unexpected error: %v", err) } if ob != nil { t.Error("expected nil for non-orderbook message") } // Invalid JSON returns error. _, err = f.parseMessage([]byte(`{broken`)) if err == nil { t.Error("expected error for invalid JSON") } } func TestFeedParseMessageZeroPrice(t *testing.T) { f := NewFeed("wss://example.com/ws", "test-venue", "ETH") // Zero-price entries should be filtered out. msg := []byte(`{ "bids": [["0", "1.0"], ["3000", "2.0"]], "asks": [["3010", "1.0"], ["0", "0.5"]] }`) ob, err := f.parseMessage(msg) if err != nil { t.Fatalf("parseMessage error: %v", err) } if len(ob.Bids) != 1 { t.Errorf("expected 1 bid (zero filtered), got %d", len(ob.Bids)) } if len(ob.Asks) != 1 { t.Errorf("expected 1 ask (zero filtered), got %d", len(ob.Asks)) } } // --- Stage 16: Economic Agency tests --- func TestPositionApplyFill(t *testing.T) { p := &Position{Asset: "BTC"} // Open long: buy 1 BTC @ 100000. p.ApplyFill(&Fill{ Asset: "BTC", Side: Buy, Price: 100000, Quantity: 1, Fee: 10, }) if p.Quantity != 1 { t.Errorf("quantity = %f, want 1", p.Quantity) } if p.AvgEntry != 100000 { t.Errorf("avg entry = %f, want 100000", p.AvgEntry) } // Add to position: buy 1 more @ 102000. p.ApplyFill(&Fill{ Asset: "BTC", Side: Buy, Price: 102000, Quantity: 1, Fee: 10, }) if p.Quantity != 2 { t.Errorf("quantity = %f, want 2", p.Quantity) } if p.AvgEntry != 101000 { t.Errorf("avg entry = %f, want 101000 (weighted avg)", p.AvgEntry) } // Close half: sell 1 @ 105000. Realized = 1 * (105000 - 101000) = 4000 minus fees. p.ApplyFill(&Fill{ Asset: "BTC", Side: Sell, Price: 105000, Quantity: 1, Fee: 10, }) if p.Quantity != 1 { t.Errorf("quantity after partial close = %f, want 1", p.Quantity) } // Realized: 4000 - 30 (cumulative fees) = 3970 if p.Realized < 3960 || p.Realized > 3980 { t.Errorf("realized = %f, want ~3970", p.Realized) } } func TestPositionMarkToMarket(t *testing.T) { p := &Position{Asset: "ETH", Quantity: 10, AvgEntry: 3000} mtm := p.MarkToMarket(3100) // 10 * (3100 - 3000) = 1000 if mtm != 1000 { t.Errorf("mark to market = %f, want 1000", mtm) } mtm = p.MarkToMarket(2900) if mtm != -1000 { t.Errorf("mark to market = %f, want -1000", mtm) } } func TestPositionEmpty(t *testing.T) { p := &Position{Asset: "SOL"} if p.MarkToMarket(100) != 0 { t.Error("empty position should have zero MTM") } if p.TotalPnL(100) != 0 { t.Error("empty position should have zero total PnL") } } func TestPortfolio(t *testing.T) { pf := NewPortfolio() pf.RecordFill(Fill{ Asset: "BTC", Side: Buy, Price: 100000, Quantity: 1, Fee: 10, Venue: "exchange-a", FilledAt: time.Now(), }) pf.RecordFill(Fill{ Asset: "ETH", Side: Buy, Price: 3000, Quantity: 5, Fee: 1.5, Venue: "exchange-b", FilledAt: time.Now(), }) if pf.FillCount() != 2 { t.Errorf("fill count = %d, want 2", pf.FillCount()) } btc := pf.Position("BTC") if btc == nil || btc.Quantity != 1 { t.Error("expected BTC position with quantity 1") } eth := pf.Position("ETH") if eth == nil || eth.Quantity != 5 { t.Error("expected ETH position with quantity 5") } if pf.Position("SOL") != nil { t.Error("expected nil for untracked asset") } costs := pf.TotalCosts() if costs.ExchangeFees != 11.5 { t.Errorf("exchange fees = %f, want 11.5", costs.ExchangeFees) } } func TestPortfolioAddCost(t *testing.T) { pf := NewPortfolio() pf.AddCost(5.0, 2.0, 1.0) pf.AddCost(3.0, 0, 0.5) costs := pf.TotalCosts() if costs.APIFees != 8.0 { t.Errorf("API fees = %f, want 8.0", costs.APIFees) } if costs.Compute != 2.0 { t.Errorf("compute = %f, want 2.0", costs.Compute) } if costs.Bandwidth != 1.5 { t.Errorf("bandwidth = %f, want 1.5", costs.Bandwidth) } if costs.Total() != 11.5 { t.Errorf("total costs = %f, want 11.5", costs.Total()) } } func TestExecutorPaperTrading(t *testing.T) { ex := NewExecutor(100000) // 100k capital order := TradeOrder{ ID: "test-1", Asset: "BTC", Side: Buy, Venue: "exchange-a", Price: 50000, Quantity: 0.01, } fill, err := ex.Submit(order) if err != nil { t.Fatalf("submit error: %v", err) } if fill == nil { t.Fatal("expected fill") } if fill.Price != 50000 { t.Errorf("fill price = %f, want 50000", fill.Price) } if fill.Fee == 0 { t.Error("expected simulated fee") } pos := ex.Portfolio.Position("BTC") if pos == nil || pos.Quantity != 0.01 { t.Error("expected BTC position with quantity 0.01") } } func TestExecutorPerTradeLimitReject(t *testing.T) { ex := NewExecutor(10000) // 10k capital, max per trade = 200 order := TradeOrder{ ID: "big-order", Asset: "BTC", Side: Buy, Venue: "exchange-a", Price: 50000, Quantity: 1, // notional 50000 >> 200 } _, err := ex.Submit(order) if err == nil { t.Error("expected rejection for exceeding per-trade limit") } } func TestExecutorDrawdownCircuitBreaker(t *testing.T) { ex := NewExecutor(10000) // 10k, drawdown limit 5% = 500 ex.MaxPerTrade = 100000 ex.MaxAggregate = 200000 // Buy high: 10 units @ 1000 = 10000 notional. ex.Submit(TradeOrder{ ID: "buy", Asset: "BTC", Side: Buy, Venue: "a", Price: 1000, Quantity: 10, }) // Sell low: realize loss of 10 * (1000 - 940) = 600 > 500 (5% of 10k). ex.Submit(TradeOrder{ ID: "sell-loss", Asset: "BTC", Side: Sell, Venue: "a", Price: 940, Quantity: 10, }) halted, reason := ex.IsHalted() if !halted { t.Error("expected halt from drawdown circuit breaker") } if reason == "" { t.Error("expected halt reason") } // Further trades should be rejected. _, err := ex.Submit(TradeOrder{ ID: "after-halt", Asset: "ETH", Side: Buy, Venue: "a", Price: 10, Quantity: 1, }) if err == nil { t.Error("expected rejection while halted") } // Resume clears halt. ex.Resume() halted, _ = ex.IsHalted() if halted { t.Error("expected halt cleared after resume") } } func TestExecutorKillFile(t *testing.T) { killPath := t.TempDir() + "/STOP" ex := NewExecutor(100000) ex.KillFilePath = killPath // No kill file — should work. _, err := ex.Submit(TradeOrder{ ID: "ok", Asset: "BTC", Side: Buy, Venue: "a", Price: 100, Quantity: 0.01, }) if err != nil { t.Fatalf("expected success without kill file: %v", err) } // Create kill file. os.WriteFile(killPath, []byte("stop"), 0o644) _, err = ex.Submit(TradeOrder{ ID: "killed", Asset: "BTC", Side: Buy, Venue: "a", Price: 100, Quantity: 0.01, }) if err == nil { t.Error("expected rejection with kill file present") } halted, reason := ex.IsHalted() if !halted || reason != "kill file" { t.Errorf("expected halted with kill file reason, got halted=%v reason=%q", halted, reason) } } func TestEconomicFitness(t *testing.T) { ex := NewExecutor(100000) ex.SetGeneration(5) ex.MaxPerTrade = 100000 ex.MaxAggregate = 200000 // Profitable trade: buy 1 @ 100, sell 1 @ 110. ex.Submit(TradeOrder{ ID: "buy-1", Asset: "BTC", Side: Buy, Venue: "a", Price: 100, Quantity: 1, }) ex.Submit(TradeOrder{ ID: "sell-1", Asset: "BTC", Side: Sell, Venue: "a", Price: 110, Quantity: 1, }) // Add operational costs. ex.Portfolio.AddCost(1.0, 0.5, 0.2) ef := ex.EconomicFitness() if ef.Generation != 5 { t.Errorf("generation = %d, want 5", ef.Generation) } if ef.TradeCount != 2 { t.Errorf("trade count = %d, want 2", ef.TradeCount) } if ef.NetRevenue() <= 0 { t.Errorf("expected positive net revenue, got %f", ef.NetRevenue()) } if !ef.IsViable() { t.Error("expected viable with profitable trade") } } func TestEconomicFitnessNotViable(t *testing.T) { ef := &EconomicFitness{ Realized: 10, Costs: CostLedger{ExchangeFees: 5, APIFees: 10}, TradeCount: 2, } if ef.IsViable() { t.Error("expected not viable when costs exceed revenue") } if ef.NetRevenue() != -5 { t.Errorf("net revenue = %f, want -5", ef.NetRevenue()) } } func TestFillToElements(t *testing.T) { fill := &Fill{ OrderID: "fill-1", Asset: "BTC", Side: Buy, Venue: "exchange-a", Price: 50000, Quantity: 0.5, Fee: 25, FilledAt: time.Unix(1700000000, 0), } elems := FillToElements(fill) if len(elems) == 0 { t.Fatal("fill produced no elements") } types := make(map[string]bool) for _, e := range elems { var _ axiom.Element = e types[e.Type()] = true } for _, want := range []string{"asset", "trade-side", "trade-price", "trade-quantity", "venue", "timestamp"} { if !types[want] { t.Errorf("missing element type %q", want) } } } func TestEconomicFitnessToElements(t *testing.T) { ef := &EconomicFitness{ Generation: 3, Realized: 500, Costs: CostLedger{ExchangeFees: 20, APIFees: 5}, TradeCount: 10, WinCount: 7, LossCount: 3, Time: time.Unix(1700000000, 0), } elems := EconomicFitnessToElements(ef) if len(elems) == 0 { t.Fatal("economic fitness produced no elements") } types := make(map[string]bool) for _, e := range elems { var _ axiom.Element = e types[e.Type()] = true } for _, want := range []string{"generation", "realized-pnl", "net-revenue", "trade-count", "win-rate", "economic-viable"} { if !types[want] { t.Errorf("missing element type %q", want) } } } func TestExecutorLogWrite(t *testing.T) { logPath := t.TempDir() + "/trades.log" ex := NewExecutor(100000) ex.LogPath = logPath ex.Submit(TradeOrder{ ID: "logged", Asset: "BTC", Side: Buy, Venue: "exchange-a", Price: 50000, Quantity: 0.01, }) data, err := os.ReadFile(logPath) if err != nil { t.Fatalf("failed to read trade log: %v", err) } content := string(data) if !strings.Contains(content, "BTC") { t.Errorf("trade log should contain BTC, got: %s", content) } if !strings.Contains(content, "paper") { t.Errorf("trade log should contain mode=paper, got: %s", content) } } // --- Stage 17: Structural Correction tests --- func TestPriceFeedConsolidate(t *testing.T) { vg := NewVenueGraph() vg.Update(&OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 99100, Volume: 5}}, Asks: []OrderEntry{{Price: 99200, Volume: 3}}, Time: time.Now(), }) vg.Update(&OrderBook{ Asset: "BTC", Venue: "exchange-b", Bids: []OrderEntry{{Price: 99000, Volume: 8}}, Asks: []OrderEntry{{Price: 99050, Volume: 10}}, Time: time.Now(), }) pf := NewPriceFeed() pf.Consolidate(vg) snap := pf.Snapshot("BTC") if snap == nil { t.Fatal("expected consolidated snapshot") } // Best bid: 99100 on exchange-a. Best ask: 99050 on exchange-b. if snap.BestBid != 99100 { t.Errorf("best bid = %f, want 99100", snap.BestBid) } if snap.BidVenue != "exchange-a" { t.Errorf("bid venue = %s, want exchange-a", snap.BidVenue) } if snap.BestAsk != 99050 { t.Errorf("best ask = %f, want 99050", snap.BestAsk) } if snap.AskVenue != "exchange-b" { t.Errorf("ask venue = %s, want exchange-b", snap.AskVenue) } if len(snap.Venues) != 2 { t.Errorf("expected 2 venues, got %d", len(snap.Venues)) } } func TestPriceFeedSubscribe(t *testing.T) { vg := NewVenueGraph() vg.Update(&OrderBook{ Asset: "ETH", Venue: "a", Bids: []OrderEntry{{Price: 3000, Volume: 1}}, Asks: []OrderEntry{{Price: 3010, Volume: 1}}, }) pf := NewPriceFeed() ch := pf.Subscribe() pf.Consolidate(vg) select { case snap := <-ch: if snap.Asset != "ETH" { t.Errorf("subscriber received asset %s, want ETH", snap.Asset) } default: t.Error("subscriber should have received a snapshot") } } func TestPriceFeedAssets(t *testing.T) { vg := NewVenueGraph() vg.Update(&OrderBook{Asset: "BTC", Venue: "a", Bids: []OrderEntry{{Price: 100, Volume: 1}}, Asks: []OrderEntry{{Price: 101, Volume: 1}}}) vg.Update(&OrderBook{Asset: "ETH", Venue: "a", Bids: []OrderEntry{{Price: 3000, Volume: 1}}, Asks: []OrderEntry{{Price: 3010, Volume: 1}}}) pf := NewPriceFeed() pf.Consolidate(vg) assets := pf.Assets() if len(assets) != 2 { t.Errorf("expected 2 assets, got %d", len(assets)) } } func TestConsolidatedSnapshotToElements(t *testing.T) { snap := &ConsolidatedSnapshot{ Asset: "BTC", BestBid: 99100, BidVenue: "exchange-a", BestAsk: 99050, AskVenue: "exchange-b", Spread: -50, Venues: []VenueDepth{ {Venue: "exchange-a", BidDepth: 5, AskDepth: 3, Spread: 100}, {Venue: "exchange-b", BidDepth: 8, AskDepth: 10, Spread: 50}, }, Time: time.Unix(1700000000, 0), } elems := ConsolidatedSnapshotToElements(snap) if len(elems) == 0 { t.Fatal("snapshot produced no elements") } types := make(map[string]bool) for _, e := range elems { var _ axiom.Element = e types[e.Type()] = true } for _, want := range []string{"asset", "consolidated-bid", "consolidated-ask", "consolidated-spread", "venue-count"} { if !types[want] { t.Errorf("missing element type %q", want) } } } func TestReputation(t *testing.T) { rep := NewReputation() // Author makes 5 predictions, 4 correct. for i := range 5 { rep.RecordSignal(Prediction{ Pubkey: "author-a", Asset: "BTC", Direction: 1, Time: time.Now().Add(time.Duration(i) * time.Minute), }) } for i := range 4 { rep.RecordOutcome(Outcome{ Prediction: Prediction{Pubkey: "author-a"}, Correct: true, PriceMove: float64(i+1) * 0.5, ResolvedAt: time.Now(), }) } rep.RecordOutcome(Outcome{ Prediction: Prediction{Pubkey: "author-a"}, Correct: false, PriceMove: -0.3, ResolvedAt: time.Now(), }) rec := rep.Author("author-a") if rec == nil { t.Fatal("expected author record") } if rec.Signals != 5 { t.Errorf("signals = %d, want 5", rec.Signals) } if rec.Correct != 4 { t.Errorf("correct = %d, want 4", rec.Correct) } if rec.Incorrect != 1 { t.Errorf("incorrect = %d, want 1", rec.Incorrect) } // Accuracy = 4/5 = 0.8 if rec.Accuracy < 0.79 || rec.Accuracy > 0.81 { t.Errorf("accuracy = %f, want 0.8", rec.Accuracy) } // Score: 0.8 * (5 / (5+10)) = 0.8 * 0.333 ≈ 0.267 if rec.Score < 0.2 || rec.Score > 0.3 { t.Errorf("score = %f, want ~0.267", rec.Score) } } func TestReputationTopAuthors(t *testing.T) { rep := NewReputation() // Author A: 10 signals, 8 correct. for range 10 { rep.RecordSignal(Prediction{Pubkey: "a", Asset: "BTC", Direction: 1, Time: time.Now()}) } for range 8 { rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "a"}, Correct: true}) } for range 2 { rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "a"}, Correct: false}) } // Author B: 10 signals, 5 correct. for range 10 { rep.RecordSignal(Prediction{Pubkey: "b", Asset: "BTC", Direction: -1, Time: time.Now()}) } for range 5 { rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "b"}, Correct: true}) } for range 5 { rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "b"}, Correct: false}) } top := rep.TopAuthors(2) if len(top) != 2 { t.Fatalf("expected 2 top authors, got %d", len(top)) } if top[0].Pubkey != "a" { t.Errorf("top author should be 'a' (higher accuracy), got %q", top[0].Pubkey) } } func TestReputationUnknownAuthor(t *testing.T) { rep := NewReputation() if rep.Author("nonexistent") != nil { t.Error("expected nil for unknown author") } if rep.AuthorCount() != 0 { t.Errorf("expected 0 authors, got %d", rep.AuthorCount()) } } func TestAuthorRecordToElements(t *testing.T) { rec := &AuthorRecord{ Pubkey: "abc123", Signals: 20, Correct: 15, Incorrect: 5, Accuracy: 0.75, Score: 0.5, LastSignal: time.Unix(1700000000, 0), } elems := AuthorRecordToElements(rec) if len(elems) == 0 { t.Fatal("author record produced no elements") } types := make(map[string]bool) for _, e := range elems { var _ axiom.Element = e types[e.Type()] = true } for _, want := range []string{"pubkey", "signal-count", "accuracy", "reputation-score"} { if !types[want] { t.Errorf("missing element type %q", want) } } } func TestRevenueAccounting(t *testing.T) { ra := NewRevenueAccounting() ra.Record(RevenueEntry{ Source: ArbitrageRevenue, Amount: 100, Asset: "BTC", Note: "cross-venue arb", Time: time.Now(), }) ra.Record(RevenueEntry{ Source: InfrastructureRevenue, Amount: 50, Asset: "BTC", Note: "feed subscription", Time: time.Now(), }) ra.Record(RevenueEntry{ Source: ArbitrageRevenue, Amount: 30, Asset: "ETH", Note: "dislocation", Time: time.Now(), }) s := ra.Summary() if s.Arbitrage != 130 { t.Errorf("arbitrage = %f, want 130", s.Arbitrage) } if s.Infrastructure != 50 { t.Errorf("infrastructure = %f, want 50", s.Infrastructure) } if s.Total != 180 { t.Errorf("total = %f, want 180", s.Total) } // InfraRatio = 50/180 ≈ 0.278 if s.InfraRatio < 0.27 || s.InfraRatio > 0.29 { t.Errorf("infra ratio = %f, want ~0.278", s.InfraRatio) } if s.EntryCount != 3 { t.Errorf("entry count = %d, want 3", s.EntryCount) } } func TestRevenueAccountingTransition(t *testing.T) { ra := NewRevenueAccounting() // Initially not transitioning. if ra.IsTransitioning() { t.Error("should not be transitioning with no entries") } // Add more infrastructure than arbitrage. ra.Record(RevenueEntry{Source: ArbitrageRevenue, Amount: 40}) ra.Record(RevenueEntry{Source: InfrastructureRevenue, Amount: 60}) if !ra.IsTransitioning() { t.Error("should be transitioning when infra > arb") } } func TestRevenueSummaryToElements(t *testing.T) { s := &RevenueSummary{ Arbitrage: 100, Infrastructure: 200, Total: 300, InfraRatio: 0.667, EntryCount: 5, } elems := RevenueSummaryToElements(s) if len(elems) == 0 { t.Fatal("revenue summary produced no elements") } types := make(map[string]bool) for _, e := range elems { var _ axiom.Element = e types[e.Type()] = true } for _, want := range []string{"revenue-arbitrage", "revenue-infrastructure", "revenue-total", "revenue-infra-ratio"} { if !types[want] { t.Errorf("missing element type %q", want) } } } // --- Stage 15: Bar decomposition tests --- func TestBarToElements(t *testing.T) { bar := &Bar{ Symbol: "BTC/USD", Open: 100000, High: 101500, Low: 99500, Close: 101000, Volume: 1234.56, VWAP: 100700, TradeCount: 5000, Timestamp: time.Unix(1700000000, 0), } elems := BarToElements(bar) if len(elems) == 0 { t.Fatal("bar produced no elements") } // All elements must satisfy axiom.Element. for _, e := range elems { var _ axiom.Element = e if e.Type() == "" { t.Error("element has empty type tag") } if e.Value() == nil { t.Error("element has nil value") } } // Check expected element types. types := make(map[string]bool) for _, e := range elems { types[e.Type()] = true } for _, want := range []string{ "asset", "bar-open", "bar-high", "bar-low", "bar-close", "bar-volume", "bar-vwap", "bar-trades", "timestamp", "bar-body", "bar-range", "bar-upper-shadow", "bar-lower-shadow", "bar-vwap-deviation", } { if !types[want] { t.Errorf("missing element type %q", want) } } } func TestBarToElementsBullish(t *testing.T) { bar := &Bar{ Symbol: "ETH/USD", Open: 3000, High: 3100, Low: 2950, Close: 3080, Volume: 500, VWAP: 3040, } elems := BarToElements(bar) // Find body element — should be positive (bullish). for _, e := range elems { if e.Type() == "bar-body" { body, ok := e.Value().(float64) if !ok { t.Fatal("bar-body value is not float64") } if body != 80 { t.Errorf("bar-body = %f, want 80 (3080-3000)", body) } } } } func TestBarToElementsBearish(t *testing.T) { bar := &Bar{ Symbol: "ETH/USD", Open: 3100, High: 3150, Low: 2900, Close: 2950, Volume: 800, VWAP: 3000, } elems := BarToElements(bar) for _, e := range elems { if e.Type() == "bar-body" { body, ok := e.Value().(float64) if !ok { t.Fatal("bar-body value is not float64") } if body != -150 { t.Errorf("bar-body = %f, want -150 (2950-3100)", body) } } } } func TestBarToElementsZeroRange(t *testing.T) { bar := &Bar{ Symbol: "DOGE/USD", Open: 0.10, High: 0.10, Low: 0.10, Close: 0.10, Volume: 100, } elems := BarToElements(bar) // With zero range, should NOT have bar-range or shadow elements. types := make(map[string]bool) for _, e := range elems { types[e.Type()] = true } if types["bar-range"] { t.Error("zero-range bar should not have bar-range element") } if types["bar-upper-shadow"] { t.Error("zero-range bar should not have shadow elements") } } // TestBarCoherence verifies that bar elements are structurally // indistinguishable from event elements and order book elements. // The lattice sees structure, not domain. func TestBarCoherence(t *testing.T) { // Bar elements. bar := &Bar{ Symbol: "BTC/USD", Open: 100000, High: 101000, Low: 99000, Close: 100500, Volume: 1000, VWAP: 100300, Timestamp: time.Unix(1700000000, 0), } barElems := BarToElements(bar) // Event elements. ev := &nostr.Event{ ID: "abcdef1234567890", Pubkey: "pubkeyhex", CreatedAt: 1700000000, Kind: 1, Content: "Bitcoin going up $BTC", } eventElems := nostr.EventToElements(ev) // Order book elements. ob := &OrderBook{ Asset: "BTC", Venue: "exchange-a", Bids: []OrderEntry{{Price: 100000, Volume: 1, Side: Bid}}, Asks: []OrderEntry{{Price: 100100, Volume: 1, Side: Ask}}, Time: time.Unix(1700000000, 0), } obElems := OrderBookToElements(ob) // All three must produce non-empty element slices. if len(barElems) == 0 { t.Fatal("bar decomposition produced no elements") } if len(eventElems) == 0 { t.Fatal("event decomposition produced no elements") } if len(obElems) == 0 { t.Fatal("order book decomposition produced no elements") } // All must satisfy the same interface — the lattice sees them identically. allElems := make([]axiom.Element, 0, len(barElems)+len(eventElems)+len(obElems)) allElems = append(allElems, barElems...) allElems = append(allElems, eventElems...) allElems = append(allElems, obElems...) for i, e := range allElems { if e.Type() == "" { t.Errorf("element %d has empty type", i) } if e.Value() == nil { t.Errorf("element %d has nil value", i) } } // All three domains share the "timestamp" element type — // the bonding surface where bars, events, and books meet. hasTimestamp := func(elems []axiom.Element) bool { for _, e := range elems { if e.Type() == "timestamp" { return true } } return false } if !hasTimestamp(barElems) { t.Error("bar elements missing timestamp") } if !hasTimestamp(eventElems) { t.Error("event elements missing timestamp") } if !hasTimestamp(obElems) { t.Error("order book elements missing timestamp") } } func TestAlpacaClientCreation(t *testing.T) { cfg := AlpacaConfig{ APIKey: "test-key", APISecret: "test-secret", Symbols: []string{"BTC/USD", "ETH/USD"}, Paper: true, } client := NewAlpacaClient(cfg) if client == nil { t.Fatal("expected non-nil client") } if len(client.Symbols) != 2 { t.Errorf("expected 2 symbols, got %d", len(client.Symbols)) } if client.Symbols[0] != "BTC/USD" { t.Errorf("first symbol = %s, want BTC/USD", client.Symbols[0]) } if client.IsConnected() { t.Error("new client should not be connected") } } func TestAlpacaExecutorCreation(t *testing.T) { exec := NewAlpacaExecutor("test-key", "test-secret", true) if exec == nil { t.Fatal("expected non-nil executor") } if !exec.Paper { t.Error("expected paper trading mode") } } func TestAlpacaExecutorWire(t *testing.T) { ex := NewExecutor(100000) alpacaExec := NewAlpacaExecutor("test-key", "test-secret", false) alpacaExec.WireExecutor(ex) if ex.Mode != LiveTrading { t.Errorf("expected LiveTrading mode, got %s", ex.Mode) } if ex.Execute == nil { t.Error("expected Execute callback to be set") } } func TestAlpacaExecutorWirePaper(t *testing.T) { ex := NewExecutor(100000) alpacaExec := NewAlpacaExecutor("test-key", "test-secret", true) alpacaExec.WireExecutor(ex) if ex.Mode != PaperTrading { t.Errorf("expected PaperTrading mode, got %s", ex.Mode) } }