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