executor.go raw

   1  package market
   2  
   3  import (
   4  	"fmt"
   5  	"os"
   6  	"sync"
   7  	"time"
   8  )
   9  
  10  // ExecutionMode determines whether the organism trades live or on paper.
  11  type ExecutionMode int
  12  
  13  const (
  14  	PaperTrading ExecutionMode = iota
  15  	LiveTrading
  16  )
  17  
  18  func (m ExecutionMode) String() string {
  19  	if m == PaperTrading {
  20  		return "paper"
  21  	}
  22  	return "live"
  23  }
  24  
  25  // Executor manages trade execution with safety constraints.
  26  // The organism proposes trades; the executor applies limits, circuit breakers,
  27  // and kill file checks before executing.
  28  type Executor struct {
  29  	mu sync.Mutex
  30  
  31  	Mode          ExecutionMode
  32  	Portfolio     *Portfolio
  33  	KillFilePath  string  // if this file exists, halt all trading
  34  	MaxPerTrade   float64 // maximum notional value per trade
  35  	MaxAggregate  float64 // maximum aggregate exposure
  36  	DrawdownLimit float64 // halt if cumulative loss exceeds this fraction of capital
  37  	Capital       float64 // operator-allocated capital
  38  	LogPath       string
  39  
  40  	// Callback for live execution. The organism provides this.
  41  	// For paper trading, fills are simulated internally.
  42  	Execute func(order TradeOrder) (*Fill, error)
  43  
  44  	generation int
  45  	halted     bool
  46  	haltReason string
  47  }
  48  
  49  // NewExecutor creates an executor in paper trading mode with sensible defaults.
  50  func NewExecutor(capital float64) *Executor {
  51  	return &Executor{
  52  		Mode:          PaperTrading,
  53  		Portfolio:     NewPortfolio(),
  54  		KillFilePath:  "_output/STOP",
  55  		MaxPerTrade:   capital * 0.02, // 2% per trade
  56  		MaxAggregate:  capital * 0.20, // 20% total exposure
  57  		DrawdownLimit: 0.05,           // 5% drawdown halts trading
  58  		Capital:       capital,
  59  	}
  60  }
  61  
  62  // SetGeneration advances the generation counter.
  63  func (ex *Executor) SetGeneration(gen int) {
  64  	ex.mu.Lock()
  65  	defer ex.mu.Unlock()
  66  	ex.generation = gen
  67  }
  68  
  69  // IsHalted returns whether trading is halted and why.
  70  func (ex *Executor) IsHalted() (bool, string) {
  71  	ex.mu.Lock()
  72  	defer ex.mu.Unlock()
  73  	return ex.halted, ex.haltReason
  74  }
  75  
  76  // Submit proposes a trade. The executor applies all safety checks before
  77  // executing. Returns the fill if executed, nil if rejected.
  78  func (ex *Executor) Submit(order TradeOrder) (*Fill, error) {
  79  	ex.mu.Lock()
  80  	defer ex.mu.Unlock()
  81  
  82  	// Kill file check.
  83  	if ex.killFileExists() {
  84  		ex.halted = true
  85  		ex.haltReason = "kill file"
  86  		return nil, fmt.Errorf("trading halted: kill file exists")
  87  	}
  88  
  89  	// Check halt state.
  90  	if ex.halted {
  91  		return nil, fmt.Errorf("trading halted: %s", ex.haltReason)
  92  	}
  93  
  94  	// Position size limit.
  95  	notional := order.Price * order.Quantity
  96  	if notional > ex.MaxPerTrade {
  97  		return nil, fmt.Errorf("order notional %.2f exceeds per-trade limit %.2f",
  98  			notional, ex.MaxPerTrade)
  99  	}
 100  
 101  	// Aggregate exposure limit.
 102  	currentExposure := ex.Portfolio.AggregateExposure()
 103  	if currentExposure+notional > ex.MaxAggregate {
 104  		return nil, fmt.Errorf("aggregate exposure %.2f + order %.2f exceeds limit %.2f",
 105  			currentExposure, notional, ex.MaxAggregate)
 106  	}
 107  
 108  	// Execute.
 109  	var fill *Fill
 110  	var err error
 111  
 112  	if ex.Mode == PaperTrading {
 113  		fill = ex.simulateFill(order)
 114  	} else {
 115  		if ex.Execute == nil {
 116  			return nil, fmt.Errorf("live trading requires Execute callback")
 117  		}
 118  		fill, err = ex.Execute(order)
 119  		if err != nil {
 120  			return nil, fmt.Errorf("execution failed: %w", err)
 121  		}
 122  	}
 123  
 124  	// Record the fill.
 125  	ex.Portfolio.RecordFill(*fill)
 126  
 127  	// Drawdown circuit breaker.
 128  	realized := ex.Portfolio.TotalRealized()
 129  	if ex.Capital > 0 && realized < 0 && (-realized/ex.Capital) > ex.DrawdownLimit {
 130  		ex.halted = true
 131  		ex.haltReason = fmt.Sprintf("drawdown %.2f%% exceeds limit %.2f%%",
 132  			(-realized/ex.Capital)*100, ex.DrawdownLimit*100)
 133  	}
 134  
 135  	// Log the trade.
 136  	ex.logTrade(order, fill)
 137  
 138  	return fill, nil
 139  }
 140  
 141  // Resume clears the halt state. Requires operator action.
 142  func (ex *Executor) Resume() {
 143  	ex.mu.Lock()
 144  	defer ex.mu.Unlock()
 145  	ex.halted = false
 146  	ex.haltReason = ""
 147  }
 148  
 149  // EconomicFitness computes the fitness for the current generation.
 150  func (ex *Executor) EconomicFitness() *EconomicFitness {
 151  	ex.mu.Lock()
 152  	defer ex.mu.Unlock()
 153  
 154  	ef := &EconomicFitness{
 155  		Generation: ex.generation,
 156  		Realized:   ex.Portfolio.TotalRealized(),
 157  		Costs:      ex.Portfolio.TotalCosts(),
 158  		TradeCount: ex.Portfolio.FillCount(),
 159  		Time:       time.Now(),
 160  	}
 161  
 162  	// Count wins and losses via Portfolio method (thread-safe).
 163  	ef.WinCount, ef.LossCount = ex.Portfolio.WinLossCounts()
 164  
 165  	return ef
 166  }
 167  
 168  func (ex *Executor) killFileExists() bool {
 169  	if ex.KillFilePath == "" {
 170  		return false
 171  	}
 172  	_, err := os.Stat(ex.KillFilePath)
 173  	return err == nil
 174  }
 175  
 176  func (ex *Executor) simulateFill(order TradeOrder) *Fill {
 177  	return &Fill{
 178  		OrderID:  order.ID,
 179  		Asset:    order.Asset,
 180  		Side:     order.Side,
 181  		Venue:    order.Venue,
 182  		Price:    order.Price,
 183  		Quantity: order.Quantity,
 184  		Fee:      order.Price * order.Quantity * 0.001, // 0.1% simulated fee
 185  		FilledAt: time.Now(),
 186  	}
 187  }
 188  
 189  func (ex *Executor) logTrade(order TradeOrder, fill *Fill) {
 190  	if ex.LogPath == "" {
 191  		return
 192  	}
 193  	f, err := os.OpenFile(ex.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 194  	if err != nil {
 195  		return
 196  	}
 197  	defer f.Close()
 198  	fmt.Fprintf(f, "%s gen=%d mode=%s %s %s %.4f@%.2f venue=%s fee=%.4f\n",
 199  		fill.FilledAt.Format(time.RFC3339),
 200  		ex.generation,
 201  		ex.Mode,
 202  		fill.Side,
 203  		fill.Asset,
 204  		fill.Quantity,
 205  		fill.Price,
 206  		fill.Venue,
 207  		fill.Fee,
 208  	)
 209  }
 210