trade.go raw
1 package market
2
3 import (
4 "fmt"
5 "math"
6 "sync"
7 "time"
8
9 "git.mleku.dev/mleku/dendrite/pkg/axiom"
10 )
11
12 // OrderSide is buy or sell for trade execution.
13 type OrderSide int
14
15 const (
16 Buy OrderSide = iota
17 Sell
18 )
19
20 func (s OrderSide) String() string {
21 if s == Buy {
22 return "buy"
23 }
24 return "sell"
25 }
26
27 // TradeOrder is a request to execute a trade.
28 type TradeOrder struct {
29 ID string
30 Asset string
31 Side OrderSide
32 Venue Venue
33 Price float64 // limit price; 0 = market
34 Quantity float64
35 CreatedAt time.Time
36 }
37
38 // Fill is the result of an executed order.
39 type Fill struct {
40 OrderID string
41 Asset string
42 Side OrderSide
43 Venue Venue
44 Price float64
45 Quantity float64
46 Fee float64
47 FilledAt time.Time
48 }
49
50 // Position tracks the organism's exposure in a single asset.
51 type Position struct {
52 Asset string
53 Quantity float64 // positive = long, negative = short
54 AvgEntry float64 // weighted average entry price
55 Realized float64 // cumulative realized P&L
56 FillCount int
57 }
58
59 // MarkToMarket returns unrealized P&L at the given market price.
60 func (p *Position) MarkToMarket(marketPrice float64) float64 {
61 if p.Quantity == 0 {
62 return 0
63 }
64 return p.Quantity * (marketPrice - p.AvgEntry)
65 }
66
67 // TotalPnL returns realized + unrealized P&L at the given market price.
68 func (p *Position) TotalPnL(marketPrice float64) float64 {
69 return p.Realized + p.MarkToMarket(marketPrice)
70 }
71
72 // ApplyFill updates the position based on a trade fill.
73 func (p *Position) ApplyFill(f *Fill) {
74 qty := f.Quantity
75 if f.Side == Sell {
76 qty = -qty
77 }
78
79 if p.Quantity == 0 {
80 // Opening position.
81 p.AvgEntry = f.Price
82 p.Quantity = qty
83 } else if (p.Quantity > 0 && qty > 0) || (p.Quantity < 0 && qty < 0) {
84 // Adding to position — update weighted average.
85 totalQty := p.Quantity + qty
86 p.AvgEntry = (p.AvgEntry*p.Quantity + f.Price*qty) / totalQty
87 p.Quantity = totalQty
88 } else {
89 // Reducing or closing — realize P&L.
90 closingQty := qty
91 if math.Abs(qty) > math.Abs(p.Quantity) {
92 closingQty = -p.Quantity // close only existing position
93 }
94 p.Realized += -closingQty * (f.Price - p.AvgEntry)
95 p.Quantity += qty
96
97 // If position flipped, reset avg entry.
98 if (p.Quantity > 0 && qty > 0) || (p.Quantity < 0 && qty < 0) {
99 p.AvgEntry = f.Price
100 }
101 }
102 p.Realized -= f.Fee
103 p.FillCount++
104 }
105
106 // Portfolio tracks all positions and aggregate economics.
107 type Portfolio struct {
108 mu sync.Mutex
109 positions map[string]*Position // asset → position
110 fills []Fill
111 costs CostLedger
112 }
113
114 // CostLedger tracks operational costs: API fees, compute, bandwidth.
115 type CostLedger struct {
116 ExchangeFees float64
117 APIFees float64
118 Compute float64
119 Bandwidth float64
120 }
121
122 // Total returns total operational cost.
123 func (c *CostLedger) Total() float64 {
124 return c.ExchangeFees + c.APIFees + c.Compute + c.Bandwidth
125 }
126
127 // NewPortfolio creates an empty portfolio.
128 func NewPortfolio() *Portfolio {
129 return &Portfolio{
130 positions: make(map[string]*Position),
131 }
132 }
133
134 // RecordFill applies a fill to the appropriate position.
135 func (pf *Portfolio) RecordFill(f Fill) {
136 pf.mu.Lock()
137 defer pf.mu.Unlock()
138
139 pos, ok := pf.positions[f.Asset]
140 if !ok {
141 pos = &Position{Asset: f.Asset}
142 pf.positions[f.Asset] = pos
143 }
144 pos.ApplyFill(&f)
145 pf.fills = append(pf.fills, f)
146 pf.costs.ExchangeFees += f.Fee
147 }
148
149 // AddCost records a non-trade cost.
150 func (pf *Portfolio) AddCost(apiCost, computeCost, bandwidthCost float64) {
151 pf.mu.Lock()
152 defer pf.mu.Unlock()
153 pf.costs.APIFees += apiCost
154 pf.costs.Compute += computeCost
155 pf.costs.Bandwidth += bandwidthCost
156 }
157
158 // Position returns the position for an asset, or nil.
159 func (pf *Portfolio) Position(asset string) *Position {
160 pf.mu.Lock()
161 defer pf.mu.Unlock()
162 return pf.positions[asset]
163 }
164
165 // TotalRealized returns aggregate realized P&L across all positions.
166 func (pf *Portfolio) TotalRealized() float64 {
167 pf.mu.Lock()
168 defer pf.mu.Unlock()
169 total := 0.0
170 for _, p := range pf.positions {
171 total += p.Realized
172 }
173 return total
174 }
175
176 // TotalCosts returns aggregate operational costs.
177 func (pf *Portfolio) TotalCosts() CostLedger {
178 pf.mu.Lock()
179 defer pf.mu.Unlock()
180 return pf.costs
181 }
182
183 // FillCount returns total number of fills recorded.
184 func (pf *Portfolio) FillCount() int {
185 pf.mu.Lock()
186 defer pf.mu.Unlock()
187 return len(pf.fills)
188 }
189
190 // WinLossCounts returns the number of positions with positive and negative realized P&L.
191 func (pf *Portfolio) WinLossCounts() (wins, losses int) {
192 pf.mu.Lock()
193 defer pf.mu.Unlock()
194 for _, p := range pf.positions {
195 if p.Realized > 0 {
196 wins++
197 } else if p.Realized < 0 {
198 losses++
199 }
200 }
201 return
202 }
203
204 // FlattenAll closes all open positions at the given market prices.
205 // Returns the number of positions flattened and total P&L realized.
206 // This is used at the end of each generation to start the next one clean.
207 func (pf *Portfolio) FlattenAll(prices map[string]float64) (int, float64) {
208 pf.mu.Lock()
209 defer pf.mu.Unlock()
210 flattened := 0
211 totalPnL := 0.0
212 for asset, pos := range pf.positions {
213 if pos.Quantity == 0 {
214 continue
215 }
216 price, ok := prices[asset]
217 if !ok {
218 continue
219 }
220 // Create a closing fill.
221 side := Sell
222 qty := pos.Quantity
223 if qty < 0 {
224 side = Buy
225 qty = -qty
226 }
227 f := Fill{
228 Asset: asset,
229 Side: side,
230 Price: price,
231 Quantity: qty,
232 Fee: 0, // no fee for internal flattening
233 FilledAt: time.Now(),
234 }
235 pnl := pos.MarkToMarket(price)
236 pos.ApplyFill(&f)
237 pf.fills = append(pf.fills, f)
238 totalPnL += pnl
239 flattened++
240 }
241 return flattened, totalPnL
242 }
243
244 // AggregateExposure returns total absolute notional exposure across all positions.
245 func (pf *Portfolio) AggregateExposure() float64 {
246 pf.mu.Lock()
247 defer pf.mu.Unlock()
248 total := 0.0
249 for _, p := range pf.positions {
250 total += math.Abs(p.Quantity * p.AvgEntry)
251 }
252 return total
253 }
254
255 // EconomicFitness returns revenue minus all costs for a generation.
256 // Positive is coherent. Negative is incoherent. Zero is homeostasis.
257 type EconomicFitness struct {
258 Generation int
259 Realized float64 // realized trading P&L
260 Costs CostLedger // operational costs
261 TradeCount int
262 WinCount int
263 LossCount int
264 Time time.Time
265 }
266
267 // NetRevenue returns realized P&L minus total operational cost.
268 func (ef *EconomicFitness) NetRevenue() float64 {
269 return ef.Realized - ef.Costs.Total()
270 }
271
272 // WinRate returns the fraction of profitable trades.
273 func (ef *EconomicFitness) WinRate() float64 {
274 if ef.TradeCount == 0 {
275 return 0
276 }
277 return float64(ef.WinCount) / float64(ef.TradeCount)
278 }
279
280 // IsViable returns true if the organism covers its own costs.
281 func (ef *EconomicFitness) IsViable() bool {
282 return ef.NetRevenue() >= 0
283 }
284
285 // FillToElements decomposes a trade fill into lattice elements.
286 func FillToElements(f *Fill) []axiom.Element {
287 return []axiom.Element{
288 element{"asset", f.Asset},
289 element{"trade-side", f.Side.String()},
290 element{"trade-price", f.Price},
291 element{"trade-quantity", f.Quantity},
292 element{"trade-fee", f.Fee},
293 element{"venue", string(f.Venue)},
294 element{"timestamp", fmt.Sprintf("%d", f.FilledAt.Unix())},
295 }
296 }
297
298 // EconomicFitnessToElements decomposes economic fitness into lattice elements.
299 func EconomicFitnessToElements(ef *EconomicFitness) []axiom.Element {
300 return []axiom.Element{
301 element{"generation", ef.Generation},
302 element{"realized-pnl", ef.Realized},
303 element{"net-revenue", ef.NetRevenue()},
304 element{"trade-count", ef.TradeCount},
305 element{"win-rate", ef.WinRate()},
306 element{"exchange-fees", ef.Costs.ExchangeFees},
307 element{"api-fees", ef.Costs.APIFees},
308 element{"economic-viable", ef.IsViable()},
309 element{"timestamp", fmt.Sprintf("%d", ef.Time.Unix())},
310 }
311 }
312