Skip to main content

rithmic_rs/api/commands/
triggers.rs

1//! The trigger conditions an order embeds: trailing stops and if-touched
2//! triggers.
3
4use crate::{
5    error::RithmicError,
6    types::{OrderCondition, OrderPriceField},
7};
8
9/// Configuration for trailing stop orders.
10///
11/// Used both by [`RithmicOrder::trailing_stop`](crate::RithmicOrder::trailing_stop)
12/// for a standalone order and by
13/// [`RithmicOcoOrderLeg::trailing_stop`](crate::RithmicOcoOrderLeg::trailing_stop)
14/// for a single leg of an OCO group.
15///
16/// # Example
17///
18/// ```
19/// use rithmic_rs::TrailingStop;
20/// # fn main() -> Result<(), rithmic_rs::RithmicError> {
21/// let trailing = TrailingStop::new()
22///     .trail_by_ticks(20)
23///     .trail_by_price_id(1)
24///     .build()?;
25/// # Ok(())
26/// # }
27/// ```
28#[derive(Debug, Clone, PartialEq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[non_exhaustive]
31#[must_use = "a trailing stop does nothing until attached to an order"]
32pub struct TrailingStop {
33    /// Number of ticks to trail behind the market price
34    pub trail_by_ticks: i32,
35    /// Rithmic price-id to trail against. `build()` requires a non-zero id.
36    pub trail_by_price_id: i32,
37}
38
39impl TrailingStop {
40    /// Start an empty trailing stop.
41    #[allow(clippy::new_without_default)]
42    pub fn new() -> Self {
43        Self {
44            trail_by_ticks: 0,
45            trail_by_price_id: 0,
46        }
47    }
48
49    /// Number of ticks to trail behind the market price.
50    pub fn trail_by_ticks(mut self, trail_by_ticks: i32) -> Self {
51        self.trail_by_ticks = trail_by_ticks;
52        self
53    }
54
55    /// Rithmic price-id to trail against.
56    pub fn trail_by_price_id(mut self, trail_by_price_id: i32) -> Self {
57        self.trail_by_price_id = trail_by_price_id;
58        self
59    }
60
61    /// Requires both fields.
62    pub fn build(self) -> Result<Self, RithmicError> {
63        if self.trail_by_ticks < 1 {
64            return Err(RithmicError::InvalidArgument(
65                "trail_by_ticks must be at least 1".to_string(),
66            ));
67        }
68        if self.trail_by_price_id < 1 {
69            return Err(RithmicError::InvalidArgument(
70                "trail_by_price_id must be at least 1".to_string(),
71            ));
72        }
73        Ok(self)
74    }
75}
76
77/// Conditional trigger that releases an order once a price is touched.
78///
79/// Maps to the `if_touched_*` fields on `RequestNewOrder`,
80/// `RequestBracketOrder` and `RequestModifyOrder`, which are field-identical.
81///
82/// # Example
83///
84/// ```
85/// use rithmic_rs::{OrderCondition, OrderPriceField, RithmicIfTouchedTrigger};
86/// # fn main() -> Result<(), rithmic_rs::RithmicError> {
87/// let trigger = RithmicIfTouchedTrigger::new()
88///     .symbol("NQM6")
89///     .exchange("CME")
90///     .condition(OrderCondition::GreaterThanEqualTo)
91///     .price_field(OrderPriceField::TradePrice)
92///     .price(18250.5)
93///     .build()?;
94/// # Ok(())
95/// # }
96/// ```
97#[derive(Debug, Clone, PartialEq)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99#[non_exhaustive]
100#[must_use = "a trigger does nothing until attached to an order"]
101pub struct RithmicIfTouchedTrigger {
102    /// Trading symbol to monitor for the condition.
103    pub symbol: String,
104    /// Exchange for the monitored symbol.
105    pub exchange: String,
106    /// Comparison operator for the trigger.
107    pub condition: OrderCondition,
108    /// Price field to evaluate.
109    pub price_field: OrderPriceField,
110    /// Threshold price for the condition. Left off the wire when unset.
111    pub price: Option<f64>,
112}
113
114impl RithmicIfTouchedTrigger {
115    /// Start an empty trigger.
116    #[allow(clippy::new_without_default)]
117    pub fn new() -> Self {
118        Self {
119            symbol: String::new(),
120            exchange: String::new(),
121            condition: OrderCondition::GreaterThanEqualTo,
122            price_field: OrderPriceField::TradePrice,
123            price: None,
124        }
125    }
126
127    /// Trading symbol to monitor for the condition.
128    pub fn symbol(mut self, symbol: impl Into<String>) -> Self {
129        self.symbol = symbol.into();
130        self
131    }
132
133    /// Exchange for the monitored symbol.
134    pub fn exchange(mut self, exchange: impl Into<String>) -> Self {
135        self.exchange = exchange.into();
136        self
137    }
138
139    /// Comparison operator for the trigger.
140    pub fn condition(mut self, condition: OrderCondition) -> Self {
141        self.condition = condition;
142        self
143    }
144
145    /// Price field to evaluate.
146    pub fn price_field(mut self, price_field: OrderPriceField) -> Self {
147        self.price_field = price_field;
148        self
149    }
150
151    /// Threshold price for the condition.
152    pub fn price(mut self, price: f64) -> Self {
153        self.price = Some(price);
154        self
155    }
156
157    /// Requires a symbol, an exchange and a price.
158    pub fn build(self) -> Result<Self, RithmicError> {
159        if self.symbol.is_empty() {
160            return Err(RithmicError::InvalidArgument(
161                "an if-touched trigger requires a symbol".to_string(),
162            ));
163        }
164
165        if self.exchange.is_empty() {
166            return Err(RithmicError::InvalidArgument(
167                "an if-touched trigger requires an exchange".to_string(),
168            ));
169        }
170
171        if self.price.is_none() {
172            return Err(RithmicError::InvalidArgument(
173                "an if-touched trigger requires a price; unset would otherwise \
174                 release the order immediately under the default condition"
175                    .to_string(),
176            ));
177        }
178        Ok(self)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn a_trailing_stop_requires_both_fields() {
188        assert!(TrailingStop::new().build().is_err());
189        assert!(TrailingStop::new().trail_by_ticks(20).build().is_err());
190        assert!(TrailingStop::new().trail_by_price_id(1).build().is_err());
191        assert!(
192            TrailingStop::new()
193                .trail_by_ticks(20)
194                .trail_by_price_id(1)
195                .build()
196                .is_ok()
197        );
198    }
199
200    #[test]
201    fn an_if_touched_trigger_requires_symbol_exchange_and_price() {
202        let full = RithmicIfTouchedTrigger::new()
203            .symbol("NQM6")
204            .exchange("CME")
205            .price(18250.5);
206        assert!(full.clone().build().is_ok());
207
208        assert!(full.clone().symbol("").build().is_err());
209        assert!(full.exchange("").build().is_err());
210
211        let err = RithmicIfTouchedTrigger::new()
212            .symbol("NQM6")
213            .exchange("CME")
214            .build()
215            .unwrap_err()
216            .to_string();
217        assert!(err.contains("requires a price"), "{err}");
218    }
219}