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