Skip to main content

engine/strategies/
avellaneda_stoikov.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use std::collections::VecDeque;
4
5#[derive(Clone, Debug)]
6pub struct AvellanedaStoikovParameters {
7    pub gamma: f64,                  // Risk aversion
8    pub sigma: f64,                  // Volatility (Initial or Fixed)
9    pub t_horizon: f64,              // Time horizon (T) in years
10    pub k: f64,                      // Order book liquidity parameter
11    pub a: f64,                      // Order book liquidity parameter
12    pub dt: f64,                     // Time step for vol calculation
13    pub allow_real_vol: bool,        // If true, use volatility from observation if available
14    pub enable_vol_estimation: bool, // If true, estimate volatility from price history (fallback)
15    pub impact_parameter: f64,       // Linear permanent market impact parameter (xi)
16}
17
18pub struct AvellanedaStoikovStrategy {
19    // Model Parameters
20    pub params: AvellanedaStoikovParameters,
21
22    // State
23    pub sigma: f64, // Current Volatility
24    pub start_time: Option<f64>,
25    pub order_counter: u64,
26
27    // Volatility Estimation
28    pub price_history: VecDeque<f64>,
29    pub window_size: usize,
30}
31
32impl AvellanedaStoikovStrategy {
33    pub fn new(params: AvellanedaStoikovParameters) -> Self {
34        let sigma = params.sigma;
35        Self {
36            params,
37            sigma,
38            start_time: None,
39            order_counter: 0,
40            price_history: VecDeque::new(),
41            window_size: 100, // Default window size
42        }
43    }
44
45    pub fn with_window_size(mut self, window_size: usize) -> Self {
46        self.window_size = window_size;
47        self
48    }
49
50    fn update_volatility(&mut self, price: f64) {
51        self.price_history.push_back(price);
52        if self.price_history.len() > self.window_size {
53            self.price_history.pop_front();
54        }
55
56        if self.price_history.len() >= 3 {
57            // Calculate realized volatility
58            let mut log_returns = Vec::with_capacity(self.price_history.len() - 1);
59            for i in 0..self.price_history.len() - 1 {
60                let r = (self.price_history[i + 1] / self.price_history[i]).ln();
61                log_returns.push(r);
62            }
63
64            let mean = log_returns.iter().sum::<f64>() / log_returns.len() as f64;
65            let variance = log_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
66                / (log_returns.len() - 1) as f64;
67
68            let sigma_step = variance.sqrt();
69
70            // Smooth updates (EWMA-like) to avoid jumps
71            let alpha = 0.1;
72            self.sigma = (1.0 - alpha) * self.sigma + alpha * sigma_step;
73        }
74    }
75}
76
77impl Strategy for AvellanedaStoikovStrategy {
78    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
79        // Initialize start time if first tick
80        if self.start_time.is_none() {
81            self.start_time = Some(obs.timestamp);
82        }
83
84        let mid_price = obs.mid_price();
85
86        // Volatility: use observed volatility if available and allowed, else estimate
87        let mut used_real_vol = false;
88        if self.params.allow_real_vol
89            && let Some(vol) = obs.volatility
90        {
91            // Observed vol (may be ground truth if filter is transparent)
92            self.sigma = vol * self.params.dt.sqrt();
93            used_real_vol = true;
94        }
95
96        if !used_real_vol && self.params.enable_vol_estimation {
97            self.update_volatility(mid_price);
98        }
99
100        // Position from portfolio (authoritative source via Exchange)
101        let position = obs.portfolio.position;
102
103        // Calculate Time Remaining
104        let elapsed_seconds = obs.timestamp - self.start_time.unwrap();
105
106        let time_remaining = self.params.t_horizon - elapsed_seconds;
107
108        // If time is up, don't quote (or liquidate)
109        if time_remaining <= 0.0 {
110            requests.push(OrderRequest::CancelAll);
111            return;
112        }
113
114        // Reservation Price: r(s, q, t) = s - q * gamma * sigma^2 * (T - t)
115        let mut reservation_price =
116            mid_price - position * self.params.gamma * self.sigma.powi(2) * time_remaining;
117
118        // Market Impact Adjustment (Extended Model)
119        if self.params.impact_parameter.abs() > f64::EPSILON {
120            reservation_price -= position * self.params.impact_parameter;
121        }
122
123        // Half Spread: (gamma * sigma^2 * (T-t) + (2/gamma) * ln(1 + gamma/k)) / 2
124        let half_spread = (self.params.gamma * self.sigma.powi(2) * time_remaining
125            + (2.0 / self.params.gamma) * (1.0 + self.params.gamma / self.params.k).ln())
126            / 2.0;
127
128        let bid_price = ((reservation_price - half_spread) * 100.0).round() / 100.0;
129        let ask_price = ((reservation_price + half_spread) * 100.0).round() / 100.0;
130
131        let quantity = 1.0;
132
133        self.order_counter += 1;
134        let bid_id = self.order_counter;
135        self.order_counter += 1;
136        let ask_id = self.order_counter;
137
138        requests.push(OrderRequest::CancelAll);
139        requests.push(OrderRequest::New(Order::new(
140            bid_id,
141            Side::Buy,
142            bid_price,
143            quantity,
144        )));
145        requests.push(OrderRequest::New(Order::new(
146            ask_id,
147            Side::Sell,
148            ask_price,
149            quantity,
150        )));
151    }
152
153    fn as_any(&self) -> &dyn std::any::Any {
154        self
155    }
156    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
157        self
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::types::PortfolioSnapshot;
165
166    fn make_obs(bid: f64, ask: f64, position: f64, vol: Option<f64>) -> Observation {
167        Observation {
168            timestamp: 0.0,
169            best_bid: bid,
170            best_ask: ask,
171            last_price: Some((bid + ask) / 2.0),
172            portfolio: PortfolioSnapshot {
173                cash: 10000.0,
174                position,
175            },
176            volatility: vol,
177            drift: None,
178            parameters: None,
179        }
180    }
181
182    #[test]
183    fn test_places_symmetric_quotes_at_zero_inventory() {
184        let params = AvellanedaStoikovParameters {
185            gamma: 0.1,
186            sigma: 0.001,
187            t_horizon: 1000.0,
188            k: 1.5,
189            a: 140.0,
190            dt: 1.0,
191            allow_real_vol: false,
192            enable_vol_estimation: false,
193            impact_parameter: 0.0,
194        };
195        let mut strategy = AvellanedaStoikovStrategy::new(params);
196        let obs = make_obs(99.95, 100.05, 0.0, None);
197
198        let mut requests = Vec::new();
199        strategy.on_tick(&obs, &mut requests);
200
201        // CancelAll + 2 new orders
202        assert_eq!(requests.len(), 3);
203        // Extract bid and ask prices
204        let bid_price = match &requests[1] {
205            OrderRequest::New(o) => {
206                assert_eq!(o.side, Side::Buy);
207                o.limit_price
208            }
209            _ => panic!("Expected New order"),
210        };
211        let ask_price = match &requests[2] {
212            OrderRequest::New(o) => {
213                assert_eq!(o.side, Side::Sell);
214                o.limit_price
215            }
216            _ => panic!("Expected New order"),
217        };
218        let mid = 100.0;
219        // At zero inventory, reservation price = mid, so quotes should be symmetric
220        assert!((mid - bid_price - (ask_price - mid)).abs() < 0.02);
221    }
222
223    #[test]
224    fn test_skews_quotes_with_positive_inventory() {
225        let params = AvellanedaStoikovParameters {
226            gamma: 0.1,
227            sigma: 0.001,
228            t_horizon: 1000.0,
229            k: 1.5,
230            a: 140.0,
231            dt: 1.0,
232            allow_real_vol: false,
233            enable_vol_estimation: false,
234            impact_parameter: 0.0,
235        };
236        let mut strategy = AvellanedaStoikovStrategy::new(params);
237        // Positive inventory -> reservation price below mid -> both quotes shift down
238        let obs = make_obs(99.95, 100.05, 5.0, None);
239
240        let mut requests = Vec::new();
241        strategy.on_tick(&obs, &mut requests);
242
243        let bid_price = match &requests[1] {
244            OrderRequest::New(o) => o.limit_price,
245            _ => panic!("Expected New"),
246        };
247        let ask_price = match &requests[2] {
248            OrderRequest::New(o) => o.limit_price,
249            _ => panic!("Expected New"),
250        };
251        let mid = 100.0;
252        let reservation = (bid_price + ask_price) / 2.0;
253        // With positive inventory, reservation should be below mid
254        assert!(reservation < mid);
255    }
256
257    #[test]
258    fn test_uses_observed_volatility_when_allowed() {
259        let params = AvellanedaStoikovParameters {
260            gamma: 0.1,
261            sigma: 0.0001, // low initial
262            t_horizon: 1000.0,
263            k: 1.5,
264            a: 140.0,
265            dt: 1.0,
266            allow_real_vol: true,
267            enable_vol_estimation: false,
268            impact_parameter: 0.0,
269        };
270        let mut strategy = AvellanedaStoikovStrategy::new(params);
271        let obs = make_obs(99.95, 100.05, 0.0, Some(0.5)); // high vol in observation
272        let mut requests = Vec::new();
273        strategy.on_tick(&obs, &mut requests);
274
275        // sigma should have been updated from observation
276        assert!((strategy.sigma - 0.5).abs() < 1e-6); // 0.5 * sqrt(1.0) = 0.5
277    }
278
279    #[test]
280    fn test_cancels_all_when_time_is_up() {
281        let params = AvellanedaStoikovParameters {
282            gamma: 0.1,
283            sigma: 0.001,
284            t_horizon: 0.0, // already expired
285            k: 1.5,
286            a: 140.0,
287            dt: 1.0,
288            allow_real_vol: false,
289            enable_vol_estimation: false,
290            impact_parameter: 0.0,
291        };
292        let mut strategy = AvellanedaStoikovStrategy::new(params);
293        let obs = make_obs(99.95, 100.05, 0.0, None);
294        let mut requests = Vec::new();
295        strategy.on_tick(&obs, &mut requests);
296
297        assert_eq!(requests.len(), 1);
298        assert!(matches!(requests[0], OrderRequest::CancelAll));
299    }
300}