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