Skip to main content

engine/strategies/
signal_strategy.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use market_model::strategy::PriceStrategy;
4use market_model::types::Signal;
5use std::collections::VecDeque;
6
7/// How multiple `PriceStrategy` signals are combined into a single decision.
8#[derive(Clone, Debug)]
9pub enum SignalCombinator {
10    /// All signals must agree (AND). Flat signals count as abstain.
11    All,
12    /// Any non-Flat signal is used; if multiple conflict, first non-Flat wins.
13    Any,
14    /// Majority vote across signals. Ties produce Flat.
15    Majority,
16}
17
18/// Position-sizing policy translating a signal into order quantity.
19#[derive(Clone, Debug)]
20pub enum PositionSizing {
21    /// Always trade `quantity` units. Long buys quantity, short sells quantity.
22    Fixed { quantity: f64 },
23    /// Trade a fraction of available cash (for long) or current position value.
24    FractionOfWealth { fraction: f64 },
25}
26
27/// A generic engine strategy driven by one or more `PriceStrategy` signals.
28///
29/// **Rigor: Heuristic.** Translates technical indicators (MA crossover, RSI,
30/// etc.) into order-book orders. No HJB or optimal-control derivation.
31///
32/// Supports combining multiple signals with AND/OR/Majority logic.
33pub struct SignalEngineStrategy {
34    signal_sources: Vec<Box<dyn PriceStrategy>>,
35    combinator: SignalCombinator,
36    sizing: PositionSizing,
37    price_history: VecDeque<f64>,
38    max_history: usize,
39    current_position: f64,
40    order_counter: u64,
41}
42
43impl SignalEngineStrategy {
44    /// Create a strategy driven by a single `PriceStrategy`.
45    pub fn new(
46        signal_source: Box<dyn PriceStrategy>,
47        combinator: SignalCombinator,
48        sizing: PositionSizing,
49    ) -> Self {
50        Self {
51            signal_sources: vec![signal_source],
52            combinator,
53            sizing,
54            price_history: VecDeque::new(),
55            max_history: 200,
56            current_position: 0.0,
57            order_counter: 0,
58        }
59    }
60
61    /// Create a strategy driven by multiple `PriceStrategy` sources.
62    pub fn new_multi(
63        signal_sources: Vec<Box<dyn PriceStrategy>>,
64        combinator: SignalCombinator,
65        sizing: PositionSizing,
66    ) -> Self {
67        Self {
68            signal_sources,
69            combinator,
70            sizing,
71            price_history: VecDeque::new(),
72            max_history: 200,
73            current_position: 0.0,
74            order_counter: 0,
75        }
76    }
77
78    /// Convenience: MA crossover with fixed quantity.
79    pub fn new_ma(fast_window: usize, slow_window: usize, quantity: f64) -> Self {
80        use market_model::strategy::ma_crossover::MaCrossover;
81        Self::new(
82            Box::new(MaCrossover::new(fast_window, slow_window)),
83            SignalCombinator::All,
84            PositionSizing::Fixed { quantity },
85        )
86    }
87
88    /// Convenience: RSI with fixed quantity.
89    pub fn new_rsi(window: usize, oversold: f64, overbought: f64, quantity: f64) -> Self {
90        use market_model::strategy::rsi::RsiStrategy;
91        Self::new(
92            Box::new(RsiStrategy::new(window, oversold, overbought)),
93            SignalCombinator::All,
94            PositionSizing::Fixed { quantity },
95        )
96    }
97
98    fn combined_signal(&self) -> Signal {
99        let signals: Vec<Signal> = self
100            .signal_sources
101            .iter()
102            .map(|s| {
103                let history: Vec<f64> = self.price_history.iter().copied().collect();
104                s.signal(&history)
105            })
106            .collect();
107
108        match self.combinator {
109            SignalCombinator::All => {
110                let non_flat: Vec<&Signal> = signals
111                    .iter()
112                    .filter(|s| !matches!(s, Signal::Flat))
113                    .collect();
114                if non_flat.is_empty() {
115                    return Signal::Flat;
116                }
117                let first = non_flat[0];
118                if non_flat
119                    .iter()
120                    .all(|s| std::mem::discriminant(*s) == std::mem::discriminant(first))
121                {
122                    *first
123                } else {
124                    Signal::Flat
125                }
126            }
127            SignalCombinator::Any => {
128                for s in &signals {
129                    if !matches!(s, Signal::Flat) {
130                        return *s;
131                    }
132                }
133                Signal::Flat
134            }
135            SignalCombinator::Majority => {
136                let long_count = signals.iter().filter(|s| matches!(s, Signal::Long)).count();
137                let short_count = signals
138                    .iter()
139                    .filter(|s| matches!(s, Signal::Short))
140                    .count();
141                if long_count > short_count && long_count > signals.len() / 2 {
142                    Signal::Long
143                } else if short_count > long_count && short_count > signals.len() / 2 {
144                    Signal::Short
145                } else {
146                    Signal::Flat
147                }
148            }
149        }
150    }
151
152    fn target_position(&self, signal: Signal) -> f64 {
153        let quantity = match &self.sizing {
154            PositionSizing::Fixed { quantity } => *quantity,
155            PositionSizing::FractionOfWealth { fraction } => {
156                // Approximate: use last price as wealth proxy. In practice,
157                // this is called mid-tick and the exact wealth is unavailable.
158                // A more precise implementation would track realized PnL.
159                let last_price = self.price_history.back().copied().unwrap_or(100.0);
160                let notional = last_price * 0.1; // heuristic: 10% of price as position scale
161                (notional * fraction).max(0.0)
162            }
163        };
164
165        match signal {
166            Signal::Long => quantity,
167            Signal::Short => -quantity,
168            Signal::Flat => 0.0,
169        }
170    }
171}
172
173impl Strategy for SignalEngineStrategy {
174    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
175        // Update price history from the observation
176        let mid = obs.mid_price();
177        self.price_history.push_back(mid);
178        if self.price_history.len() > self.max_history {
179            self.price_history.pop_front();
180        }
181
182        let signal = self.combined_signal();
183        let target = self.target_position(signal);
184        let delta = target - self.current_position;
185
186        if delta == 0.0 {
187            return;
188        }
189
190        let side = if delta > 0.0 { Side::Buy } else { Side::Sell };
191        let abs_qty = delta.abs();
192
193        // Cancel existing orders and place one aggressive limit order
194        requests.push(OrderRequest::CancelAll);
195
196        let price = if side == Side::Buy {
197            obs.best_ask
198        } else {
199            obs.best_bid
200        };
201
202        self.order_counter += 1;
203        requests.push(OrderRequest::New(Order::new(
204            self.order_counter,
205            side,
206            price,
207            abs_qty,
208        )));
209
210        self.current_position = target;
211    }
212
213    fn as_any(&self) -> &dyn std::any::Any {
214        self
215    }
216
217    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
218        self
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::types::PortfolioSnapshot;
226
227    fn make_obs(mid: f64) -> Observation {
228        Observation {
229            timestamp: 0.0,
230            best_bid: mid - 0.01,
231            best_ask: mid + 0.01,
232            last_price: Some(mid),
233            portfolio: PortfolioSnapshot {
234                cash: 10000.0,
235                position: 0.0,
236            },
237            volatility: None,
238            drift: None,
239            parameters: None,
240        }
241    }
242
243    fn feed_prices(strategy: &mut SignalEngineStrategy, prices: &[f64]) {
244        for p in prices {
245            strategy.price_history.push_back(*p);
246            if strategy.price_history.len() > strategy.max_history {
247                strategy.price_history.pop_front();
248            }
249        }
250    }
251
252    fn tick(strategy: &mut SignalEngineStrategy, mid: f64) -> Vec<OrderRequest> {
253        let obs = make_obs(mid);
254        let mut requests = Vec::new();
255        strategy.on_tick(&obs, &mut requests);
256        requests
257    }
258
259    #[test]
260    fn ma_crossover_goes_long_on_uptrend() {
261        let mut strategy = SignalEngineStrategy::new_ma(2, 4, 1.0);
262        // Prime history: flat then rising
263        feed_prices(&mut strategy, &[100.0, 100.0, 100.0, 100.0, 102.0, 104.0]);
264        // fast MA = (102+104)/2 = 103, slow MA = (100+100+102+104)/4 = 101.5
265        // fast > slow -> Long
266        let requests = tick(&mut strategy, 106.0);
267        let buys: Vec<_> = requests
268            .iter()
269            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Buy))
270            .collect();
271        assert!(
272            !buys.is_empty(),
273            "should place buy order on MA crossover long"
274        );
275    }
276
277    #[test]
278    fn ma_crossover_goes_short_on_downtrend() {
279        let mut strategy = SignalEngineStrategy::new_ma(2, 4, 1.0);
280        feed_prices(&mut strategy, &[110.0, 110.0, 110.0, 108.0, 106.0, 104.0]);
281        // fast = (106+104)/2=105, slow = (110+108+106+104)/4=107, fast < slow -> Short
282        let requests = tick(&mut strategy, 102.0);
283        let sells: Vec<_> = requests
284            .iter()
285            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Sell))
286            .collect();
287        assert!(
288            !sells.is_empty(),
289            "should place sell order on MA crossover short"
290        );
291    }
292
293    #[test]
294    fn rsi_goes_long_on_oversold() {
295        let mut strategy = SignalEngineStrategy::new_rsi(5, 30.0, 70.0, 1.0);
296        // All price decreases: RSI near 0 (< 30 oversold)
297        feed_prices(&mut strategy, &[100.0, 99.0, 98.0, 97.0, 96.0, 95.0]);
298        let requests = tick(&mut strategy, 94.0);
299        let buys: Vec<_> = requests
300            .iter()
301            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Buy))
302            .collect();
303        assert!(!buys.is_empty(), "should buy on RSI oversold");
304    }
305
306    #[test]
307    fn rsi_goes_short_on_overbought() {
308        let mut strategy = SignalEngineStrategy::new_rsi(5, 30.0, 70.0, 1.0);
309        // All price increases: RSI near 100 (> 70 overbought)
310        feed_prices(&mut strategy, &[100.0, 101.0, 102.0, 103.0, 104.0, 105.0]);
311        let requests = tick(&mut strategy, 106.0);
312        let sells: Vec<_> = requests
313            .iter()
314            .filter(|r| matches!(r, OrderRequest::New(o) if o.side == Side::Sell))
315            .collect();
316        assert!(!sells.is_empty(), "should sell on RSI overbought");
317    }
318
319    #[test]
320    fn no_reorder_when_signal_unchanged() {
321        let mut strategy = SignalEngineStrategy::new_ma(2, 4, 1.0);
322        // Go long
323        feed_prices(&mut strategy, &[100.0, 100.0, 100.0, 100.0, 102.0, 104.0]);
324        let _ = tick(&mut strategy, 106.0); // enter long
325        assert_eq!(strategy.current_position, 1.0);
326
327        // Same signal on next tick — should not place new orders
328        let requests = tick(&mut strategy, 108.0);
329        let new_orders: Vec<_> = requests
330            .iter()
331            .filter(|r| matches!(r, OrderRequest::New(_)))
332            .collect();
333        assert!(
334            new_orders.is_empty(),
335            "should not place new orders when signal unchanged"
336        );
337    }
338
339    #[test]
340    fn multiple_signals_all_requires_consensus() {
341        use market_model::strategy::ma_crossover::MaCrossover;
342        use market_model::strategy::rsi::RsiStrategy;
343
344        let ma = Box::new(MaCrossover::new(2, 4));
345        let rsi = Box::new(RsiStrategy::new(5, 30.0, 70.0));
346        let mut strategy = SignalEngineStrategy::new_multi(
347            vec![ma, rsi],
348            SignalCombinator::All,
349            PositionSizing::Fixed { quantity: 1.0 },
350        );
351        // Rising prices: MA -> Long, RSI -> overbought (Short). All -> Flat.
352        feed_prices(&mut strategy, &[100.0, 101.0, 102.0, 103.0, 104.0, 105.0]);
353        let requests = tick(&mut strategy, 106.0);
354        let new_orders: Vec<_> = requests
355            .iter()
356            .filter(|r| matches!(r, OrderRequest::New(_)))
357            .collect();
358        assert!(
359            new_orders.is_empty(),
360            "All combinator: MA long + RSI short should produce Flat"
361        );
362    }
363}