Skip to main content

engine/strategies/
kelly_rigorous.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use solver::lookup::LookupTable;
4use std::sync::Arc;
5
6/// Parameters for the rigorous Kelly (log-utility) strategy.
7///
8/// Uses precomputed 3D lookup tables indexed by [q, x, tau].
9/// Tables are generated offline via the Kelly HJB solver.
10#[derive(Clone, Debug)]
11pub struct KellyRigorousParameters {
12    /// Trading horizon in model seconds — must match the tau axis of the tables.
13    pub t_horizon: f64,
14}
15
16/// Kelly-optimal market-making strategy driven by the rigorous log-utility HJB.
17///
18/// **Rigor: Rigorous.** Uses the true log-utility HJB solution, not a CARA
19/// approximation. On every tick:
20///   1. Computes tau = t_horizon - elapsed and x = cash / mid.
21///   2. Interpolates 3D spread tables at (q, x, tau).
22///   3. Places bid/ask orders at mid +/- interpolated spread.
23///
24/// # Table format
25/// Both tables must be 3-dimensional with axes [q, x, tau]:
26///   - axis 0: inventory (-Q..Q)
27///   - axis 1: wealth ratio x = X/S
28///   - axis 2: time remaining tau (0..T)
29#[derive(Clone)]
30pub struct KellyRigorousStrategy {
31    pub params: KellyRigorousParameters,
32    pub bid_spread_table: Arc<LookupTable>,
33    pub ask_spread_table: Arc<LookupTable>,
34    q_min: f64,
35    q_max: f64,
36    start_time: Option<f64>,
37    order_counter: u64,
38}
39
40impl KellyRigorousStrategy {
41    pub fn new(
42        params: KellyRigorousParameters,
43        bid_spread_table: Arc<LookupTable>,
44        ask_spread_table: Arc<LookupTable>,
45    ) -> Self {
46        let q_axis = &bid_spread_table.axes[0];
47        let q_min = q_axis[0];
48        let q_max = q_axis[q_axis.len() - 1];
49        Self {
50            params,
51            bid_spread_table,
52            ask_spread_table,
53            q_min,
54            q_max,
55            start_time: None,
56            order_counter: 0,
57        }
58    }
59}
60
61impl Strategy for KellyRigorousStrategy {
62    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
63        if self.start_time.is_none() {
64            self.start_time = Some(obs.timestamp);
65        }
66
67        let elapsed = obs.timestamp - self.start_time.unwrap();
68        let tau = (self.params.t_horizon - elapsed).max(0.0);
69
70        if tau <= 0.0 {
71            requests.push(OrderRequest::CancelAll);
72            return;
73        }
74
75        let mid = obs.mid_price();
76        let q = obs.portfolio.position;
77        let cash = obs.portfolio.cash;
78        let x = if mid > 0.0 { cash / mid } else { 1.0 };
79
80        let delta_bid = self.bid_spread_table.interpolate(&[q, x, tau]).max(1e-9);
81        let delta_ask = self.ask_spread_table.interpolate(&[q, x, tau]).max(1e-9);
82
83        let bid_price = mid - delta_bid;
84        let ask_price = mid + delta_ask;
85
86        let place_bid = q < self.q_max;
87        let place_ask = q > self.q_min;
88
89        requests.push(OrderRequest::CancelAll);
90        if place_bid {
91            self.order_counter += 1;
92            requests.push(OrderRequest::New(Order::new(
93                self.order_counter,
94                Side::Buy,
95                bid_price,
96                1.0,
97            )));
98        }
99        if place_ask {
100            self.order_counter += 1;
101            requests.push(OrderRequest::New(Order::new(
102                self.order_counter,
103                Side::Sell,
104                ask_price,
105                1.0,
106            )));
107        }
108    }
109
110    fn as_any(&self) -> &dyn std::any::Any {
111        self
112    }
113    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
114        self
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::types::PortfolioSnapshot;
122    use ndarray::ArrayD;
123    use ndarray::IxDyn;
124    use std::sync::Arc;
125
126    fn make_obs(mid: f64, position: f64, cash: f64) -> Observation {
127        Observation {
128            timestamp: 0.0,
129            best_bid: mid - 0.05,
130            best_ask: mid + 0.05,
131            last_price: Some(mid),
132            portfolio: PortfolioSnapshot { cash, position },
133            volatility: None,
134            drift: None,
135            parameters: None,
136        }
137    }
138
139    fn make_3d_table(values: &[f64]) -> LookupTable {
140        let axes = vec![
141            vec![-1.0, 0.0, 1.0], // q
142            vec![1.0, 5.0, 10.0], // x
143            vec![0.0, 0.5, 1.0],  // tau
144        ];
145        let data = ArrayD::from_shape_vec(IxDyn(&[3, 3, 3]), values.to_vec()).unwrap();
146        LookupTable::new(axes, data)
147    }
148
149    #[test]
150    fn happy_path_places_bid_and_ask() {
151        let spreads = vec![0.01; 27];
152        let bid_table = Arc::new(make_3d_table(&spreads));
153        let ask_table = Arc::new(make_3d_table(&spreads));
154        let mut strategy = KellyRigorousStrategy::new(
155            KellyRigorousParameters { t_horizon: 1.0 },
156            bid_table,
157            ask_table,
158        );
159        let obs = make_obs(100.0, 0.0, 10000.0);
160        let mut requests = Vec::new();
161        strategy.on_tick(&obs, &mut requests);
162
163        let bid_count = requests
164            .iter()
165            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Buy))
166            .count();
167        let ask_count = requests
168            .iter()
169            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Sell))
170            .count();
171        assert_eq!(bid_count, 1);
172        assert_eq!(ask_count, 1);
173    }
174
175    #[test]
176    fn cancels_all_at_horizon() {
177        let spreads = vec![0.01; 27];
178        let bid_table = Arc::new(make_3d_table(&spreads));
179        let ask_table = Arc::new(make_3d_table(&spreads));
180        let mut strategy = KellyRigorousStrategy::new(
181            KellyRigorousParameters { t_horizon: 0.0 },
182            bid_table,
183            ask_table,
184        );
185        let obs = make_obs(100.0, 0.0, 10000.0);
186        let mut requests = Vec::new();
187        strategy.on_tick(&obs, &mut requests);
188        assert_eq!(requests.len(), 1);
189        assert!(matches!(requests[0], OrderRequest::CancelAll));
190    }
191
192    #[test]
193    fn no_bid_at_q_max() {
194        let spreads = vec![0.01; 27];
195        let bid_table = Arc::new(make_3d_table(&spreads));
196        let ask_table = Arc::new(make_3d_table(&spreads));
197        let mut strategy = KellyRigorousStrategy::new(
198            KellyRigorousParameters { t_horizon: 1.0 },
199            bid_table,
200            ask_table,
201        );
202        let obs = make_obs(100.0, 1.0, 10000.0);
203        let mut requests = Vec::new();
204        strategy.on_tick(&obs, &mut requests);
205        let bid_count = requests
206            .iter()
207            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Buy))
208            .count();
209        assert_eq!(bid_count, 0, "should not bid at q=q_max");
210    }
211}