enzyme.go raw
1 package market
2
3 import (
4 "fmt"
5 "time"
6
7 "git.mleku.dev/mleku/dendrite/pkg/axiom"
8 )
9
10 // element is the concrete axiom.Element type for market data.
11 // Same pattern as nostr/enzyme.go — no special machinery.
12 type element struct {
13 tag string
14 val any
15 }
16
17 func (e element) Type() string { return e.tag }
18 func (e element) Value() any { return e.val }
19
20 // assetElement extends element with symbol annotation.
21 // Used by BarToElements so per-asset lattice regions can filter by symbol.
22 type assetElement struct {
23 element
24 symbol string
25 }
26
27 func (e assetElement) Symbol() string { return e.symbol }
28
29 // OrderBookToElements decomposes an order book snapshot into typed
30 // lattice elements. Each book produces elements for its structural
31 // parts: asset, venue, price levels, spread, depth imbalance.
32 //
33 // The output is structurally indistinguishable from EventToElements.
34 // Price is structure. Events are structure. The lattice sees structure.
35 func OrderBookToElements(ob *OrderBook) []axiom.Element {
36 var elems []axiom.Element
37
38 // Asset identifier.
39 elems = append(elems, element{"asset", ob.Asset})
40
41 // Venue.
42 elems = append(elems, element{"venue", string(ob.Venue)})
43
44 // Timestamp.
45 elems = append(elems, element{"timestamp", fmt.Sprintf("%d", ob.Time.Unix())})
46
47 // Price levels — each is a typed element.
48 for i, bid := range ob.Bids {
49 elems = append(elems, element{"bid", bid.Price})
50 elems = append(elems, element{"bid-volume", bid.Volume})
51 if i >= 9 {
52 break // top 10 levels
53 }
54 }
55 for i, ask := range ob.Asks {
56 elems = append(elems, element{"ask", ask.Price})
57 elems = append(elems, element{"ask-volume", ask.Volume})
58 if i >= 9 {
59 break
60 }
61 }
62
63 // Spread as a derived structural element.
64 spread := ob.Spread()
65 if spread != 0 {
66 elems = append(elems, element{"spread", spread})
67 mid := ob.MidPrice()
68 if mid != 0 {
69 elems = append(elems, element{"spread-bps", (spread / mid) * 10000})
70 }
71 }
72
73 // Depth imbalance (top 5 levels).
74 elems = append(elems, element{"depth-imbalance", ob.DepthImbalance(5)})
75
76 return elems
77 }
78
79 // Bar is an OHLCV candlestick — the primary market data structure from Alpaca.
80 // Structurally identical to a Nostr event: a timestamped data point that
81 // decomposes into typed elements through enzymatic decomposition.
82 type Bar struct {
83 Symbol string
84 Open float64
85 High float64
86 Low float64
87 Close float64
88 Volume float64
89 VWAP float64
90 TradeCount uint64
91 Timestamp time.Time
92 }
93
94 // BarToElements decomposes an OHLCV bar into typed lattice elements.
95 // The output is structurally indistinguishable from EventToElements
96 // or OrderBookToElements. The lattice sees structure.
97 func BarToElements(b *Bar) []axiom.Element {
98 s := b.Symbol // all elements carry their asset's symbol
99 ae := func(tag string, val any) axiom.Element {
100 return assetElement{element{tag, val}, s}
101 }
102 elems := []axiom.Element{
103 ae("asset", b.Symbol),
104 ae("bar-open", b.Open),
105 ae("bar-high", b.High),
106 ae("bar-low", b.Low),
107 ae("bar-close", b.Close),
108 ae("bar-volume", b.Volume),
109 ae("bar-vwap", b.VWAP),
110 ae("bar-trades", b.TradeCount),
111 ae("timestamp", fmt.Sprintf("%d", b.Timestamp.Unix())),
112 }
113
114 // Derived structural elements — the shape of the candle.
115 body := b.Close - b.Open
116 elems = append(elems, ae("bar-body", body))
117
118 rang := b.High - b.Low
119 if rang > 0 {
120 elems = append(elems, ae("bar-range", rang))
121 // Upper and lower shadows as fractions of range.
122 if body >= 0 {
123 elems = append(elems, ae("bar-upper-shadow", (b.High-b.Close)/rang))
124 elems = append(elems, ae("bar-lower-shadow", (b.Open-b.Low)/rang))
125 } else {
126 elems = append(elems, ae("bar-upper-shadow", (b.High-b.Open)/rang))
127 elems = append(elems, ae("bar-lower-shadow", (b.Close-b.Low)/rang))
128 }
129 }
130
131 // Volume-weighted price deviation: how far VWAP is from the midpoint.
132 if b.VWAP > 0 {
133 mid := (b.High + b.Low) / 2
134 elems = append(elems, ae("bar-vwap-deviation", b.VWAP-mid))
135 }
136
137 return elems
138 }
139
140 // OrderEntryToElements decomposes a single order entry into elements.
141 func OrderEntryToElements(entry *OrderEntry) []axiom.Element {
142 return []axiom.Element{
143 element{"price", entry.Price},
144 element{"volume", entry.Volume},
145 element{"side", entry.Side.String()},
146 element{"venue", string(entry.Venue)},
147 element{"timestamp", fmt.Sprintf("%d", entry.Time.Unix())},
148 }
149 }
150