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#[derive(Clone, Debug)]
9pub enum SignalCombinator {
10 All,
12 Any,
14 Majority,
16}
17
18#[derive(Clone, Debug)]
20pub enum PositionSizing {
21 Fixed { quantity: f64 },
23 FractionOfWealth { fraction: f64 },
25}
26
27pub 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 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 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 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 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 let last_price = self.price_history.back().copied().unwrap_or(100.0);
160 let notional = last_price * 0.1; (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 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 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 feed_prices(&mut strategy, &[100.0, 100.0, 100.0, 100.0, 102.0, 104.0]);
264 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 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 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 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 feed_prices(&mut strategy, &[100.0, 100.0, 100.0, 100.0, 102.0, 104.0]);
324 let _ = tick(&mut strategy, 106.0); assert_eq!(strategy.current_position, 1.0);
326
327 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 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}