market_test.go raw

   1  package market
   2  
   3  import (
   4  	"os"
   5  	"strings"
   6  	"testing"
   7  	"time"
   8  
   9  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
  10  	"git.mleku.dev/mleku/dendrite/pkg/nostr"
  11  )
  12  
  13  func TestOrderBookSpread(t *testing.T) {
  14  	ob := &OrderBook{
  15  		Asset: "BTC",
  16  		Venue: "venue-a",
  17  		Bids: []OrderEntry{
  18  			{Price: 99000, Volume: 1.0, Side: Bid},
  19  			{Price: 98900, Volume: 2.0, Side: Bid},
  20  		},
  21  		Asks: []OrderEntry{
  22  			{Price: 99100, Volume: 1.5, Side: Ask},
  23  			{Price: 99200, Volume: 0.5, Side: Ask},
  24  		},
  25  		Time: time.Now(),
  26  	}
  27  
  28  	if got := ob.BestBid(); got != 99000 {
  29  		t.Errorf("BestBid = %f, want 99000", got)
  30  	}
  31  	if got := ob.BestAsk(); got != 99100 {
  32  		t.Errorf("BestAsk = %f, want 99100", got)
  33  	}
  34  	if got := ob.Spread(); got != 100 {
  35  		t.Errorf("Spread = %f, want 100", got)
  36  	}
  37  	if got := ob.MidPrice(); got != 99050 {
  38  		t.Errorf("MidPrice = %f, want 99050", got)
  39  	}
  40  }
  41  
  42  func TestOrderBookDepthImbalance(t *testing.T) {
  43  	ob := &OrderBook{
  44  		Bids: []OrderEntry{
  45  			{Volume: 10}, {Volume: 5},
  46  		},
  47  		Asks: []OrderEntry{
  48  			{Volume: 5}, {Volume: 5},
  49  		},
  50  	}
  51  
  52  	imb := ob.DepthImbalance(5)
  53  	// Bid vol = 15, Ask vol = 10, total = 25. Ratio = 15/25 = 0.6
  54  	if imb < 0.59 || imb > 0.61 {
  55  		t.Errorf("DepthImbalance = %f, want ~0.6", imb)
  56  	}
  57  }
  58  
  59  func TestOrderBookEmpty(t *testing.T) {
  60  	ob := &OrderBook{}
  61  
  62  	if ob.BestBid() != 0 {
  63  		t.Errorf("empty BestBid should be 0")
  64  	}
  65  	if ob.BestAsk() != 0 {
  66  		t.Errorf("empty BestAsk should be 0")
  67  	}
  68  	if ob.Spread() != 0 {
  69  		t.Errorf("empty Spread should be 0")
  70  	}
  71  	if ob.DepthImbalance(5) != 0.5 {
  72  		t.Errorf("empty DepthImbalance should be 0.5")
  73  	}
  74  }
  75  
  76  func TestOrderBookToElements(t *testing.T) {
  77  	ob := &OrderBook{
  78  		Asset: "ETH",
  79  		Venue: "venue-b",
  80  		Bids: []OrderEntry{
  81  			{Price: 3000, Volume: 10, Side: Bid},
  82  		},
  83  		Asks: []OrderEntry{
  84  			{Price: 3010, Volume: 5, Side: Ask},
  85  		},
  86  		Time: time.Unix(1700000000, 0),
  87  	}
  88  
  89  	elems := OrderBookToElements(ob)
  90  
  91  	// Verify all elements satisfy axiom.Element.
  92  	for _, e := range elems {
  93  		var _ axiom.Element = e // compile-time check
  94  		if e.Type() == "" {
  95  			t.Error("element has empty type tag")
  96  		}
  97  		if e.Value() == nil {
  98  			t.Error("element has nil value")
  99  		}
 100  	}
 101  
 102  	// Check expected element types are present.
 103  	types := make(map[string]bool)
 104  	for _, e := range elems {
 105  		types[e.Type()] = true
 106  	}
 107  
 108  	for _, want := range []string{"asset", "venue", "timestamp", "bid", "ask", "spread", "depth-imbalance"} {
 109  		if !types[want] {
 110  			t.Errorf("missing element type %q", want)
 111  		}
 112  	}
 113  }
 114  
 115  // TestCoherence verifies the Stage 8 coherence criterion: price elements
 116  // and event elements are structurally indistinguishable. Both produce
 117  // []axiom.Element through the same interface. The lattice cannot tell
 118  // them apart.
 119  func TestCoherence(t *testing.T) {
 120  	// Decompose a Nostr event.
 121  	ev := &nostr.Event{
 122  		ID:        "abcdef1234567890",
 123  		Pubkey:    "pubkeyhex",
 124  		CreatedAt: 1700000000,
 125  		Kind:      1,
 126  		Content:   "Bitcoin is going up $BTC",
 127  		Tags:      [][]string{{"t", "bitcoin"}},
 128  	}
 129  	eventElems := nostr.EventToElements(ev)
 130  
 131  	// Decompose an order book.
 132  	ob := &OrderBook{
 133  		Asset: "BTC",
 134  		Venue: "exchange-a",
 135  		Bids:  []OrderEntry{{Price: 99000, Volume: 1, Side: Bid}},
 136  		Asks:  []OrderEntry{{Price: 99100, Volume: 1, Side: Ask}},
 137  		Time:  time.Unix(1700000000, 0),
 138  	}
 139  	priceElems := OrderBookToElements(ob)
 140  
 141  	// Both must produce non-empty element slices.
 142  	if len(eventElems) == 0 {
 143  		t.Fatal("event decomposition produced no elements")
 144  	}
 145  	if len(priceElems) == 0 {
 146  		t.Fatal("price decomposition produced no elements")
 147  	}
 148  
 149  	// All elements must satisfy the same interface.
 150  	// The lattice sees them identically.
 151  	allElems := make([]axiom.Element, 0, len(eventElems)+len(priceElems))
 152  	allElems = append(allElems, eventElems...)
 153  	allElems = append(allElems, priceElems...)
 154  
 155  	for i, e := range allElems {
 156  		if e.Type() == "" {
 157  			t.Errorf("element %d has empty type", i)
 158  		}
 159  		if e.Value() == nil {
 160  			t.Errorf("element %d has nil value", i)
 161  		}
 162  	}
 163  
 164  	// Both domains share the "timestamp" element type.
 165  	// This is the bonding surface where events and prices meet.
 166  	hasTimestamp := func(elems []axiom.Element) bool {
 167  		for _, e := range elems {
 168  			if e.Type() == "timestamp" {
 169  				return true
 170  			}
 171  		}
 172  		return false
 173  	}
 174  	if !hasTimestamp(eventElems) {
 175  		t.Error("event elements missing timestamp")
 176  	}
 177  	if !hasTimestamp(priceElems) {
 178  		t.Error("price elements missing timestamp")
 179  	}
 180  }
 181  
 182  func TestCrossVenueSpread(t *testing.T) {
 183  	bookA := &OrderBook{
 184  		Asset: "BTC",
 185  		Venue: "exchange-a",
 186  		Bids:  []OrderEntry{{Price: 99100, Volume: 1}},
 187  		Asks:  []OrderEntry{{Price: 99200, Volume: 1}},
 188  	}
 189  	bookB := &OrderBook{
 190  		Asset: "BTC",
 191  		Venue: "exchange-b",
 192  		Bids:  []OrderEntry{{Price: 99000, Volume: 1}},
 193  		Asks:  []OrderEntry{{Price: 99050, Volume: 1}},
 194  	}
 195  
 196  	cs := CompareBooksForSpread(bookA, bookB)
 197  
 198  	// A's bid (99100) > B's ask (99050) → profit of 50 buying on B, selling on A.
 199  	ab := cs.SpreadAB()
 200  	if ab != 50 {
 201  		t.Errorf("SpreadAB = %f, want 50", ab)
 202  	}
 203  
 204  	// B's bid (99000) < A's ask (99200) → no profit the other way.
 205  	ba := cs.SpreadBA()
 206  	if ba >= 0 {
 207  		t.Errorf("SpreadBA = %f, want negative", ba)
 208  	}
 209  
 210  	spread, buyV, sellV := cs.BestSpread()
 211  	if spread != 50 {
 212  		t.Errorf("BestSpread = %f, want 50", spread)
 213  	}
 214  	if buyV != "exchange-b" {
 215  		t.Errorf("buy venue = %s, want exchange-b", buyV)
 216  	}
 217  	if sellV != "exchange-a" {
 218  		t.Errorf("sell venue = %s, want exchange-a", sellV)
 219  	}
 220  }
 221  
 222  func TestExtractAssetMentions(t *testing.T) {
 223  	ev := &nostr.Event{
 224  		ID:        "test123",
 225  		Pubkey:    "author1",
 226  		CreatedAt: 1700000000,
 227  		Content:   "I'm bullish on $BTC and $ETH right now",
 228  	}
 229  
 230  	known := map[string]bool{"BTC": true, "ETH": true, "SOL": true}
 231  	mentions := ExtractAssetMentions(ev, known)
 232  
 233  	if len(mentions) != 2 {
 234  		t.Fatalf("expected 2 mentions, got %d", len(mentions))
 235  	}
 236  
 237  	assets := make(map[string]bool)
 238  	for _, m := range mentions {
 239  		assets[m.Asset] = true
 240  		if m.EventID != "test123" {
 241  			t.Errorf("mention event ID = %s, want test123", m.EventID)
 242  		}
 243  	}
 244  	if !assets["BTC"] || !assets["ETH"] {
 245  		t.Errorf("expected BTC and ETH mentions, got %v", assets)
 246  	}
 247  }
 248  
 249  func TestAuthorWeight(t *testing.T) {
 250  	g := nostr.NewEventGraph()
 251  
 252  	// Author with no references → baseline weight.
 253  	w := AuthorWeight("unknown-pubkey", g)
 254  	if w != 0.1 {
 255  		t.Errorf("unknown author weight = %f, want 0.1", w)
 256  	}
 257  
 258  	// Add events that reference a pubkey.
 259  	for i := range 10 {
 260  		ev := &nostr.Event{
 261  			ID:     "event" + string(rune('A'+i)),
 262  			Pubkey: "other-author",
 263  			Kind:   1,
 264  			Tags:   [][]string{{"p", "referenced-author"}},
 265  		}
 266  		g.Add(ev)
 267  	}
 268  	g.Resolve()
 269  
 270  	w = AuthorWeight("referenced-author", g)
 271  	if w <= 0.1 {
 272  		t.Errorf("referenced author weight should be > 0.1, got %f", w)
 273  	}
 274  	if w > 1.0 {
 275  		t.Errorf("author weight should be <= 1.0, got %f", w)
 276  	}
 277  }
 278  
 279  func TestAggregateSentiment(t *testing.T) {
 280  	signals := []SentimentSignal{
 281  		{Weight: 1.0, Direction: 0.8},  // bullish, high weight
 282  		{Weight: 0.5, Direction: -0.5}, // bearish, lower weight
 283  		{Weight: 0.3, Direction: 0.3},  // slightly bullish, low weight
 284  	}
 285  
 286  	s := AggregateSentiment(signals)
 287  	// Weighted: (0.8*1.0 + -0.5*0.5 + 0.3*0.3) / (1.0+0.5+0.3)
 288  	// = (0.8 - 0.25 + 0.09) / 1.8 = 0.64 / 1.8 ≈ 0.356
 289  	if s < 0.3 || s > 0.4 {
 290  		t.Errorf("AggregateSentiment = %f, want ~0.356", s)
 291  	}
 292  }
 293  
 294  func TestDetectDislocation(t *testing.T) {
 295  	ob := &OrderBook{
 296  		Asset: "BTC",
 297  		Venue: "exchange-a",
 298  		Bids:  []OrderEntry{{Price: 99000, Volume: 10}}, // heavy bids
 299  		Asks:  []OrderEntry{{Price: 99100, Volume: 1}},  // light asks
 300  	}
 301  
 302  	// Imbalance: 10/(10+1) ≈ 0.91 → price direction ≈ +0.82
 303  	// Sentiment: strongly bearish
 304  	signals := []SentimentSignal{
 305  		{Weight: 1.0, Direction: -0.8},
 306  		{Weight: 0.8, Direction: -0.7},
 307  	}
 308  
 309  	d := DetectDislocation("BTC", ob, signals)
 310  	if d == nil {
 311  		t.Fatal("expected dislocation, got nil")
 312  	}
 313  	if d.Magnitude <= 0 {
 314  		t.Error("dislocation magnitude should be > 0")
 315  	}
 316  	if d.Direction != -1 {
 317  		t.Errorf("direction = %d, want -1 (sentiment bearish, price bullish)", d.Direction)
 318  	}
 319  }
 320  
 321  func TestDislocationToElements(t *testing.T) {
 322  	d := &Dislocation{
 323  		Asset:     "ETH",
 324  		PriceMid:  3000,
 325  		Sentiment: 0.5,
 326  		Magnitude: 0.7,
 327  		Direction: 1,
 328  		Time:      time.Now(),
 329  	}
 330  
 331  	elems := DislocationToElements(d)
 332  	if len(elems) == 0 {
 333  		t.Fatal("dislocation produced no elements")
 334  	}
 335  
 336  	for _, e := range elems {
 337  		var _ axiom.Element = e
 338  		if e.Type() == "" {
 339  			t.Error("element has empty type")
 340  		}
 341  	}
 342  
 343  	types := make(map[string]bool)
 344  	for _, e := range elems {
 345  		types[e.Type()] = true
 346  	}
 347  	if !types["dislocation-magnitude"] {
 348  		t.Error("missing dislocation-magnitude element")
 349  	}
 350  	if !types["sentiment"] {
 351  		t.Error("missing sentiment element")
 352  	}
 353  }
 354  
 355  func TestCrossSpreadToElements(t *testing.T) {
 356  	cs := &CrossSpread{
 357  		Asset:  "BTC",
 358  		VenueA: "exchange-a",
 359  		VenueB: "exchange-b",
 360  		BidA:   99100,
 361  		AskB:   99050,
 362  		BidB:   99000,
 363  		AskA:   99200,
 364  		Time:   time.Now(),
 365  	}
 366  
 367  	elems := CrossSpreadToElements(cs)
 368  	if len(elems) == 0 {
 369  		t.Fatal("cross-spread produced no elements")
 370  	}
 371  
 372  	types := make(map[string]bool)
 373  	for _, e := range elems {
 374  		var _ axiom.Element = e
 375  		types[e.Type()] = true
 376  	}
 377  	if !types["cross-spread"] {
 378  		t.Error("missing cross-spread element")
 379  	}
 380  	if !types["buy-venue"] {
 381  		t.Error("missing buy-venue element")
 382  	}
 383  }
 384  
 385  func TestOrderEntryToElements(t *testing.T) {
 386  	entry := &OrderEntry{
 387  		Price:  42000,
 388  		Volume: 2.5,
 389  		Side:   Bid,
 390  		Venue:  "exchange-c",
 391  		Time:   time.Unix(1700000000, 0),
 392  	}
 393  
 394  	elems := OrderEntryToElements(entry)
 395  	if len(elems) != 5 {
 396  		t.Fatalf("expected 5 elements, got %d", len(elems))
 397  	}
 398  
 399  	for _, e := range elems {
 400  		var _ axiom.Element = e
 401  	}
 402  }
 403  
 404  func TestVenueGraphUpdate(t *testing.T) {
 405  	vg := NewVenueGraph()
 406  
 407  	ob := &OrderBook{
 408  		Asset: "BTC",
 409  		Venue: "exchange-a",
 410  		Bids:  []OrderEntry{{Price: 99000, Volume: 1}},
 411  		Asks:  []OrderEntry{{Price: 99100, Volume: 1}},
 412  		Time:  time.Now(),
 413  	}
 414  	vg.Update(ob)
 415  
 416  	assets := vg.Assets()
 417  	if len(assets) != 1 || assets[0] != "BTC" {
 418  		t.Errorf("expected [BTC], got %v", assets)
 419  	}
 420  
 421  	venues := vg.VenuesForAsset("BTC")
 422  	if len(venues) != 1 || venues[0] != "exchange-a" {
 423  		t.Errorf("expected [exchange-a], got %v", venues)
 424  	}
 425  
 426  	got := vg.Book("BTC", "exchange-a")
 427  	if got == nil {
 428  		t.Fatal("expected book, got nil")
 429  	}
 430  	if got.BestBid() != 99000 {
 431  		t.Errorf("book best bid = %f, want 99000", got.BestBid())
 432  	}
 433  
 434  	// Missing asset/venue returns nil.
 435  	if vg.Book("ETH", "exchange-a") != nil {
 436  		t.Error("expected nil for missing asset")
 437  	}
 438  	if vg.Book("BTC", "exchange-z") != nil {
 439  		t.Error("expected nil for missing venue")
 440  	}
 441  }
 442  
 443  func TestVenueGraphDetectDislocations(t *testing.T) {
 444  	vg := NewVenueGraph()
 445  
 446  	// Exchange A: BTC bid 99100, ask 99200
 447  	vg.Update(&OrderBook{
 448  		Asset: "BTC", Venue: "exchange-a",
 449  		Bids: []OrderEntry{{Price: 99100, Volume: 1}},
 450  		Asks: []OrderEntry{{Price: 99200, Volume: 1}},
 451  		Time: time.Now(),
 452  	})
 453  
 454  	// Exchange B: BTC bid 99000, ask 99050
 455  	// A's bid (99100) > B's ask (99050) → spread of 50
 456  	vg.Update(&OrderBook{
 457  		Asset: "BTC", Venue: "exchange-b",
 458  		Bids: []OrderEntry{{Price: 99000, Volume: 1}},
 459  		Asks: []OrderEntry{{Price: 99050, Volume: 1}},
 460  		Time: time.Now(),
 461  	})
 462  
 463  	// Threshold 0 → should detect the 50-spread dislocation.
 464  	dislocations := vg.DetectDislocations(0)
 465  	if len(dislocations) != 1 {
 466  		t.Fatalf("expected 1 dislocation, got %d", len(dislocations))
 467  	}
 468  
 469  	spread, _, _ := dislocations[0].BestSpread()
 470  	if spread != 50 {
 471  		t.Errorf("dislocation spread = %f, want 50", spread)
 472  	}
 473  
 474  	// Threshold above the spread → no dislocations.
 475  	dislocations = vg.DetectDislocations(100)
 476  	if len(dislocations) != 0 {
 477  		t.Errorf("expected 0 dislocations with high threshold, got %d", len(dislocations))
 478  	}
 479  }
 480  
 481  func TestVenueGraphNoDislocationsOnSingleVenue(t *testing.T) {
 482  	vg := NewVenueGraph()
 483  	vg.Update(&OrderBook{
 484  		Asset: "ETH", Venue: "solo-exchange",
 485  		Bids: []OrderEntry{{Price: 3000, Volume: 1}},
 486  		Asks: []OrderEntry{{Price: 3010, Volume: 1}},
 487  	})
 488  
 489  	dislocations := vg.DetectDislocations(0)
 490  	if len(dislocations) != 0 {
 491  		t.Errorf("single venue should produce no dislocations, got %d", len(dislocations))
 492  	}
 493  }
 494  
 495  func TestDislocationLog(t *testing.T) {
 496  	dl := NewDislocationLog("") // no file, in-memory only
 497  
 498  	cs := &CrossSpread{
 499  		Asset:  "BTC",
 500  		VenueA: "exchange-a",
 501  		VenueB: "exchange-b",
 502  		BidA:   99100,
 503  		AskB:   99050,
 504  		BidB:   99000,
 505  		AskA:   99200,
 506  		Time:   time.Now(),
 507  	}
 508  
 509  	dl.Record(cs)
 510  	dl.Record(cs)
 511  
 512  	if dl.Count() != 2 {
 513  		t.Errorf("expected 2 entries, got %d", dl.Count())
 514  	}
 515  
 516  	entries := dl.Entries()
 517  	if len(entries) != 2 {
 518  		t.Fatalf("expected 2 entries, got %d", len(entries))
 519  	}
 520  
 521  	if entries[0].Asset != "BTC" {
 522  		t.Errorf("entry asset = %s, want BTC", entries[0].Asset)
 523  	}
 524  	if entries[0].Spread != 50 {
 525  		t.Errorf("entry spread = %f, want 50", entries[0].Spread)
 526  	}
 527  	if entries[0].BuyVenue != "exchange-b" {
 528  		t.Errorf("entry buy venue = %s, want exchange-b", entries[0].BuyVenue)
 529  	}
 530  	if entries[0].SellVenue != "exchange-a" {
 531  		t.Errorf("entry sell venue = %s, want exchange-a", entries[0].SellVenue)
 532  	}
 533  }
 534  
 535  func TestDislocationLogFileWrite(t *testing.T) {
 536  	path := t.TempDir() + "/dislocations.log"
 537  	dl := NewDislocationLog(path)
 538  
 539  	cs := &CrossSpread{
 540  		Asset:  "ETH",
 541  		VenueA: "a",
 542  		VenueB: "b",
 543  		BidA:   3010,
 544  		AskB:   3000,
 545  		BidB:   2990,
 546  		AskA:   3020,
 547  		Time:   time.Now(),
 548  	}
 549  	dl.Record(cs)
 550  
 551  	// Verify file was written.
 552  	data, err := os.ReadFile(path)
 553  	if err != nil {
 554  		t.Fatalf("failed to read log file: %v", err)
 555  	}
 556  	if len(data) == 0 {
 557  		t.Error("log file is empty")
 558  	}
 559  
 560  	content := string(data)
 561  	if !strings.Contains(content, "ETH") {
 562  		t.Errorf("log file should contain ETH, got: %s", content)
 563  	}
 564  	if !strings.Contains(content, "spread=") {
 565  		t.Errorf("log file should contain spread=, got: %s", content)
 566  	}
 567  }
 568  
 569  func TestFeedParseMessage(t *testing.T) {
 570  	f := NewFeed("wss://example.com/ws", "test-venue", "BTC")
 571  
 572  	msg := []byte(`{
 573  		"bids": [["99000", "1.5"], ["98900", "2.0"]],
 574  		"asks": [["99100", "1.0"], ["99200", "0.5"]]
 575  	}`)
 576  
 577  	ob, err := f.parseMessage(msg)
 578  	if err != nil {
 579  		t.Fatalf("parseMessage error: %v", err)
 580  	}
 581  	if ob == nil {
 582  		t.Fatal("expected order book, got nil")
 583  	}
 584  
 585  	if ob.Asset != "BTC" {
 586  		t.Errorf("asset = %s, want BTC", ob.Asset)
 587  	}
 588  	if ob.Venue != "test-venue" {
 589  		t.Errorf("venue = %s, want test-venue", ob.Venue)
 590  	}
 591  	if len(ob.Bids) != 2 {
 592  		t.Errorf("expected 2 bids, got %d", len(ob.Bids))
 593  	}
 594  	if len(ob.Asks) != 2 {
 595  		t.Errorf("expected 2 asks, got %d", len(ob.Asks))
 596  	}
 597  
 598  	if ob.BestBid() != 99000 {
 599  		t.Errorf("best bid = %f, want 99000", ob.BestBid())
 600  	}
 601  	if ob.BestAsk() != 99100 {
 602  		t.Errorf("best ask = %f, want 99100", ob.BestAsk())
 603  	}
 604  }
 605  
 606  func TestFeedParseMessageEmpty(t *testing.T) {
 607  	f := NewFeed("wss://example.com/ws", "test-venue", "BTC")
 608  
 609  	// Non-order-book message returns nil, nil.
 610  	ob, err := f.parseMessage([]byte(`{"type": "heartbeat"}`))
 611  	if err != nil {
 612  		t.Fatalf("unexpected error: %v", err)
 613  	}
 614  	if ob != nil {
 615  		t.Error("expected nil for non-orderbook message")
 616  	}
 617  
 618  	// Invalid JSON returns error.
 619  	_, err = f.parseMessage([]byte(`{broken`))
 620  	if err == nil {
 621  		t.Error("expected error for invalid JSON")
 622  	}
 623  }
 624  
 625  func TestFeedParseMessageZeroPrice(t *testing.T) {
 626  	f := NewFeed("wss://example.com/ws", "test-venue", "ETH")
 627  
 628  	// Zero-price entries should be filtered out.
 629  	msg := []byte(`{
 630  		"bids": [["0", "1.0"], ["3000", "2.0"]],
 631  		"asks": [["3010", "1.0"], ["0", "0.5"]]
 632  	}`)
 633  
 634  	ob, err := f.parseMessage(msg)
 635  	if err != nil {
 636  		t.Fatalf("parseMessage error: %v", err)
 637  	}
 638  	if len(ob.Bids) != 1 {
 639  		t.Errorf("expected 1 bid (zero filtered), got %d", len(ob.Bids))
 640  	}
 641  	if len(ob.Asks) != 1 {
 642  		t.Errorf("expected 1 ask (zero filtered), got %d", len(ob.Asks))
 643  	}
 644  }
 645  
 646  // --- Stage 16: Economic Agency tests ---
 647  
 648  func TestPositionApplyFill(t *testing.T) {
 649  	p := &Position{Asset: "BTC"}
 650  
 651  	// Open long: buy 1 BTC @ 100000.
 652  	p.ApplyFill(&Fill{
 653  		Asset: "BTC", Side: Buy, Price: 100000, Quantity: 1, Fee: 10,
 654  	})
 655  	if p.Quantity != 1 {
 656  		t.Errorf("quantity = %f, want 1", p.Quantity)
 657  	}
 658  	if p.AvgEntry != 100000 {
 659  		t.Errorf("avg entry = %f, want 100000", p.AvgEntry)
 660  	}
 661  
 662  	// Add to position: buy 1 more @ 102000.
 663  	p.ApplyFill(&Fill{
 664  		Asset: "BTC", Side: Buy, Price: 102000, Quantity: 1, Fee: 10,
 665  	})
 666  	if p.Quantity != 2 {
 667  		t.Errorf("quantity = %f, want 2", p.Quantity)
 668  	}
 669  	if p.AvgEntry != 101000 {
 670  		t.Errorf("avg entry = %f, want 101000 (weighted avg)", p.AvgEntry)
 671  	}
 672  
 673  	// Close half: sell 1 @ 105000. Realized = 1 * (105000 - 101000) = 4000 minus fees.
 674  	p.ApplyFill(&Fill{
 675  		Asset: "BTC", Side: Sell, Price: 105000, Quantity: 1, Fee: 10,
 676  	})
 677  	if p.Quantity != 1 {
 678  		t.Errorf("quantity after partial close = %f, want 1", p.Quantity)
 679  	}
 680  	// Realized: 4000 - 30 (cumulative fees) = 3970
 681  	if p.Realized < 3960 || p.Realized > 3980 {
 682  		t.Errorf("realized = %f, want ~3970", p.Realized)
 683  	}
 684  }
 685  
 686  func TestPositionMarkToMarket(t *testing.T) {
 687  	p := &Position{Asset: "ETH", Quantity: 10, AvgEntry: 3000}
 688  
 689  	mtm := p.MarkToMarket(3100)
 690  	// 10 * (3100 - 3000) = 1000
 691  	if mtm != 1000 {
 692  		t.Errorf("mark to market = %f, want 1000", mtm)
 693  	}
 694  
 695  	mtm = p.MarkToMarket(2900)
 696  	if mtm != -1000 {
 697  		t.Errorf("mark to market = %f, want -1000", mtm)
 698  	}
 699  }
 700  
 701  func TestPositionEmpty(t *testing.T) {
 702  	p := &Position{Asset: "SOL"}
 703  	if p.MarkToMarket(100) != 0 {
 704  		t.Error("empty position should have zero MTM")
 705  	}
 706  	if p.TotalPnL(100) != 0 {
 707  		t.Error("empty position should have zero total PnL")
 708  	}
 709  }
 710  
 711  func TestPortfolio(t *testing.T) {
 712  	pf := NewPortfolio()
 713  
 714  	pf.RecordFill(Fill{
 715  		Asset: "BTC", Side: Buy, Price: 100000, Quantity: 1, Fee: 10,
 716  		Venue: "exchange-a", FilledAt: time.Now(),
 717  	})
 718  	pf.RecordFill(Fill{
 719  		Asset: "ETH", Side: Buy, Price: 3000, Quantity: 5, Fee: 1.5,
 720  		Venue: "exchange-b", FilledAt: time.Now(),
 721  	})
 722  
 723  	if pf.FillCount() != 2 {
 724  		t.Errorf("fill count = %d, want 2", pf.FillCount())
 725  	}
 726  
 727  	btc := pf.Position("BTC")
 728  	if btc == nil || btc.Quantity != 1 {
 729  		t.Error("expected BTC position with quantity 1")
 730  	}
 731  
 732  	eth := pf.Position("ETH")
 733  	if eth == nil || eth.Quantity != 5 {
 734  		t.Error("expected ETH position with quantity 5")
 735  	}
 736  
 737  	if pf.Position("SOL") != nil {
 738  		t.Error("expected nil for untracked asset")
 739  	}
 740  
 741  	costs := pf.TotalCosts()
 742  	if costs.ExchangeFees != 11.5 {
 743  		t.Errorf("exchange fees = %f, want 11.5", costs.ExchangeFees)
 744  	}
 745  }
 746  
 747  func TestPortfolioAddCost(t *testing.T) {
 748  	pf := NewPortfolio()
 749  	pf.AddCost(5.0, 2.0, 1.0)
 750  	pf.AddCost(3.0, 0, 0.5)
 751  
 752  	costs := pf.TotalCosts()
 753  	if costs.APIFees != 8.0 {
 754  		t.Errorf("API fees = %f, want 8.0", costs.APIFees)
 755  	}
 756  	if costs.Compute != 2.0 {
 757  		t.Errorf("compute = %f, want 2.0", costs.Compute)
 758  	}
 759  	if costs.Bandwidth != 1.5 {
 760  		t.Errorf("bandwidth = %f, want 1.5", costs.Bandwidth)
 761  	}
 762  	if costs.Total() != 11.5 {
 763  		t.Errorf("total costs = %f, want 11.5", costs.Total())
 764  	}
 765  }
 766  
 767  func TestExecutorPaperTrading(t *testing.T) {
 768  	ex := NewExecutor(100000) // 100k capital
 769  
 770  	order := TradeOrder{
 771  		ID:       "test-1",
 772  		Asset:    "BTC",
 773  		Side:     Buy,
 774  		Venue:    "exchange-a",
 775  		Price:    50000,
 776  		Quantity: 0.01,
 777  	}
 778  
 779  	fill, err := ex.Submit(order)
 780  	if err != nil {
 781  		t.Fatalf("submit error: %v", err)
 782  	}
 783  	if fill == nil {
 784  		t.Fatal("expected fill")
 785  	}
 786  	if fill.Price != 50000 {
 787  		t.Errorf("fill price = %f, want 50000", fill.Price)
 788  	}
 789  	if fill.Fee == 0 {
 790  		t.Error("expected simulated fee")
 791  	}
 792  
 793  	pos := ex.Portfolio.Position("BTC")
 794  	if pos == nil || pos.Quantity != 0.01 {
 795  		t.Error("expected BTC position with quantity 0.01")
 796  	}
 797  }
 798  
 799  func TestExecutorPerTradeLimitReject(t *testing.T) {
 800  	ex := NewExecutor(10000) // 10k capital, max per trade = 200
 801  
 802  	order := TradeOrder{
 803  		ID:       "big-order",
 804  		Asset:    "BTC",
 805  		Side:     Buy,
 806  		Venue:    "exchange-a",
 807  		Price:    50000,
 808  		Quantity: 1, // notional 50000 >> 200
 809  	}
 810  
 811  	_, err := ex.Submit(order)
 812  	if err == nil {
 813  		t.Error("expected rejection for exceeding per-trade limit")
 814  	}
 815  }
 816  
 817  func TestExecutorDrawdownCircuitBreaker(t *testing.T) {
 818  	ex := NewExecutor(10000) // 10k, drawdown limit 5% = 500
 819  	ex.MaxPerTrade = 100000
 820  	ex.MaxAggregate = 200000
 821  
 822  	// Buy high: 10 units @ 1000 = 10000 notional.
 823  	ex.Submit(TradeOrder{
 824  		ID: "buy", Asset: "BTC", Side: Buy, Venue: "a",
 825  		Price: 1000, Quantity: 10,
 826  	})
 827  
 828  	// Sell low: realize loss of 10 * (1000 - 940) = 600 > 500 (5% of 10k).
 829  	ex.Submit(TradeOrder{
 830  		ID: "sell-loss", Asset: "BTC", Side: Sell, Venue: "a",
 831  		Price: 940, Quantity: 10,
 832  	})
 833  
 834  	halted, reason := ex.IsHalted()
 835  	if !halted {
 836  		t.Error("expected halt from drawdown circuit breaker")
 837  	}
 838  	if reason == "" {
 839  		t.Error("expected halt reason")
 840  	}
 841  
 842  	// Further trades should be rejected.
 843  	_, err := ex.Submit(TradeOrder{
 844  		ID: "after-halt", Asset: "ETH", Side: Buy, Venue: "a",
 845  		Price: 10, Quantity: 1,
 846  	})
 847  	if err == nil {
 848  		t.Error("expected rejection while halted")
 849  	}
 850  
 851  	// Resume clears halt.
 852  	ex.Resume()
 853  	halted, _ = ex.IsHalted()
 854  	if halted {
 855  		t.Error("expected halt cleared after resume")
 856  	}
 857  }
 858  
 859  func TestExecutorKillFile(t *testing.T) {
 860  	killPath := t.TempDir() + "/STOP"
 861  	ex := NewExecutor(100000)
 862  	ex.KillFilePath = killPath
 863  
 864  	// No kill file — should work.
 865  	_, err := ex.Submit(TradeOrder{
 866  		ID: "ok", Asset: "BTC", Side: Buy, Venue: "a",
 867  		Price: 100, Quantity: 0.01,
 868  	})
 869  	if err != nil {
 870  		t.Fatalf("expected success without kill file: %v", err)
 871  	}
 872  
 873  	// Create kill file.
 874  	os.WriteFile(killPath, []byte("stop"), 0o644)
 875  
 876  	_, err = ex.Submit(TradeOrder{
 877  		ID: "killed", Asset: "BTC", Side: Buy, Venue: "a",
 878  		Price: 100, Quantity: 0.01,
 879  	})
 880  	if err == nil {
 881  		t.Error("expected rejection with kill file present")
 882  	}
 883  
 884  	halted, reason := ex.IsHalted()
 885  	if !halted || reason != "kill file" {
 886  		t.Errorf("expected halted with kill file reason, got halted=%v reason=%q", halted, reason)
 887  	}
 888  }
 889  
 890  func TestEconomicFitness(t *testing.T) {
 891  	ex := NewExecutor(100000)
 892  	ex.SetGeneration(5)
 893  	ex.MaxPerTrade = 100000
 894  	ex.MaxAggregate = 200000
 895  
 896  	// Profitable trade: buy 1 @ 100, sell 1 @ 110.
 897  	ex.Submit(TradeOrder{
 898  		ID: "buy-1", Asset: "BTC", Side: Buy, Venue: "a",
 899  		Price: 100, Quantity: 1,
 900  	})
 901  	ex.Submit(TradeOrder{
 902  		ID: "sell-1", Asset: "BTC", Side: Sell, Venue: "a",
 903  		Price: 110, Quantity: 1,
 904  	})
 905  
 906  	// Add operational costs.
 907  	ex.Portfolio.AddCost(1.0, 0.5, 0.2)
 908  
 909  	ef := ex.EconomicFitness()
 910  	if ef.Generation != 5 {
 911  		t.Errorf("generation = %d, want 5", ef.Generation)
 912  	}
 913  	if ef.TradeCount != 2 {
 914  		t.Errorf("trade count = %d, want 2", ef.TradeCount)
 915  	}
 916  	if ef.NetRevenue() <= 0 {
 917  		t.Errorf("expected positive net revenue, got %f", ef.NetRevenue())
 918  	}
 919  	if !ef.IsViable() {
 920  		t.Error("expected viable with profitable trade")
 921  	}
 922  }
 923  
 924  func TestEconomicFitnessNotViable(t *testing.T) {
 925  	ef := &EconomicFitness{
 926  		Realized:   10,
 927  		Costs:      CostLedger{ExchangeFees: 5, APIFees: 10},
 928  		TradeCount: 2,
 929  	}
 930  	if ef.IsViable() {
 931  		t.Error("expected not viable when costs exceed revenue")
 932  	}
 933  	if ef.NetRevenue() != -5 {
 934  		t.Errorf("net revenue = %f, want -5", ef.NetRevenue())
 935  	}
 936  }
 937  
 938  func TestFillToElements(t *testing.T) {
 939  	fill := &Fill{
 940  		OrderID:  "fill-1",
 941  		Asset:    "BTC",
 942  		Side:     Buy,
 943  		Venue:    "exchange-a",
 944  		Price:    50000,
 945  		Quantity: 0.5,
 946  		Fee:      25,
 947  		FilledAt: time.Unix(1700000000, 0),
 948  	}
 949  
 950  	elems := FillToElements(fill)
 951  	if len(elems) == 0 {
 952  		t.Fatal("fill produced no elements")
 953  	}
 954  
 955  	types := make(map[string]bool)
 956  	for _, e := range elems {
 957  		var _ axiom.Element = e
 958  		types[e.Type()] = true
 959  	}
 960  
 961  	for _, want := range []string{"asset", "trade-side", "trade-price", "trade-quantity", "venue", "timestamp"} {
 962  		if !types[want] {
 963  			t.Errorf("missing element type %q", want)
 964  		}
 965  	}
 966  }
 967  
 968  func TestEconomicFitnessToElements(t *testing.T) {
 969  	ef := &EconomicFitness{
 970  		Generation: 3,
 971  		Realized:   500,
 972  		Costs:      CostLedger{ExchangeFees: 20, APIFees: 5},
 973  		TradeCount: 10,
 974  		WinCount:   7,
 975  		LossCount:  3,
 976  		Time:       time.Unix(1700000000, 0),
 977  	}
 978  
 979  	elems := EconomicFitnessToElements(ef)
 980  	if len(elems) == 0 {
 981  		t.Fatal("economic fitness produced no elements")
 982  	}
 983  
 984  	types := make(map[string]bool)
 985  	for _, e := range elems {
 986  		var _ axiom.Element = e
 987  		types[e.Type()] = true
 988  	}
 989  
 990  	for _, want := range []string{"generation", "realized-pnl", "net-revenue", "trade-count", "win-rate", "economic-viable"} {
 991  		if !types[want] {
 992  			t.Errorf("missing element type %q", want)
 993  		}
 994  	}
 995  }
 996  
 997  func TestExecutorLogWrite(t *testing.T) {
 998  	logPath := t.TempDir() + "/trades.log"
 999  	ex := NewExecutor(100000)
1000  	ex.LogPath = logPath
1001  
1002  	ex.Submit(TradeOrder{
1003  		ID: "logged", Asset: "BTC", Side: Buy, Venue: "exchange-a",
1004  		Price: 50000, Quantity: 0.01,
1005  	})
1006  
1007  	data, err := os.ReadFile(logPath)
1008  	if err != nil {
1009  		t.Fatalf("failed to read trade log: %v", err)
1010  	}
1011  	content := string(data)
1012  	if !strings.Contains(content, "BTC") {
1013  		t.Errorf("trade log should contain BTC, got: %s", content)
1014  	}
1015  	if !strings.Contains(content, "paper") {
1016  		t.Errorf("trade log should contain mode=paper, got: %s", content)
1017  	}
1018  }
1019  
1020  // --- Stage 17: Structural Correction tests ---
1021  
1022  func TestPriceFeedConsolidate(t *testing.T) {
1023  	vg := NewVenueGraph()
1024  
1025  	vg.Update(&OrderBook{
1026  		Asset: "BTC", Venue: "exchange-a",
1027  		Bids: []OrderEntry{{Price: 99100, Volume: 5}},
1028  		Asks: []OrderEntry{{Price: 99200, Volume: 3}},
1029  		Time: time.Now(),
1030  	})
1031  	vg.Update(&OrderBook{
1032  		Asset: "BTC", Venue: "exchange-b",
1033  		Bids: []OrderEntry{{Price: 99000, Volume: 8}},
1034  		Asks: []OrderEntry{{Price: 99050, Volume: 10}},
1035  		Time: time.Now(),
1036  	})
1037  
1038  	pf := NewPriceFeed()
1039  	pf.Consolidate(vg)
1040  
1041  	snap := pf.Snapshot("BTC")
1042  	if snap == nil {
1043  		t.Fatal("expected consolidated snapshot")
1044  	}
1045  
1046  	// Best bid: 99100 on exchange-a. Best ask: 99050 on exchange-b.
1047  	if snap.BestBid != 99100 {
1048  		t.Errorf("best bid = %f, want 99100", snap.BestBid)
1049  	}
1050  	if snap.BidVenue != "exchange-a" {
1051  		t.Errorf("bid venue = %s, want exchange-a", snap.BidVenue)
1052  	}
1053  	if snap.BestAsk != 99050 {
1054  		t.Errorf("best ask = %f, want 99050", snap.BestAsk)
1055  	}
1056  	if snap.AskVenue != "exchange-b" {
1057  		t.Errorf("ask venue = %s, want exchange-b", snap.AskVenue)
1058  	}
1059  	if len(snap.Venues) != 2 {
1060  		t.Errorf("expected 2 venues, got %d", len(snap.Venues))
1061  	}
1062  }
1063  
1064  func TestPriceFeedSubscribe(t *testing.T) {
1065  	vg := NewVenueGraph()
1066  	vg.Update(&OrderBook{
1067  		Asset: "ETH", Venue: "a",
1068  		Bids: []OrderEntry{{Price: 3000, Volume: 1}},
1069  		Asks: []OrderEntry{{Price: 3010, Volume: 1}},
1070  	})
1071  
1072  	pf := NewPriceFeed()
1073  	ch := pf.Subscribe()
1074  
1075  	pf.Consolidate(vg)
1076  
1077  	select {
1078  	case snap := <-ch:
1079  		if snap.Asset != "ETH" {
1080  			t.Errorf("subscriber received asset %s, want ETH", snap.Asset)
1081  		}
1082  	default:
1083  		t.Error("subscriber should have received a snapshot")
1084  	}
1085  }
1086  
1087  func TestPriceFeedAssets(t *testing.T) {
1088  	vg := NewVenueGraph()
1089  	vg.Update(&OrderBook{Asset: "BTC", Venue: "a", Bids: []OrderEntry{{Price: 100, Volume: 1}}, Asks: []OrderEntry{{Price: 101, Volume: 1}}})
1090  	vg.Update(&OrderBook{Asset: "ETH", Venue: "a", Bids: []OrderEntry{{Price: 3000, Volume: 1}}, Asks: []OrderEntry{{Price: 3010, Volume: 1}}})
1091  
1092  	pf := NewPriceFeed()
1093  	pf.Consolidate(vg)
1094  
1095  	assets := pf.Assets()
1096  	if len(assets) != 2 {
1097  		t.Errorf("expected 2 assets, got %d", len(assets))
1098  	}
1099  }
1100  
1101  func TestConsolidatedSnapshotToElements(t *testing.T) {
1102  	snap := &ConsolidatedSnapshot{
1103  		Asset:    "BTC",
1104  		BestBid:  99100,
1105  		BidVenue: "exchange-a",
1106  		BestAsk:  99050,
1107  		AskVenue: "exchange-b",
1108  		Spread:   -50,
1109  		Venues: []VenueDepth{
1110  			{Venue: "exchange-a", BidDepth: 5, AskDepth: 3, Spread: 100},
1111  			{Venue: "exchange-b", BidDepth: 8, AskDepth: 10, Spread: 50},
1112  		},
1113  		Time: time.Unix(1700000000, 0),
1114  	}
1115  
1116  	elems := ConsolidatedSnapshotToElements(snap)
1117  	if len(elems) == 0 {
1118  		t.Fatal("snapshot produced no elements")
1119  	}
1120  
1121  	types := make(map[string]bool)
1122  	for _, e := range elems {
1123  		var _ axiom.Element = e
1124  		types[e.Type()] = true
1125  	}
1126  
1127  	for _, want := range []string{"asset", "consolidated-bid", "consolidated-ask", "consolidated-spread", "venue-count"} {
1128  		if !types[want] {
1129  			t.Errorf("missing element type %q", want)
1130  		}
1131  	}
1132  }
1133  
1134  func TestReputation(t *testing.T) {
1135  	rep := NewReputation()
1136  
1137  	// Author makes 5 predictions, 4 correct.
1138  	for i := range 5 {
1139  		rep.RecordSignal(Prediction{
1140  			Pubkey: "author-a", Asset: "BTC", Direction: 1,
1141  			Time: time.Now().Add(time.Duration(i) * time.Minute),
1142  		})
1143  	}
1144  
1145  	for i := range 4 {
1146  		rep.RecordOutcome(Outcome{
1147  			Prediction: Prediction{Pubkey: "author-a"},
1148  			Correct:    true,
1149  			PriceMove:  float64(i+1) * 0.5,
1150  			ResolvedAt: time.Now(),
1151  		})
1152  	}
1153  	rep.RecordOutcome(Outcome{
1154  		Prediction: Prediction{Pubkey: "author-a"},
1155  		Correct:    false,
1156  		PriceMove:  -0.3,
1157  		ResolvedAt: time.Now(),
1158  	})
1159  
1160  	rec := rep.Author("author-a")
1161  	if rec == nil {
1162  		t.Fatal("expected author record")
1163  	}
1164  	if rec.Signals != 5 {
1165  		t.Errorf("signals = %d, want 5", rec.Signals)
1166  	}
1167  	if rec.Correct != 4 {
1168  		t.Errorf("correct = %d, want 4", rec.Correct)
1169  	}
1170  	if rec.Incorrect != 1 {
1171  		t.Errorf("incorrect = %d, want 1", rec.Incorrect)
1172  	}
1173  	// Accuracy = 4/5 = 0.8
1174  	if rec.Accuracy < 0.79 || rec.Accuracy > 0.81 {
1175  		t.Errorf("accuracy = %f, want 0.8", rec.Accuracy)
1176  	}
1177  	// Score: 0.8 * (5 / (5+10)) = 0.8 * 0.333 ≈ 0.267
1178  	if rec.Score < 0.2 || rec.Score > 0.3 {
1179  		t.Errorf("score = %f, want ~0.267", rec.Score)
1180  	}
1181  }
1182  
1183  func TestReputationTopAuthors(t *testing.T) {
1184  	rep := NewReputation()
1185  
1186  	// Author A: 10 signals, 8 correct.
1187  	for range 10 {
1188  		rep.RecordSignal(Prediction{Pubkey: "a", Asset: "BTC", Direction: 1, Time: time.Now()})
1189  	}
1190  	for range 8 {
1191  		rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "a"}, Correct: true})
1192  	}
1193  	for range 2 {
1194  		rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "a"}, Correct: false})
1195  	}
1196  
1197  	// Author B: 10 signals, 5 correct.
1198  	for range 10 {
1199  		rep.RecordSignal(Prediction{Pubkey: "b", Asset: "BTC", Direction: -1, Time: time.Now()})
1200  	}
1201  	for range 5 {
1202  		rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "b"}, Correct: true})
1203  	}
1204  	for range 5 {
1205  		rep.RecordOutcome(Outcome{Prediction: Prediction{Pubkey: "b"}, Correct: false})
1206  	}
1207  
1208  	top := rep.TopAuthors(2)
1209  	if len(top) != 2 {
1210  		t.Fatalf("expected 2 top authors, got %d", len(top))
1211  	}
1212  	if top[0].Pubkey != "a" {
1213  		t.Errorf("top author should be 'a' (higher accuracy), got %q", top[0].Pubkey)
1214  	}
1215  }
1216  
1217  func TestReputationUnknownAuthor(t *testing.T) {
1218  	rep := NewReputation()
1219  	if rep.Author("nonexistent") != nil {
1220  		t.Error("expected nil for unknown author")
1221  	}
1222  	if rep.AuthorCount() != 0 {
1223  		t.Errorf("expected 0 authors, got %d", rep.AuthorCount())
1224  	}
1225  }
1226  
1227  func TestAuthorRecordToElements(t *testing.T) {
1228  	rec := &AuthorRecord{
1229  		Pubkey:     "abc123",
1230  		Signals:    20,
1231  		Correct:    15,
1232  		Incorrect:  5,
1233  		Accuracy:   0.75,
1234  		Score:      0.5,
1235  		LastSignal: time.Unix(1700000000, 0),
1236  	}
1237  
1238  	elems := AuthorRecordToElements(rec)
1239  	if len(elems) == 0 {
1240  		t.Fatal("author record produced no elements")
1241  	}
1242  
1243  	types := make(map[string]bool)
1244  	for _, e := range elems {
1245  		var _ axiom.Element = e
1246  		types[e.Type()] = true
1247  	}
1248  
1249  	for _, want := range []string{"pubkey", "signal-count", "accuracy", "reputation-score"} {
1250  		if !types[want] {
1251  			t.Errorf("missing element type %q", want)
1252  		}
1253  	}
1254  }
1255  
1256  func TestRevenueAccounting(t *testing.T) {
1257  	ra := NewRevenueAccounting()
1258  
1259  	ra.Record(RevenueEntry{
1260  		Source: ArbitrageRevenue, Amount: 100, Asset: "BTC",
1261  		Note: "cross-venue arb", Time: time.Now(),
1262  	})
1263  	ra.Record(RevenueEntry{
1264  		Source: InfrastructureRevenue, Amount: 50, Asset: "BTC",
1265  		Note: "feed subscription", Time: time.Now(),
1266  	})
1267  	ra.Record(RevenueEntry{
1268  		Source: ArbitrageRevenue, Amount: 30, Asset: "ETH",
1269  		Note: "dislocation", Time: time.Now(),
1270  	})
1271  
1272  	s := ra.Summary()
1273  	if s.Arbitrage != 130 {
1274  		t.Errorf("arbitrage = %f, want 130", s.Arbitrage)
1275  	}
1276  	if s.Infrastructure != 50 {
1277  		t.Errorf("infrastructure = %f, want 50", s.Infrastructure)
1278  	}
1279  	if s.Total != 180 {
1280  		t.Errorf("total = %f, want 180", s.Total)
1281  	}
1282  	// InfraRatio = 50/180 ≈ 0.278
1283  	if s.InfraRatio < 0.27 || s.InfraRatio > 0.29 {
1284  		t.Errorf("infra ratio = %f, want ~0.278", s.InfraRatio)
1285  	}
1286  	if s.EntryCount != 3 {
1287  		t.Errorf("entry count = %d, want 3", s.EntryCount)
1288  	}
1289  }
1290  
1291  func TestRevenueAccountingTransition(t *testing.T) {
1292  	ra := NewRevenueAccounting()
1293  
1294  	// Initially not transitioning.
1295  	if ra.IsTransitioning() {
1296  		t.Error("should not be transitioning with no entries")
1297  	}
1298  
1299  	// Add more infrastructure than arbitrage.
1300  	ra.Record(RevenueEntry{Source: ArbitrageRevenue, Amount: 40})
1301  	ra.Record(RevenueEntry{Source: InfrastructureRevenue, Amount: 60})
1302  
1303  	if !ra.IsTransitioning() {
1304  		t.Error("should be transitioning when infra > arb")
1305  	}
1306  }
1307  
1308  func TestRevenueSummaryToElements(t *testing.T) {
1309  	s := &RevenueSummary{
1310  		Arbitrage:      100,
1311  		Infrastructure: 200,
1312  		Total:          300,
1313  		InfraRatio:     0.667,
1314  		EntryCount:     5,
1315  	}
1316  
1317  	elems := RevenueSummaryToElements(s)
1318  	if len(elems) == 0 {
1319  		t.Fatal("revenue summary produced no elements")
1320  	}
1321  
1322  	types := make(map[string]bool)
1323  	for _, e := range elems {
1324  		var _ axiom.Element = e
1325  		types[e.Type()] = true
1326  	}
1327  
1328  	for _, want := range []string{"revenue-arbitrage", "revenue-infrastructure", "revenue-total", "revenue-infra-ratio"} {
1329  		if !types[want] {
1330  			t.Errorf("missing element type %q", want)
1331  		}
1332  	}
1333  }
1334  
1335  // --- Stage 15: Bar decomposition tests ---
1336  
1337  func TestBarToElements(t *testing.T) {
1338  	bar := &Bar{
1339  		Symbol:     "BTC/USD",
1340  		Open:       100000,
1341  		High:       101500,
1342  		Low:        99500,
1343  		Close:      101000,
1344  		Volume:     1234.56,
1345  		VWAP:       100700,
1346  		TradeCount: 5000,
1347  		Timestamp:  time.Unix(1700000000, 0),
1348  	}
1349  
1350  	elems := BarToElements(bar)
1351  	if len(elems) == 0 {
1352  		t.Fatal("bar produced no elements")
1353  	}
1354  
1355  	// All elements must satisfy axiom.Element.
1356  	for _, e := range elems {
1357  		var _ axiom.Element = e
1358  		if e.Type() == "" {
1359  			t.Error("element has empty type tag")
1360  		}
1361  		if e.Value() == nil {
1362  			t.Error("element has nil value")
1363  		}
1364  	}
1365  
1366  	// Check expected element types.
1367  	types := make(map[string]bool)
1368  	for _, e := range elems {
1369  		types[e.Type()] = true
1370  	}
1371  
1372  	for _, want := range []string{
1373  		"asset", "bar-open", "bar-high", "bar-low", "bar-close",
1374  		"bar-volume", "bar-vwap", "bar-trades", "timestamp",
1375  		"bar-body", "bar-range", "bar-upper-shadow", "bar-lower-shadow",
1376  		"bar-vwap-deviation",
1377  	} {
1378  		if !types[want] {
1379  			t.Errorf("missing element type %q", want)
1380  		}
1381  	}
1382  }
1383  
1384  func TestBarToElementsBullish(t *testing.T) {
1385  	bar := &Bar{
1386  		Symbol: "ETH/USD",
1387  		Open:   3000,
1388  		High:   3100,
1389  		Low:    2950,
1390  		Close:  3080,
1391  		Volume: 500,
1392  		VWAP:   3040,
1393  	}
1394  
1395  	elems := BarToElements(bar)
1396  
1397  	// Find body element — should be positive (bullish).
1398  	for _, e := range elems {
1399  		if e.Type() == "bar-body" {
1400  			body, ok := e.Value().(float64)
1401  			if !ok {
1402  				t.Fatal("bar-body value is not float64")
1403  			}
1404  			if body != 80 {
1405  				t.Errorf("bar-body = %f, want 80 (3080-3000)", body)
1406  			}
1407  		}
1408  	}
1409  }
1410  
1411  func TestBarToElementsBearish(t *testing.T) {
1412  	bar := &Bar{
1413  		Symbol: "ETH/USD",
1414  		Open:   3100,
1415  		High:   3150,
1416  		Low:    2900,
1417  		Close:  2950,
1418  		Volume: 800,
1419  		VWAP:   3000,
1420  	}
1421  
1422  	elems := BarToElements(bar)
1423  
1424  	for _, e := range elems {
1425  		if e.Type() == "bar-body" {
1426  			body, ok := e.Value().(float64)
1427  			if !ok {
1428  				t.Fatal("bar-body value is not float64")
1429  			}
1430  			if body != -150 {
1431  				t.Errorf("bar-body = %f, want -150 (2950-3100)", body)
1432  			}
1433  		}
1434  	}
1435  }
1436  
1437  func TestBarToElementsZeroRange(t *testing.T) {
1438  	bar := &Bar{
1439  		Symbol: "DOGE/USD",
1440  		Open:   0.10,
1441  		High:   0.10,
1442  		Low:    0.10,
1443  		Close:  0.10,
1444  		Volume: 100,
1445  	}
1446  
1447  	elems := BarToElements(bar)
1448  
1449  	// With zero range, should NOT have bar-range or shadow elements.
1450  	types := make(map[string]bool)
1451  	for _, e := range elems {
1452  		types[e.Type()] = true
1453  	}
1454  
1455  	if types["bar-range"] {
1456  		t.Error("zero-range bar should not have bar-range element")
1457  	}
1458  	if types["bar-upper-shadow"] {
1459  		t.Error("zero-range bar should not have shadow elements")
1460  	}
1461  }
1462  
1463  // TestBarCoherence verifies that bar elements are structurally
1464  // indistinguishable from event elements and order book elements.
1465  // The lattice sees structure, not domain.
1466  func TestBarCoherence(t *testing.T) {
1467  	// Bar elements.
1468  	bar := &Bar{
1469  		Symbol:    "BTC/USD",
1470  		Open:      100000,
1471  		High:      101000,
1472  		Low:       99000,
1473  		Close:     100500,
1474  		Volume:    1000,
1475  		VWAP:      100300,
1476  		Timestamp: time.Unix(1700000000, 0),
1477  	}
1478  	barElems := BarToElements(bar)
1479  
1480  	// Event elements.
1481  	ev := &nostr.Event{
1482  		ID:        "abcdef1234567890",
1483  		Pubkey:    "pubkeyhex",
1484  		CreatedAt: 1700000000,
1485  		Kind:      1,
1486  		Content:   "Bitcoin going up $BTC",
1487  	}
1488  	eventElems := nostr.EventToElements(ev)
1489  
1490  	// Order book elements.
1491  	ob := &OrderBook{
1492  		Asset: "BTC",
1493  		Venue: "exchange-a",
1494  		Bids:  []OrderEntry{{Price: 100000, Volume: 1, Side: Bid}},
1495  		Asks:  []OrderEntry{{Price: 100100, Volume: 1, Side: Ask}},
1496  		Time:  time.Unix(1700000000, 0),
1497  	}
1498  	obElems := OrderBookToElements(ob)
1499  
1500  	// All three must produce non-empty element slices.
1501  	if len(barElems) == 0 {
1502  		t.Fatal("bar decomposition produced no elements")
1503  	}
1504  	if len(eventElems) == 0 {
1505  		t.Fatal("event decomposition produced no elements")
1506  	}
1507  	if len(obElems) == 0 {
1508  		t.Fatal("order book decomposition produced no elements")
1509  	}
1510  
1511  	// All must satisfy the same interface — the lattice sees them identically.
1512  	allElems := make([]axiom.Element, 0, len(barElems)+len(eventElems)+len(obElems))
1513  	allElems = append(allElems, barElems...)
1514  	allElems = append(allElems, eventElems...)
1515  	allElems = append(allElems, obElems...)
1516  
1517  	for i, e := range allElems {
1518  		if e.Type() == "" {
1519  			t.Errorf("element %d has empty type", i)
1520  		}
1521  		if e.Value() == nil {
1522  			t.Errorf("element %d has nil value", i)
1523  		}
1524  	}
1525  
1526  	// All three domains share the "timestamp" element type —
1527  	// the bonding surface where bars, events, and books meet.
1528  	hasTimestamp := func(elems []axiom.Element) bool {
1529  		for _, e := range elems {
1530  			if e.Type() == "timestamp" {
1531  				return true
1532  			}
1533  		}
1534  		return false
1535  	}
1536  	if !hasTimestamp(barElems) {
1537  		t.Error("bar elements missing timestamp")
1538  	}
1539  	if !hasTimestamp(eventElems) {
1540  		t.Error("event elements missing timestamp")
1541  	}
1542  	if !hasTimestamp(obElems) {
1543  		t.Error("order book elements missing timestamp")
1544  	}
1545  }
1546  
1547  func TestAlpacaClientCreation(t *testing.T) {
1548  	cfg := AlpacaConfig{
1549  		APIKey:    "test-key",
1550  		APISecret: "test-secret",
1551  		Symbols:   []string{"BTC/USD", "ETH/USD"},
1552  		Paper:     true,
1553  	}
1554  
1555  	client := NewAlpacaClient(cfg)
1556  	if client == nil {
1557  		t.Fatal("expected non-nil client")
1558  	}
1559  	if len(client.Symbols) != 2 {
1560  		t.Errorf("expected 2 symbols, got %d", len(client.Symbols))
1561  	}
1562  	if client.Symbols[0] != "BTC/USD" {
1563  		t.Errorf("first symbol = %s, want BTC/USD", client.Symbols[0])
1564  	}
1565  	if client.IsConnected() {
1566  		t.Error("new client should not be connected")
1567  	}
1568  }
1569  
1570  func TestAlpacaExecutorCreation(t *testing.T) {
1571  	exec := NewAlpacaExecutor("test-key", "test-secret", true)
1572  	if exec == nil {
1573  		t.Fatal("expected non-nil executor")
1574  	}
1575  	if !exec.Paper {
1576  		t.Error("expected paper trading mode")
1577  	}
1578  }
1579  
1580  func TestAlpacaExecutorWire(t *testing.T) {
1581  	ex := NewExecutor(100000)
1582  	alpacaExec := NewAlpacaExecutor("test-key", "test-secret", false)
1583  	alpacaExec.WireExecutor(ex)
1584  
1585  	if ex.Mode != LiveTrading {
1586  		t.Errorf("expected LiveTrading mode, got %s", ex.Mode)
1587  	}
1588  	if ex.Execute == nil {
1589  		t.Error("expected Execute callback to be set")
1590  	}
1591  }
1592  
1593  func TestAlpacaExecutorWirePaper(t *testing.T) {
1594  	ex := NewExecutor(100000)
1595  	alpacaExec := NewAlpacaExecutor("test-key", "test-secret", true)
1596  	alpacaExec.WireExecutor(ex)
1597  
1598  	if ex.Mode != PaperTrading {
1599  		t.Errorf("expected PaperTrading mode, got %s", ex.Mode)
1600  	}
1601  }
1602