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