Skip to main content

engine/strategies/
kelly.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use std::collections::VecDeque;
4
5/// Parameters for the Kelly criterion optimal strategy.
6///
7/// The strategy targets a growth-optimal (Kelly) inventory position
8/// by biasing quotes toward accumulating q* = mu / (gamma * sigma^2).
9#[derive(Clone, Debug)]
10pub struct KellyParameters {
11    /// Risk aversion parameter. Maps CARA to Kelly: gamma = 1 / q*_scale.
12    pub gamma: f64,
13    /// Order book liquidity decay parameter.
14    pub k: f64,
15    /// Base fill rate.
16    pub a: f64,
17    /// Trading horizon in seconds.
18    pub t_horizon: f64,
19    /// Base half-spread to use when q = q* and time_remaining = 0.
20    /// Overrides the AS formula and allows tuning to the data source spread.
21    pub base_half_spread: f64,
22    /// Maximum absolute target inventory (clamp). Prevents extreme
23    /// positions when drift estimates are noisy.
24    pub max_position: f64,
25    /// Minimum estimate standard deviation to avoid division by zero.
26    pub min_sigma: f64,
27}
28
29/// Kelly criterion market-making strategy (CARA-based approximation).
30///
31/// **Approximation, not a rigorous solution.** This strategy uses the CARA
32/// (exponential) utility ansatz from the Avellaneda-Stoikov model and shifts
33/// the reservation price toward the Kelly target inventory q* = mu/(gamma*sigma^2).
34/// It does not solve the true log-utility HJB. The Kelly target matches the
35/// CARA-optimal position to first order, but the spread dynamics and
36/// value-function curvature are CARA, not log-utility.
37///
38/// A rigorous Kelly HJB solver (log-utility without the CARA separation) is
39/// planned for a future phase. See `reference/optimal_criteria.md`.
40pub struct KellyStrategy {
41    pub params: KellyParameters,
42    price_history: VecDeque<f64>,
43    window_size: usize,
44    mu_est: f64,
45    sigma_est: f64,
46    start_time: Option<f64>,
47    order_counter: u64,
48}
49
50impl KellyStrategy {
51    pub fn new(params: KellyParameters) -> Self {
52        Self {
53            params,
54            price_history: VecDeque::new(),
55            window_size: 100,
56            mu_est: 0.0,
57            sigma_est: 0.0,
58            start_time: None,
59            order_counter: 0,
60        }
61    }
62
63    pub fn with_window_size(mut self, window_size: usize) -> Self {
64        self.window_size = window_size;
65        self
66    }
67
68    fn update_estimates(&mut self, price: f64) {
69        self.price_history.push_back(price);
70        if self.price_history.len() > self.window_size {
71            self.price_history.pop_front();
72        }
73
74        if self.price_history.len() < 3 {
75            return;
76        }
77
78        let mut log_returns = Vec::with_capacity(self.price_history.len() - 1);
79        for i in 0..self.price_history.len() - 1 {
80            let r = (self.price_history[i + 1] / self.price_history[i]).ln();
81            log_returns.push(r);
82        }
83
84        let mean = log_returns.iter().sum::<f64>() / log_returns.len() as f64;
85        let variance = log_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
86            / (log_returns.len() - 1) as f64;
87        let sigma_step = variance.sqrt();
88
89        let alpha = 0.1;
90        self.mu_est = (1.0 - alpha) * self.mu_est + alpha * mean;
91        self.sigma_est = (1.0 - alpha) * self.sigma_est + alpha * sigma_step;
92    }
93
94    fn target_inventory(&self) -> f64 {
95        let sigma = self.sigma_est.max(self.params.min_sigma);
96        let target = self.mu_est / (self.params.gamma * sigma * sigma);
97        target.clamp(-self.params.max_position, self.params.max_position)
98    }
99}
100
101impl Strategy for KellyStrategy {
102    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
103        if self.start_time.is_none() {
104            self.start_time = Some(obs.timestamp);
105        }
106
107        let elapsed = obs.timestamp - self.start_time.unwrap();
108        let time_remaining = (self.params.t_horizon - elapsed).max(0.0);
109
110        if time_remaining <= 0.0 {
111            requests.push(OrderRequest::CancelAll);
112            return;
113        }
114
115        let mid = obs.mid_price();
116        self.update_estimates(mid);
117
118        let q = obs.portfolio.position;
119        let q_target = self.target_inventory();
120        let sigma = self.sigma_est.max(self.params.min_sigma);
121
122        // Reservation price targeting q* instead of 0:
123        //   r = mid - gamma * sigma^2 * (T - t) * (q - q*)
124        let reservation_price =
125            mid - self.params.gamma * sigma.powi(2) * time_remaining * (q - q_target);
126
127        // Half-spread: inventory-risk term + configurable base spread.
128        // When q == q* (target), the reservation-price adjustment vanishes
129        // and the spread reverts to base_half_spread.
130        let spread_adjustment = self.params.gamma * sigma.powi(2) * time_remaining;
131        let half_spread = (spread_adjustment + 2.0 * self.params.base_half_spread) / 2.0;
132
133        let bid_price = reservation_price - half_spread;
134        let ask_price = reservation_price + half_spread;
135
136        self.order_counter += 1;
137        let bid_id = self.order_counter;
138        self.order_counter += 1;
139        let ask_id = self.order_counter;
140
141        requests.push(OrderRequest::CancelAll);
142        requests.push(OrderRequest::New(Order::new(
143            bid_id,
144            Side::Buy,
145            bid_price,
146            1.0,
147        )));
148        requests.push(OrderRequest::New(Order::new(
149            ask_id,
150            Side::Sell,
151            ask_price,
152            1.0,
153        )));
154    }
155
156    fn as_any(&self) -> &dyn std::any::Any {
157        self
158    }
159    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
160        self
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::types::PortfolioSnapshot;
168
169    fn make_obs(timestamp: f64, mid: f64, position: f64) -> Observation {
170        Observation {
171            timestamp,
172            best_bid: mid - 0.05,
173            best_ask: mid + 0.05,
174            last_price: Some(mid),
175            portfolio: PortfolioSnapshot {
176                cash: 10000.0,
177                position,
178            },
179            volatility: None,
180            drift: None,
181            parameters: None,
182        }
183    }
184
185    fn default_params() -> KellyParameters {
186        KellyParameters {
187            gamma: 0.1,
188            k: 1.5,
189            a: 140.0,
190            t_horizon: 1000.0,
191            base_half_spread: 0.05,
192            max_position: 10.0,
193            min_sigma: 1e-6,
194        }
195    }
196
197    #[test]
198    fn test_happy_path_places_orders() {
199        let mut strategy = KellyStrategy::new(default_params());
200        let obs = make_obs(0.0, 100.0, 0.0);
201        let mut requests = Vec::new();
202        strategy.on_tick(&obs, &mut requests);
203
204        assert_eq!(requests.len(), 3);
205        assert!(matches!(requests[0], OrderRequest::CancelAll));
206        // bid below ask
207        let bid = match &requests[1] {
208            OrderRequest::New(o) => {
209                assert_eq!(o.side, Side::Buy);
210                o.limit_price
211            }
212            _ => panic!("Expected New bid"),
213        };
214        let ask = match &requests[2] {
215            OrderRequest::New(o) => {
216                assert_eq!(o.side, Side::Sell);
217                o.limit_price
218            }
219            _ => panic!("Expected New ask"),
220        };
221        assert!(bid < ask, "bid={bid} must be below ask={ask}");
222    }
223
224    #[test]
225    fn test_zero_drift_gives_symmetric_quotes() {
226        // Feed flat prices: no drift, no volatility
227        let mut strategy = KellyStrategy::new(default_params());
228        for i in 0..20 {
229            let obs = make_obs(i as f64, 100.0, 0.0);
230            let mut requests = Vec::new();
231            strategy.on_tick(&obs, &mut requests);
232        }
233
234        // After estimation settles, mu ~ 0, sigma ~ 0 -> target = 0
235        let target = strategy.target_inventory();
236        assert!(target.abs() < 1e-6);
237    }
238
239    #[test]
240    fn test_positive_drift_targets_long_position() {
241        let mut strategy = KellyStrategy::new(default_params());
242        // Rising prices: 100 * (1.001)^t
243        for i in 0..100 {
244            let price = 100.0 * 1.001_f64.powi(i);
245            let obs = make_obs(i as f64, price, 0.0);
246            let mut requests = Vec::new();
247            strategy.on_tick(&obs, &mut requests);
248        }
249
250        // Positive drift -> target should be positive
251        assert!(strategy.mu_est > 0.0, "Expected positive drift estimate");
252        let target = strategy.target_inventory();
253        assert!(target > 0.0, "Expected positive target, got {target}");
254    }
255
256    #[test]
257    fn test_negative_drift_targets_short_position() {
258        let mut strategy = KellyStrategy::new(default_params());
259        // Falling prices: 100 * 0.999^t
260        for i in 0..100 {
261            let price = 100.0 * 0.999_f64.powi(i);
262            let obs = make_obs(i as f64, price, 0.0);
263            let mut requests = Vec::new();
264            strategy.on_tick(&obs, &mut requests);
265        }
266
267        // Negative drift -> target should be negative
268        assert!(strategy.mu_est < 0.0, "Expected negative drift estimate");
269        let target = strategy.target_inventory();
270        assert!(target < 0.0, "Expected negative target, got {target}");
271    }
272
273    #[test]
274    fn test_target_clamped_to_max_position() {
275        let mut strategy = KellyStrategy::new(default_params());
276        // Very rapid rise to produce a large drift estimate
277        for i in 0..100 {
278            let price = 100.0 * 1.01_f64.powi(i);
279            let obs = make_obs(i as f64, price, 0.0);
280            let mut requests = Vec::new();
281            strategy.on_tick(&obs, &mut requests);
282        }
283
284        let target = strategy.target_inventory();
285        assert!(
286            target.abs() <= strategy.params.max_position + 1e-9,
287            "target={target} exceeds max_position={}",
288            strategy.params.max_position
289        );
290    }
291
292    #[test]
293    fn test_cancels_all_when_horizon_elapsed() {
294        let mut params = default_params();
295        params.t_horizon = 0.0;
296        let mut strategy = KellyStrategy::new(params);
297        let obs = make_obs(0.0, 100.0, 0.0);
298        let mut requests = Vec::new();
299        strategy.on_tick(&obs, &mut requests);
300
301        assert_eq!(requests.len(), 1);
302        assert!(matches!(requests[0], OrderRequest::CancelAll));
303    }
304}