mirror of
https://github.com/luxfi/dex.git
synced 2026-11-02 09:17:49 +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]>
525 lines
12 KiB
Go
525 lines
12 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: Position and Risk Tracking
|
|
//
|
|
// This example demonstrates comprehensive risk management:
|
|
// - Position tracking across venues
|
|
// - PnL monitoring
|
|
// - Risk limit enforcement
|
|
// - Kill switch functionality
|
|
// - Portfolio-level metrics
|
|
//
|
|
// 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"
|
|
)
|
|
|
|
// RiskMonitor provides real-time risk monitoring and enforcement.
|
|
type RiskMonitor struct {
|
|
client *trading.Client
|
|
riskManager *trading.RiskManager
|
|
|
|
// Configuration
|
|
config RiskConfig
|
|
|
|
// State
|
|
mu sync.RWMutex
|
|
positions map[string]Position // asset -> position
|
|
pnlHistory []PnLSnapshot
|
|
alerts []Alert
|
|
lastBalance map[string]decimal.Decimal
|
|
|
|
// Control
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// RiskConfig configures risk monitoring.
|
|
type RiskConfig struct {
|
|
// Position limits
|
|
MaxPositionUSD decimal.Decimal
|
|
MaxPositionAsset map[string]decimal.Decimal
|
|
|
|
// PnL limits
|
|
MaxDailyLoss decimal.Decimal
|
|
MaxDrawdown decimal.Decimal
|
|
ProfitTarget decimal.Decimal
|
|
|
|
// Alert thresholds
|
|
AlertPositionPct decimal.Decimal // Alert when position > X% of limit
|
|
AlertLossPct decimal.Decimal // Alert when loss > X% of limit
|
|
|
|
// Kill switch
|
|
EnableKillSwitch bool
|
|
KillOnMaxLoss bool
|
|
|
|
// Monitoring
|
|
CheckInterval time.Duration
|
|
}
|
|
|
|
// Position represents a position in an asset.
|
|
type Position struct {
|
|
Asset string
|
|
Quantity decimal.Decimal
|
|
AvgEntryPrice decimal.Decimal
|
|
MarketPrice decimal.Decimal
|
|
UnrealizedPnL decimal.Decimal
|
|
RealizedPnL decimal.Decimal
|
|
ByVenue map[string]decimal.Decimal
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// PnLSnapshot is a point-in-time PnL record.
|
|
type PnLSnapshot struct {
|
|
Timestamp time.Time
|
|
UnrealizedPnL decimal.Decimal
|
|
RealizedPnL decimal.Decimal
|
|
TotalPnL decimal.Decimal
|
|
}
|
|
|
|
// Alert represents a risk alert.
|
|
type Alert struct {
|
|
Timestamp time.Time
|
|
Level string // "WARNING", "CRITICAL"
|
|
Type string
|
|
Message string
|
|
}
|
|
|
|
// DefaultRiskConfig returns sensible defaults.
|
|
func DefaultRiskConfig() RiskConfig {
|
|
return RiskConfig{
|
|
MaxPositionUSD: decimal.NewFromFloat(100000),
|
|
MaxPositionAsset: make(map[string]decimal.Decimal),
|
|
MaxDailyLoss: decimal.NewFromFloat(5000),
|
|
MaxDrawdown: decimal.NewFromFloat(10000),
|
|
ProfitTarget: decimal.NewFromFloat(10000),
|
|
AlertPositionPct: decimal.NewFromFloat(80),
|
|
AlertLossPct: decimal.NewFromFloat(50),
|
|
EnableKillSwitch: true,
|
|
KillOnMaxLoss: true,
|
|
CheckInterval: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
// NewRiskMonitor creates a new risk monitor.
|
|
func NewRiskMonitor(client *trading.Client, config RiskConfig) *RiskMonitor {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &RiskMonitor{
|
|
client: client,
|
|
riskManager: client.RiskManager(),
|
|
config: config,
|
|
positions: make(map[string]Position),
|
|
lastBalance: make(map[string]decimal.Decimal),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start begins risk monitoring.
|
|
func (rm *RiskMonitor) Start() {
|
|
log.Println("Starting risk monitor")
|
|
log.Printf("Max position: $%s, Max daily loss: $%s",
|
|
rm.config.MaxPositionUSD, rm.config.MaxDailyLoss)
|
|
|
|
rm.wg.Add(1)
|
|
go rm.monitorLoop()
|
|
}
|
|
|
|
// Stop stops risk monitoring.
|
|
func (rm *RiskMonitor) Stop() {
|
|
rm.cancel()
|
|
rm.wg.Wait()
|
|
rm.printFinalReport()
|
|
}
|
|
|
|
// monitorLoop continuously monitors risk metrics.
|
|
func (rm *RiskMonitor) monitorLoop() {
|
|
defer rm.wg.Done()
|
|
|
|
ticker := time.NewTicker(rm.config.CheckInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-rm.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
rm.checkRisk()
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkRisk performs risk checks.
|
|
func (rm *RiskMonitor) checkRisk() {
|
|
// Update positions from balances
|
|
rm.updatePositions()
|
|
|
|
// Check PnL
|
|
rm.checkPnL()
|
|
|
|
// Check position limits
|
|
rm.checkPositionLimits()
|
|
|
|
// Check kill switch
|
|
rm.checkKillSwitch()
|
|
}
|
|
|
|
// updatePositions updates position tracking from venue balances.
|
|
func (rm *RiskMonitor) updatePositions() {
|
|
balances, err := rm.client.Balances(rm.ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
rm.mu.Lock()
|
|
defer rm.mu.Unlock()
|
|
|
|
for _, bal := range balances {
|
|
pos, ok := rm.positions[bal.Asset]
|
|
if !ok {
|
|
pos = Position{
|
|
Asset: bal.Asset,
|
|
ByVenue: make(map[string]decimal.Decimal),
|
|
}
|
|
}
|
|
|
|
// Track quantity by venue
|
|
for _, vb := range bal.ByVenue {
|
|
pos.ByVenue[vb.Venue] = vb.Total()
|
|
}
|
|
|
|
// Total quantity
|
|
pos.Quantity = bal.Total()
|
|
pos.UpdatedAt = time.Now()
|
|
|
|
// Calculate PnL change
|
|
lastTotal, hasLast := rm.lastBalance[bal.Asset]
|
|
if hasLast {
|
|
change := bal.Total().Sub(lastTotal)
|
|
if !change.IsZero() {
|
|
// Simplified: treat balance changes as realized PnL
|
|
// In production, track actual trade fills
|
|
pos.RealizedPnL = pos.RealizedPnL.Add(change)
|
|
}
|
|
}
|
|
rm.lastBalance[bal.Asset] = bal.Total()
|
|
|
|
rm.positions[bal.Asset] = pos
|
|
}
|
|
}
|
|
|
|
// checkPnL checks PnL limits.
|
|
func (rm *RiskMonitor) checkPnL() {
|
|
// Get risk manager state
|
|
pnl := rm.riskManager.DailyPnL()
|
|
|
|
rm.mu.Lock()
|
|
|
|
// Record snapshot
|
|
snapshot := PnLSnapshot{
|
|
Timestamp: time.Now(),
|
|
TotalPnL: pnl,
|
|
}
|
|
rm.pnlHistory = append(rm.pnlHistory, snapshot)
|
|
|
|
// Keep last 24 hours
|
|
cutoff := time.Now().Add(-24 * time.Hour)
|
|
filtered := rm.pnlHistory[:0]
|
|
for _, s := range rm.pnlHistory {
|
|
if s.Timestamp.After(cutoff) {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
rm.pnlHistory = filtered
|
|
|
|
rm.mu.Unlock()
|
|
|
|
// Check loss limit
|
|
if pnl.IsNegative() {
|
|
lossAbs := pnl.Abs()
|
|
lossPct := lossAbs.Div(rm.config.MaxDailyLoss).Mul(decimal.NewFromInt(100))
|
|
|
|
if lossPct.GreaterThan(rm.config.AlertLossPct) {
|
|
rm.addAlert("WARNING", "LOSS",
|
|
fmt.Sprintf("Daily loss at %s%% of limit ($%s)",
|
|
lossPct.StringFixed(1), lossAbs.StringFixed(2)))
|
|
}
|
|
|
|
if lossAbs.GreaterThanOrEqual(rm.config.MaxDailyLoss) {
|
|
rm.addAlert("CRITICAL", "LOSS",
|
|
fmt.Sprintf("Daily loss limit reached: $%s", lossAbs.StringFixed(2)))
|
|
|
|
if rm.config.KillOnMaxLoss {
|
|
rm.triggerKillSwitch("Max daily loss reached")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check profit target
|
|
if pnl.GreaterThanOrEqual(rm.config.ProfitTarget) {
|
|
rm.addAlert("WARNING", "PROFIT",
|
|
fmt.Sprintf("Profit target reached: $%s", pnl.StringFixed(2)))
|
|
}
|
|
}
|
|
|
|
// checkPositionLimits checks position limits.
|
|
func (rm *RiskMonitor) checkPositionLimits() {
|
|
rm.mu.RLock()
|
|
positions := rm.positions
|
|
rm.mu.RUnlock()
|
|
|
|
for _, pos := range positions {
|
|
if pos.Quantity.IsZero() {
|
|
continue
|
|
}
|
|
|
|
// Check asset-specific limit
|
|
if limit, ok := rm.config.MaxPositionAsset[pos.Asset]; ok {
|
|
usagePct := pos.Quantity.Abs().Div(limit).Mul(decimal.NewFromInt(100))
|
|
|
|
if usagePct.GreaterThan(rm.config.AlertPositionPct) {
|
|
rm.addAlert("WARNING", "POSITION",
|
|
fmt.Sprintf("%s position at %s%% of limit (%s / %s)",
|
|
pos.Asset, usagePct.StringFixed(1),
|
|
pos.Quantity.StringFixed(4), limit.StringFixed(4)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkKillSwitch checks if kill switch should be triggered.
|
|
func (rm *RiskMonitor) checkKillSwitch() {
|
|
if rm.riskManager.IsKilled() {
|
|
rm.addAlert("CRITICAL", "KILL_SWITCH", "Kill switch is active - trading halted")
|
|
}
|
|
}
|
|
|
|
// triggerKillSwitch activates the kill switch.
|
|
func (rm *RiskMonitor) triggerKillSwitch(reason string) {
|
|
if !rm.config.EnableKillSwitch {
|
|
return
|
|
}
|
|
|
|
log.Printf("KILL SWITCH ACTIVATED: %s", reason)
|
|
rm.riskManager.Kill()
|
|
rm.addAlert("CRITICAL", "KILL_SWITCH", reason)
|
|
|
|
// Cancel all orders
|
|
for _, symbol := range []string{"BTC-USDC", "ETH-USDC", "LUX-USDC"} {
|
|
rm.client.CancelAllOrders(rm.ctx, symbol)
|
|
}
|
|
}
|
|
|
|
// addAlert adds a risk alert.
|
|
func (rm *RiskMonitor) addAlert(level, alertType, message string) {
|
|
rm.mu.Lock()
|
|
defer rm.mu.Unlock()
|
|
|
|
alert := Alert{
|
|
Timestamp: time.Now(),
|
|
Level: level,
|
|
Type: alertType,
|
|
Message: message,
|
|
}
|
|
|
|
rm.alerts = append(rm.alerts, alert)
|
|
|
|
// Log the alert
|
|
log.Printf("[%s] %s: %s", level, alertType, message)
|
|
|
|
// Keep last 100 alerts
|
|
if len(rm.alerts) > 100 {
|
|
rm.alerts = rm.alerts[len(rm.alerts)-100:]
|
|
}
|
|
}
|
|
|
|
// GetSummary returns a risk summary.
|
|
func (rm *RiskMonitor) GetSummary() RiskSummary {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
summary := RiskSummary{
|
|
Timestamp: time.Now(),
|
|
DailyPnL: rm.riskManager.DailyPnL(),
|
|
IsKilled: rm.riskManager.IsKilled(),
|
|
Positions: make(map[string]decimal.Decimal),
|
|
AlertCount: len(rm.alerts),
|
|
}
|
|
|
|
for asset, pos := range rm.positions {
|
|
if !pos.Quantity.IsZero() {
|
|
summary.Positions[asset] = pos.Quantity
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// RiskSummary provides a snapshot of risk state.
|
|
type RiskSummary struct {
|
|
Timestamp time.Time
|
|
DailyPnL decimal.Decimal
|
|
IsKilled bool
|
|
Positions map[string]decimal.Decimal
|
|
AlertCount int
|
|
}
|
|
|
|
// PrintStatus prints current risk status.
|
|
func (rm *RiskMonitor) PrintStatus() {
|
|
summary := rm.GetSummary()
|
|
|
|
fmt.Printf("\n=== Risk Status (%s) ===\n", summary.Timestamp.Format("15:04:05"))
|
|
fmt.Printf("Daily PnL: $%s\n", summary.DailyPnL.StringFixed(2))
|
|
fmt.Printf("Kill Switch: %v\n", summary.IsKilled)
|
|
fmt.Printf("Alerts: %d\n", summary.AlertCount)
|
|
|
|
if len(summary.Positions) > 0 {
|
|
fmt.Println("\nPositions:")
|
|
for asset, qty := range summary.Positions {
|
|
fmt.Printf(" %s: %s\n", asset, qty.StringFixed(8))
|
|
}
|
|
}
|
|
|
|
// Recent alerts
|
|
rm.mu.RLock()
|
|
if len(rm.alerts) > 0 {
|
|
fmt.Println("\nRecent Alerts:")
|
|
start := len(rm.alerts) - 5
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
for _, a := range rm.alerts[start:] {
|
|
fmt.Printf(" [%s] %s: %s (%s)\n",
|
|
a.Level, a.Type, a.Message,
|
|
a.Timestamp.Format("15:04:05"))
|
|
}
|
|
}
|
|
rm.mu.RUnlock()
|
|
|
|
fmt.Println("=============================")
|
|
}
|
|
|
|
// printFinalReport prints final risk report.
|
|
func (rm *RiskMonitor) printFinalReport() {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
fmt.Println("\n=== Final Risk Report ===")
|
|
fmt.Printf("Session End: %s\n", time.Now().Format("2006-01-02 15:04:05"))
|
|
fmt.Printf("Daily PnL: $%s\n", rm.riskManager.DailyPnL().StringFixed(2))
|
|
fmt.Printf("Kill Switch: %v\n", rm.riskManager.IsKilled())
|
|
fmt.Printf("Total Alerts: %d\n", len(rm.alerts))
|
|
|
|
// Count alerts by type
|
|
alertCounts := make(map[string]int)
|
|
for _, a := range rm.alerts {
|
|
alertCounts[a.Type]++
|
|
}
|
|
if len(alertCounts) > 0 {
|
|
fmt.Println("\nAlerts by Type:")
|
|
for t, c := range alertCounts {
|
|
fmt.Printf(" %s: %d\n", t, c)
|
|
}
|
|
}
|
|
|
|
// Final positions
|
|
fmt.Println("\nFinal Positions:")
|
|
for asset, pos := range rm.positions {
|
|
if !pos.Quantity.IsZero() {
|
|
fmt.Printf(" %s: %s\n", asset, pos.Quantity.StringFixed(8))
|
|
}
|
|
}
|
|
|
|
fmt.Println("=========================")
|
|
}
|
|
|
|
func main() {
|
|
// Create configuration
|
|
config := trading.NewConfig()
|
|
|
|
// Add venues
|
|
config.WithNative("lx_dex", trading.NewLxDexConfig(
|
|
getEnv("LX_DEX_URL", "https://api.dex.lux.network"),
|
|
).WithCredentials(
|
|
os.Getenv("LX_DEX_KEY"),
|
|
os.Getenv("LX_DEX_SECRET"),
|
|
))
|
|
|
|
if key := os.Getenv("BINANCE_KEY"); key != "" {
|
|
config.WithCcxt("binance", trading.NewCcxtConfig("binance").
|
|
WithCredentials(key, os.Getenv("BINANCE_SECRET")))
|
|
}
|
|
|
|
// Risk config for the client
|
|
config.Risk = trading.RiskConfig{
|
|
Enabled: true,
|
|
MaxPositionSize: decimal.NewFromFloat(100000),
|
|
MaxOrderSize: decimal.NewFromFloat(10000),
|
|
MaxDailyLoss: decimal.NewFromFloat(5000),
|
|
MaxOpenOrders: 100,
|
|
KillSwitchEnabled: true,
|
|
}
|
|
|
|
// 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 risk monitor
|
|
riskConfig := DefaultRiskConfig()
|
|
riskConfig.MaxPositionAsset["BTC"] = decimal.NewFromFloat(1)
|
|
riskConfig.MaxPositionAsset["ETH"] = decimal.NewFromFloat(10)
|
|
|
|
monitor := NewRiskMonitor(client, riskConfig)
|
|
monitor.Start()
|
|
defer monitor.Stop()
|
|
|
|
// Signal handling
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
// Status ticker
|
|
statusTicker := time.NewTicker(10 * time.Second)
|
|
defer statusTicker.Stop()
|
|
|
|
log.Println("Risk monitor started. Press Ctrl+C to exit.")
|
|
|
|
for {
|
|
select {
|
|
case <-sigCh:
|
|
return
|
|
case <-statusTicker.C:
|
|
monitor.PrintStatus()
|
|
}
|
|
}
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return defaultValue
|
|
}
|