mirror of
https://github.com/luxfi/dex.git
synced 2026-10-30 14:45:46 +00:00
Lux Partners Limited is not the entity behind this work. Every place the old name stood — copyright lines, site footers, brand config, legal and compliance text — now names the company that actually holds it. lux.fund is the exception and is not renamed to match: that site belongs to Lux Assets Management LLC, a different company, and it says so. Two kinds of line were left for a person rather than rewritten. A dated news item recording that Lux Partners Limited was established in the Isle of Man is a historical fact, and renaming it would falsify the record. And a sentence asserting that the company is domiciled in the Isle of Man is a claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle of Man company, so swapping the name there would assert something untrue. Co-authored-by: Hanzo Dev <[email protected]>
411 lines
11 KiB
Go
411 lines
11 KiB
Go
// Copyright 2024 Lux Industries Inc. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// Example: Market Making with Spreads
|
|
//
|
|
// This example demonstrates a simple market making strategy:
|
|
// - Placing bid/ask orders around the mid price
|
|
// - Managing order inventory
|
|
// - Adjusting spreads based on volatility
|
|
// - Position management and hedging
|
|
//
|
|
// WARNING: This is an educational example. Real market making
|
|
// requires much more sophisticated risk management.
|
|
//
|
|
// Run with: go run main.go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
trading "github.com/luxfi/trading"
|
|
_ "github.com/luxfi/trading/adapters"
|
|
"github.com/shopspring/decimal"
|
|
)
|
|
|
|
// MarketMaker implements a simple market making strategy.
|
|
type MarketMaker struct {
|
|
client *trading.Client
|
|
config MarketMakerConfig
|
|
|
|
// State
|
|
mu sync.RWMutex
|
|
position decimal.Decimal
|
|
activeOrders map[string]trading.Order
|
|
totalPnL decimal.Decimal
|
|
tradesCount int
|
|
lastMidPrice decimal.Decimal
|
|
volatility decimal.Decimal
|
|
|
|
// Control
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// MarketMakerConfig configures the market maker.
|
|
type MarketMakerConfig struct {
|
|
// Trading parameters
|
|
Symbol string
|
|
Venue string
|
|
BaseSpreadBps decimal.Decimal // Base spread in basis points
|
|
OrderSize decimal.Decimal // Size per order
|
|
MaxPosition decimal.Decimal // Maximum position (absolute)
|
|
NumLevels int // Number of price levels
|
|
LevelSpacingBps decimal.Decimal // Spacing between levels
|
|
|
|
// Risk parameters
|
|
MaxDailyLoss decimal.Decimal
|
|
PositionSkewBps decimal.Decimal // Skew spread based on position
|
|
|
|
// Timing
|
|
QuoteInterval time.Duration
|
|
CancelAgeSeconds int
|
|
}
|
|
|
|
// DefaultMarketMakerConfig returns sensible defaults.
|
|
func DefaultMarketMakerConfig() MarketMakerConfig {
|
|
return MarketMakerConfig{
|
|
Symbol: "BTC-USDC",
|
|
Venue: "lx_dex",
|
|
BaseSpreadBps: decimal.NewFromInt(10), // 0.10%
|
|
OrderSize: decimal.NewFromFloat(0.01), // 0.01 BTC
|
|
MaxPosition: decimal.NewFromFloat(0.1), // 0.1 BTC
|
|
NumLevels: 3,
|
|
LevelSpacingBps: decimal.NewFromInt(5), // 0.05%
|
|
MaxDailyLoss: decimal.NewFromFloat(100), // $100
|
|
PositionSkewBps: decimal.NewFromInt(2), // Skew per position unit
|
|
QuoteInterval: time.Second,
|
|
CancelAgeSeconds: 30,
|
|
}
|
|
}
|
|
|
|
// NewMarketMaker creates a new market maker.
|
|
func NewMarketMaker(client *trading.Client, config MarketMakerConfig) *MarketMaker {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &MarketMaker{
|
|
client: client,
|
|
config: config,
|
|
activeOrders: make(map[string]trading.Order),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start begins market making.
|
|
func (mm *MarketMaker) Start() {
|
|
log.Printf("Starting market maker for %s on %s", mm.config.Symbol, mm.config.Venue)
|
|
log.Printf("Config: spread=%sbps, size=%s, levels=%d",
|
|
mm.config.BaseSpreadBps, mm.config.OrderSize, mm.config.NumLevels)
|
|
|
|
mm.wg.Add(2)
|
|
go mm.quoteLoop()
|
|
go mm.monitorLoop()
|
|
}
|
|
|
|
// Stop stops market making and cancels all orders.
|
|
func (mm *MarketMaker) Stop() {
|
|
log.Println("Stopping market maker...")
|
|
mm.cancel()
|
|
mm.wg.Wait()
|
|
|
|
// Cancel all active orders
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
_, err := mm.client.CancelAllOrders(ctx, mm.config.Symbol, trading.WithVenue(mm.config.Venue))
|
|
if err != nil {
|
|
log.Printf("Error cancelling orders: %v", err)
|
|
}
|
|
|
|
mm.printStats()
|
|
}
|
|
|
|
// quoteLoop continuously updates quotes.
|
|
func (mm *MarketMaker) quoteLoop() {
|
|
defer mm.wg.Done()
|
|
|
|
ticker := time.NewTicker(mm.config.QuoteInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-mm.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
mm.updateQuotes()
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateQuotes places new bid/ask orders.
|
|
func (mm *MarketMaker) updateQuotes() {
|
|
// Get current mid price
|
|
ticker, err := mm.client.Ticker(mm.ctx, mm.config.Symbol, trading.WithVenue(mm.config.Venue))
|
|
if err != nil {
|
|
log.Printf("Failed to get ticker: %v", err)
|
|
return
|
|
}
|
|
|
|
midPrice := ticker.MidPrice()
|
|
if midPrice == nil {
|
|
log.Println("No mid price available")
|
|
return
|
|
}
|
|
|
|
mm.mu.Lock()
|
|
mm.lastMidPrice = *midPrice
|
|
position := mm.position
|
|
mm.mu.Unlock()
|
|
|
|
// Calculate spread with position skew
|
|
// If long, widen ask (want to sell), tighten bid
|
|
// If short, widen bid (want to buy), tighten ask
|
|
halfSpread := mm.config.BaseSpreadBps.Div(decimal.NewFromInt(2))
|
|
positionSkew := position.Mul(mm.config.PositionSkewBps)
|
|
|
|
bidSpread := halfSpread.Add(positionSkew) // Wider if long
|
|
askSpread := halfSpread.Sub(positionSkew) // Tighter if long
|
|
|
|
// Ensure minimum spread
|
|
minSpread := decimal.NewFromInt(1) // 0.01%
|
|
if bidSpread.LessThan(minSpread) {
|
|
bidSpread = minSpread
|
|
}
|
|
if askSpread.LessThan(minSpread) {
|
|
askSpread = minSpread
|
|
}
|
|
|
|
// Cancel stale orders first
|
|
mm.cancelStaleOrders()
|
|
|
|
// Check position limits
|
|
canBuy := position.LessThan(mm.config.MaxPosition)
|
|
canSell := position.Neg().LessThan(mm.config.MaxPosition)
|
|
|
|
// Place orders at multiple levels
|
|
for level := 0; level < mm.config.NumLevels; level++ {
|
|
levelOffset := mm.config.LevelSpacingBps.Mul(decimal.NewFromInt(int64(level)))
|
|
|
|
// Bid price: mid * (1 - (bidSpread + levelOffset) / 10000)
|
|
bidBps := bidSpread.Add(levelOffset)
|
|
bidMultiplier := decimal.NewFromInt(1).Sub(bidBps.Div(decimal.NewFromInt(10000)))
|
|
bidPrice := midPrice.Mul(bidMultiplier)
|
|
|
|
// Ask price: mid * (1 + (askSpread + levelOffset) / 10000)
|
|
askBps := askSpread.Add(levelOffset)
|
|
askMultiplier := decimal.NewFromInt(1).Add(askBps.Div(decimal.NewFromInt(10000)))
|
|
askPrice := midPrice.Mul(askMultiplier)
|
|
|
|
// Place bid
|
|
if canBuy {
|
|
mm.placeOrder(trading.SideBuy, mm.config.OrderSize, bidPrice, level)
|
|
}
|
|
|
|
// Place ask
|
|
if canSell {
|
|
mm.placeOrder(trading.SideSell, mm.config.OrderSize, askPrice, level)
|
|
}
|
|
}
|
|
}
|
|
|
|
// placeOrder places a single order.
|
|
func (mm *MarketMaker) placeOrder(side trading.Side, quantity, price decimal.Decimal, level int) {
|
|
order, err := mm.client.PlaceOrder(mm.ctx, trading.OrderRequest{
|
|
ClientOrderID: fmt.Sprintf("mm-%s-%d-%d", side, level, time.Now().UnixNano()),
|
|
Symbol: mm.config.Symbol,
|
|
Side: side,
|
|
OrderType: trading.OrderTypeLimit,
|
|
Quantity: quantity,
|
|
Price: &price,
|
|
TimeInForce: trading.TimeInForceGTC,
|
|
Venue: mm.config.Venue,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to place %s order at %s: %v", side, price.StringFixed(2), err)
|
|
return
|
|
}
|
|
|
|
mm.mu.Lock()
|
|
mm.activeOrders[order.OrderID] = order
|
|
mm.mu.Unlock()
|
|
|
|
log.Printf("Placed %s %s @ %s (order: %s)",
|
|
side, quantity.StringFixed(4), price.StringFixed(2), order.OrderID)
|
|
}
|
|
|
|
// cancelStaleOrders cancels orders older than threshold.
|
|
func (mm *MarketMaker) cancelStaleOrders() {
|
|
mm.mu.Lock()
|
|
ordersToCancel := make([]string, 0)
|
|
threshold := time.Now().Add(-time.Duration(mm.config.CancelAgeSeconds) * time.Second)
|
|
|
|
for id, order := range mm.activeOrders {
|
|
if order.CreatedAt.Before(threshold) {
|
|
ordersToCancel = append(ordersToCancel, id)
|
|
}
|
|
}
|
|
mm.mu.Unlock()
|
|
|
|
for _, id := range ordersToCancel {
|
|
_, err := mm.client.CancelOrder(mm.ctx, id, mm.config.Symbol, mm.config.Venue)
|
|
if err != nil {
|
|
log.Printf("Failed to cancel order %s: %v", id, err)
|
|
} else {
|
|
mm.mu.Lock()
|
|
delete(mm.activeOrders, id)
|
|
mm.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// monitorLoop monitors fills and updates position.
|
|
func (mm *MarketMaker) monitorLoop() {
|
|
defer mm.wg.Done()
|
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-mm.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
mm.checkFills()
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkFills checks for filled orders and updates position.
|
|
func (mm *MarketMaker) checkFills() {
|
|
openOrders, err := mm.client.OpenOrders(mm.ctx, mm.config.Symbol)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Build map of current open orders
|
|
openOrderIDs := make(map[string]bool)
|
|
for _, o := range openOrders {
|
|
openOrderIDs[o.OrderID] = true
|
|
}
|
|
|
|
// Check which active orders have been filled
|
|
mm.mu.Lock()
|
|
defer mm.mu.Unlock()
|
|
|
|
for id, order := range mm.activeOrders {
|
|
if !openOrderIDs[id] {
|
|
// Order is no longer open - assume filled
|
|
// In production, you would fetch the actual fill details
|
|
delete(mm.activeOrders, id)
|
|
mm.tradesCount++
|
|
|
|
// Update position
|
|
if order.Side == trading.SideBuy {
|
|
mm.position = mm.position.Add(order.Quantity)
|
|
} else {
|
|
mm.position = mm.position.Sub(order.Quantity)
|
|
}
|
|
|
|
log.Printf("Order filled: %s %s %s (position: %s)",
|
|
order.Side, order.Quantity.StringFixed(4), mm.config.Symbol,
|
|
mm.position.StringFixed(4))
|
|
}
|
|
}
|
|
}
|
|
|
|
// printStats prints market maker statistics.
|
|
func (mm *MarketMaker) printStats() {
|
|
mm.mu.RLock()
|
|
defer mm.mu.RUnlock()
|
|
|
|
fmt.Println("\n=== Market Maker Stats ===")
|
|
fmt.Printf("Symbol: %s\n", mm.config.Symbol)
|
|
fmt.Printf("Venue: %s\n", mm.config.Venue)
|
|
fmt.Printf("Position: %s\n", mm.position.StringFixed(8))
|
|
fmt.Printf("Total Trades: %d\n", mm.tradesCount)
|
|
fmt.Printf("Active Orders: %d\n", len(mm.activeOrders))
|
|
fmt.Printf("Last Mid: %s\n", mm.lastMidPrice.StringFixed(2))
|
|
fmt.Println("==========================")
|
|
}
|
|
|
|
func main() {
|
|
// Create configuration
|
|
config := trading.NewConfig()
|
|
|
|
// Add venue
|
|
venue := getEnv("MM_VENUE", "lx_dex")
|
|
if venue == "lx_dex" {
|
|
config.WithNative(venue, trading.NewLxDexConfig(
|
|
getEnv("LX_DEX_URL", "https://api.dex.lux.network"),
|
|
).WithCredentials(
|
|
os.Getenv("LX_DEX_KEY"),
|
|
os.Getenv("LX_DEX_SECRET"),
|
|
))
|
|
} else {
|
|
config.WithCcxt(venue, trading.NewCcxtConfig(venue).
|
|
WithCredentials(
|
|
os.Getenv(venue+"_KEY"),
|
|
os.Getenv(venue+"_SECRET"),
|
|
))
|
|
}
|
|
|
|
// Risk config
|
|
config.Risk = trading.RiskConfig{
|
|
Enabled: true,
|
|
MaxPositionSize: decimal.NewFromFloat(1), // 1 BTC max position
|
|
MaxOrderSize: decimal.NewFromFloat(0.1), // 0.1 BTC max order
|
|
MaxDailyLoss: decimal.NewFromFloat(500), // $500 daily loss limit
|
|
MaxOpenOrders: 50,
|
|
}
|
|
|
|
// Create client
|
|
client := trading.NewClient(config)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := client.Connect(ctx); err != nil {
|
|
log.Fatalf("Failed to connect: %v", err)
|
|
}
|
|
defer client.Disconnect(context.Background())
|
|
|
|
// Create market maker
|
|
mmConfig := DefaultMarketMakerConfig()
|
|
mmConfig.Symbol = getEnv("MM_SYMBOL", "BTC-USDC")
|
|
mmConfig.Venue = venue
|
|
|
|
// Parse config from environment
|
|
if spread := os.Getenv("MM_SPREAD_BPS"); spread != "" {
|
|
mmConfig.BaseSpreadBps, _ = decimal.NewFromString(spread)
|
|
}
|
|
if size := os.Getenv("MM_ORDER_SIZE"); size != "" {
|
|
mmConfig.OrderSize, _ = decimal.NewFromString(size)
|
|
}
|
|
|
|
mm := NewMarketMaker(client, mmConfig)
|
|
mm.Start()
|
|
|
|
// Wait for signal
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
<-sigCh
|
|
mm.Stop()
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return defaultValue
|
|
}
|