Skip to main content

qs_core/
rules.rs

1//! Composable position management rules.
2//!
3//! Each [`Rule`] variant carries both its configuration **and** its mutable
4//! runtime state (e.g. whether it has already triggered).  Rules are evaluated
5//! on every price tick for open positions and produce [`Effect`]s that the
6//! engine applies.
7
8use serde::{Deserialize, Serialize};
9
10use crate::types::{
11    CloseReason, Effect, FillModel, PositionStatus, PriceQuote, RuleConfig, Side,
12    position_size_tolerance,
13};
14
15// ─── PositionData view (passed to rules for evaluation) ─────────────────────
16
17/// Read-only snapshot of the position fields that rules need for evaluation.
18///
19/// This is a subset of `Position` that avoids borrow-checker conflicts when
20/// rules (stored inside `Position`) need to inspect the position's data.
21pub struct PositionView<'a> {
22    pub id: &'a str,
23    pub symbol: &'a str,
24    pub side: Side,
25    pub status: PositionStatus,
26    pub average_entry: f64,
27    pub remaining_ratio: f64,
28    pub target_hits: u32,
29    pub open_ts: Option<chrono::NaiveDateTime>,
30}
31
32// ─── Rule enum ──────────────────────────────────────────────────────────────
33
34/// A composable management rule that is evaluated on every price update.
35///
36/// Rules are stored per-position and carry their own mutable state so that
37/// trigger-once semantics (e.g. breakeven) work correctly.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub enum Rule {
40    /// Close the full remaining position when price crosses the stoploss level.
41    FixedStoploss { price: f64 },
42
43    /// Stoploss that follows the price at a fixed `distance`.
44    ///
45    /// For a **Buy** position the stop trails below the highest bid seen;
46    /// for a **Sell** position it trails above the lowest ask seen.
47    TrailingStop {
48        distance: f64,
49        #[serde(default)]
50        peak_price: f64,
51        #[serde(default)]
52        initialized: bool,
53    },
54
55    /// Close `close_ratio` of the position when price reaches `price`.
56    /// Fires at most once.
57    TakeProfit {
58        price: f64,
59        close_ratio: f64,
60        #[serde(default)]
61        triggered: bool,
62    },
63
64    /// When price reaches `trigger_price`, move the stoploss to the entry
65    /// price (breakeven).  Fires at most once.
66    BreakevenWhen {
67        trigger_price: f64,
68        #[serde(default)]
69        triggered: bool,
70    },
71
72    /// After `after_n` take-profit hits, move the stoploss to entry.
73    /// Fires at most once.
74    BreakevenAfterTargets {
75        after_n: u32,
76        #[serde(default)]
77        triggered: bool,
78    },
79
80    /// Close the position if it has been open longer than `max_seconds`.
81    TimeExit { max_seconds: u64 },
82}
83
84// ─── Constructors ───────────────────────────────────────────────────────────
85
86impl Rule {
87    pub fn fixed_stoploss(price: f64) -> Self {
88        Rule::FixedStoploss { price }
89    }
90
91    pub fn trailing_stop(distance: f64) -> Self {
92        Rule::TrailingStop {
93            distance,
94            peak_price: 0.0,
95            initialized: false,
96        }
97    }
98
99    pub fn take_profit(price: f64, close_ratio: f64) -> Self {
100        Rule::TakeProfit {
101            price,
102            close_ratio,
103            triggered: false,
104        }
105    }
106
107    pub fn breakeven_when(trigger_price: f64) -> Self {
108        Rule::BreakevenWhen {
109            trigger_price,
110            triggered: false,
111        }
112    }
113
114    pub fn breakeven_after_targets(after_n: u32) -> Self {
115        Rule::BreakevenAfterTargets {
116            after_n,
117            triggered: false,
118        }
119    }
120
121    pub fn time_exit(max_seconds: u64) -> Self {
122        Rule::TimeExit { max_seconds }
123    }
124
125    /// Build a live `Rule` from a declarative [`RuleConfig`].
126    pub fn from_config(config: RuleConfig) -> Self {
127        match config {
128            RuleConfig::FixedStoploss { price } => Self::fixed_stoploss(price),
129            RuleConfig::TrailingStop { distance } => Self::trailing_stop(distance),
130            RuleConfig::TakeProfit { price, close_ratio } => Self::take_profit(price, close_ratio),
131            RuleConfig::BreakevenWhen { trigger_price } => Self::breakeven_when(trigger_price),
132            RuleConfig::BreakevenAfterTargets { after_n } => Self::breakeven_after_targets(after_n),
133            RuleConfig::TimeExit { max_seconds } => Self::time_exit(max_seconds),
134        }
135    }
136}
137
138// ─── Evaluation ─────────────────────────────────────────────────────────────
139
140impl Rule {
141    /// Whether this rule requires tick-by-tick evaluation (not indexable as a static alert).
142    pub fn is_stateful(&self) -> bool {
143        matches!(
144            self,
145            Rule::TrailingStop { .. } | Rule::TimeExit { .. } | Rule::BreakevenAfterTargets { .. }
146        )
147    }
148
149    /// Human-readable name used for logging, record keeping, and `RemoveRule`.
150    pub fn name(&self) -> &'static str {
151        match self {
152            Rule::FixedStoploss { .. } => "FixedStoploss",
153            Rule::TrailingStop { .. } => "TrailingStop",
154            Rule::TakeProfit { .. } => "TakeProfit",
155            Rule::BreakevenWhen { .. } => "BreakevenWhen",
156            Rule::BreakevenAfterTargets { .. } => "BreakevenAfterTargets",
157            Rule::TimeExit { .. } => "TimeExit",
158        }
159    }
160
161    /// Evaluate the rule against the current quote and position state.
162    ///
163    /// Returns zero or more [`Effect`]s.  The engine is responsible for
164    /// applying these effects to the position afterwards.
165    pub fn evaluate(
166        &mut self,
167        data: &PositionView<'_>,
168        quote: &PriceQuote,
169        model: FillModel,
170    ) -> Vec<Effect> {
171        // Only evaluate for open positions.
172        if data.status != PositionStatus::Open {
173            return vec![];
174        }
175
176        match self {
177            // ── Fixed stoploss ──────────────────────────────────────────
178            Rule::FixedStoploss { price } => {
179                let check = quote.eval_price(data.side, model);
180                let hit = match data.side {
181                    Side::Buy => check <= *price,
182                    Side::Sell => check >= *price,
183                };
184                if hit {
185                    vec![Effect::PositionClosed {
186                        id: data.id.to_owned(),
187                        reason: CloseReason::Stoploss,
188                    }]
189                } else {
190                    vec![]
191                }
192            }
193
194            // ── Trailing stop ───────────────────────────────────────────
195            Rule::TrailingStop {
196                distance,
197                peak_price,
198                initialized,
199            } => {
200                if !*initialized {
201                    *peak_price = data.average_entry;
202                    *initialized = true;
203                }
204
205                let check = quote.eval_price(data.side, model);
206
207                match data.side {
208                    Side::Buy => {
209                        if check > *peak_price {
210                            *peak_price = check;
211                        }
212                        let trailing_sl = *peak_price - *distance;
213                        if check <= trailing_sl {
214                            vec![Effect::PositionClosed {
215                                id: data.id.to_owned(),
216                                reason: CloseReason::TrailingStop,
217                            }]
218                        } else {
219                            vec![]
220                        }
221                    }
222                    Side::Sell => {
223                        if check < *peak_price {
224                            *peak_price = check;
225                        }
226                        let trailing_sl = *peak_price + *distance;
227                        if check >= trailing_sl {
228                            vec![Effect::PositionClosed {
229                                id: data.id.to_owned(),
230                                reason: CloseReason::TrailingStop,
231                            }]
232                        } else {
233                            vec![]
234                        }
235                    }
236                }
237            }
238
239            // ── Take profit ─────────────────────────────────────────────
240            Rule::TakeProfit {
241                price,
242                close_ratio,
243                triggered,
244            } => {
245                if *triggered {
246                    return vec![];
247                }
248                if data.remaining_ratio <= 0.0 {
249                    return vec![];
250                }
251
252                let check = quote.eval_price(data.side, model);
253                let hit = match data.side {
254                    Side::Buy => check >= *price,
255                    Side::Sell => check <= *price,
256                };
257
258                if hit {
259                    *triggered = true;
260                    let actual_ratio = close_ratio.min(data.remaining_ratio);
261
262                    // If this closes the remaining position, emit CloseFull.
263                    if data.remaining_ratio - actual_ratio <= position_size_tolerance(1.0) {
264                        vec![Effect::PositionClosed {
265                            id: data.id.to_owned(),
266                            reason: CloseReason::Target,
267                        }]
268                    } else {
269                        vec![Effect::PartialClose {
270                            id: data.id.to_owned(),
271                            ratio: actual_ratio,
272                            reason: CloseReason::Target,
273                        }]
274                    }
275                } else {
276                    vec![]
277                }
278            }
279
280            // ── Breakeven when price reached ────────────────────────────
281            Rule::BreakevenWhen {
282                trigger_price,
283                triggered,
284            } => {
285                if *triggered {
286                    return vec![];
287                }
288
289                let check = quote.eval_price(data.side, model);
290                let hit = match data.side {
291                    Side::Buy => check >= *trigger_price,
292                    Side::Sell => check <= *trigger_price,
293                };
294
295                if hit {
296                    *triggered = true;
297                    vec![Effect::StoplossModified {
298                        id: data.id.to_owned(),
299                        old_price: 0.0, // filled in by engine
300                        new_price: data.average_entry,
301                    }]
302                } else {
303                    vec![]
304                }
305            }
306
307            // ── Breakeven after N target hits ───────────────────────────
308            Rule::BreakevenAfterTargets { after_n, triggered } => {
309                if *triggered {
310                    return vec![];
311                }
312
313                if data.target_hits >= *after_n {
314                    *triggered = true;
315                    vec![Effect::StoplossModified {
316                        id: data.id.to_owned(),
317                        old_price: 0.0, // filled in by engine
318                        new_price: data.average_entry,
319                    }]
320                } else {
321                    vec![]
322                }
323            }
324
325            // ── Time-based exit ─────────────────────────────────────────
326            Rule::TimeExit { max_seconds } => {
327                if let Some(open_ts) = data.open_ts {
328                    let elapsed = (quote.ts - open_ts).num_seconds();
329                    if elapsed >= *max_seconds as i64 {
330                        vec![Effect::PositionClosed {
331                            id: data.id.to_owned(),
332                            reason: CloseReason::TimeExit,
333                        }]
334                    } else {
335                        vec![]
336                    }
337                } else {
338                    vec![]
339                }
340            }
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use chrono::NaiveDate;
349
350    fn ts(h: u32, m: u32, s: u32) -> chrono::NaiveDateTime {
351        NaiveDate::from_ymd_opt(2026, 1, 1)
352            .unwrap()
353            .and_hms_opt(h, m, s)
354            .unwrap()
355    }
356
357    fn quote(bid: f64, ask: f64) -> PriceQuote {
358        PriceQuote {
359            symbol: "EURUSD".into(),
360            ts: ts(12, 0, 0),
361            bid,
362            ask,
363        }
364    }
365
366    fn view_buy(id: &str, entry: f64) -> PositionView<'_> {
367        PositionView {
368            id,
369            symbol: "EURUSD",
370            side: Side::Buy,
371            status: PositionStatus::Open,
372            average_entry: entry,
373            remaining_ratio: 1.0,
374            target_hits: 0,
375            open_ts: Some(ts(10, 0, 0)),
376        }
377    }
378
379    fn view_sell(id: &str, entry: f64) -> PositionView<'_> {
380        PositionView {
381            id,
382            symbol: "EURUSD",
383            side: Side::Sell,
384            status: PositionStatus::Open,
385            average_entry: entry,
386            remaining_ratio: 1.0,
387            target_hits: 0,
388            open_ts: Some(ts(10, 0, 0)),
389        }
390    }
391
392    #[test]
393    fn fixed_stoploss_buy_triggers() {
394        let mut rule = Rule::fixed_stoploss(1.0800);
395        let v = view_buy("p1", 1.0850);
396        // bid at SL → triggers
397        let effects = rule.evaluate(&v, &quote(1.0800, 1.0802), FillModel::BidAsk);
398        assert_eq!(effects.len(), 1);
399        assert!(matches!(
400            &effects[0],
401            Effect::PositionClosed {
402                reason: CloseReason::Stoploss,
403                ..
404            }
405        ));
406    }
407
408    #[test]
409    fn fixed_stoploss_buy_no_trigger() {
410        let mut rule = Rule::fixed_stoploss(1.0800);
411        let v = view_buy("p1", 1.0850);
412        let effects = rule.evaluate(&v, &quote(1.0810, 1.0812), FillModel::BidAsk);
413        assert!(effects.is_empty());
414    }
415
416    #[test]
417    fn fixed_stoploss_sell_triggers() {
418        let mut rule = Rule::fixed_stoploss(1.0900);
419        let v = view_sell("p1", 1.0850);
420        // ask at SL → triggers
421        let effects = rule.evaluate(&v, &quote(1.0898, 1.0900), FillModel::BidAsk);
422        assert_eq!(effects.len(), 1);
423        assert!(matches!(
424            &effects[0],
425            Effect::PositionClosed {
426                reason: CloseReason::Stoploss,
427                ..
428            }
429        ));
430    }
431
432    #[test]
433    fn trailing_stop_buy() {
434        let mut rule = Rule::trailing_stop(0.0020);
435        let v = view_buy("p1", 1.0850);
436
437        // Price rises → no trigger
438        let effects = rule.evaluate(&v, &quote(1.0870, 1.0872), FillModel::BidAsk);
439        assert!(effects.is_empty());
440
441        // Price rises more → no trigger, peak updates
442        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
443        assert!(effects.is_empty());
444
445        // Price drops within distance → no trigger (0.0900 - 0.0020 = 0.0880)
446        let effects = rule.evaluate(&v, &quote(1.0882, 1.0884), FillModel::BidAsk);
447        assert!(effects.is_empty());
448
449        // Price drops beyond distance → triggers
450        let effects = rule.evaluate(&v, &quote(1.0879, 1.0881), FillModel::BidAsk);
451        assert_eq!(effects.len(), 1);
452        assert!(matches!(
453            &effects[0],
454            Effect::PositionClosed {
455                reason: CloseReason::TrailingStop,
456                ..
457            }
458        ));
459    }
460
461    #[test]
462    fn take_profit_partial() {
463        let mut rule = Rule::take_profit(1.0900, 0.5);
464        let v = view_buy("p1", 1.0850);
465
466        // Not reached
467        let effects = rule.evaluate(&v, &quote(1.0890, 1.0892), FillModel::BidAsk);
468        assert!(effects.is_empty());
469
470        // Reached → partial close
471        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
472        assert_eq!(effects.len(), 1);
473        assert!(
474            matches!(&effects[0], Effect::PartialClose { ratio, .. } if (*ratio - 0.5).abs() < f64::EPSILON)
475        );
476
477        // Second call → already triggered
478        let effects = rule.evaluate(&v, &quote(1.0910, 1.0912), FillModel::BidAsk);
479        assert!(effects.is_empty());
480    }
481
482    #[test]
483    fn take_profit_closes_remaining() {
484        let mut rule = Rule::take_profit(1.0900, 1.0);
485        let v = view_buy("p1", 1.0850);
486
487        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
488        assert_eq!(effects.len(), 1);
489        assert!(matches!(
490            &effects[0],
491            Effect::PositionClosed {
492                reason: CloseReason::Target,
493                ..
494            }
495        ));
496    }
497
498    #[test]
499    fn breakeven_when_triggers() {
500        let mut rule = Rule::breakeven_when(1.0900);
501        let v = view_buy("p1", 1.0850);
502
503        // Not reached
504        let effects = rule.evaluate(&v, &quote(1.0890, 1.0892), FillModel::BidAsk);
505        assert!(effects.is_empty());
506
507        // Reached
508        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
509        assert_eq!(effects.len(), 1);
510        assert!(
511            matches!(&effects[0], Effect::StoplossModified { new_price, .. } if (*new_price - 1.0850).abs() < f64::EPSILON)
512        );
513
514        // Already triggered
515        let effects = rule.evaluate(&v, &quote(1.0910, 1.0912), FillModel::BidAsk);
516        assert!(effects.is_empty());
517    }
518
519    #[test]
520    fn breakeven_after_targets() {
521        let mut rule = Rule::breakeven_after_targets(2);
522
523        // Not enough target hits
524        let mut v = view_buy("p1", 1.0850);
525        v.target_hits = 1;
526        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
527        assert!(effects.is_empty());
528
529        // Enough target hits
530        v.target_hits = 2;
531        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
532        assert_eq!(effects.len(), 1);
533        assert!(matches!(&effects[0], Effect::StoplossModified { .. }));
534
535        // Already triggered
536        v.target_hits = 3;
537        let effects = rule.evaluate(&v, &quote(1.0900, 1.0902), FillModel::BidAsk);
538        assert!(effects.is_empty());
539    }
540
541    #[test]
542    fn time_exit_triggers() {
543        let mut rule = Rule::time_exit(3600); // 1 hour
544        let v = view_buy("p1", 1.0850); // open_ts = 10:00:00
545
546        // 30 minutes later → no trigger
547        let mut q = quote(1.0860, 1.0862);
548        q.ts = ts(10, 30, 0);
549        let effects = rule.evaluate(&v, &q, FillModel::BidAsk);
550        assert!(effects.is_empty());
551
552        // 1 hour later → triggers
553        q.ts = ts(11, 0, 0);
554        let effects = rule.evaluate(&v, &q, FillModel::BidAsk);
555        assert_eq!(effects.len(), 1);
556        assert!(matches!(
557            &effects[0],
558            Effect::PositionClosed {
559                reason: CloseReason::TimeExit,
560                ..
561            }
562        ));
563    }
564
565    #[test]
566    fn rule_skips_closed_position() {
567        let mut rule = Rule::fixed_stoploss(1.0800);
568        let mut v = view_buy("p1", 1.0850);
569        v.status = PositionStatus::Closed;
570
571        let effects = rule.evaluate(&v, &quote(1.0790, 1.0792), FillModel::BidAsk);
572        assert!(effects.is_empty());
573    }
574
575    #[test]
576    fn from_config_roundtrip() {
577        let configs = vec![
578            RuleConfig::FixedStoploss { price: 1.08 },
579            RuleConfig::TrailingStop { distance: 0.002 },
580            RuleConfig::TakeProfit {
581                price: 1.09,
582                close_ratio: 0.5,
583            },
584            RuleConfig::BreakevenWhen {
585                trigger_price: 1.09,
586            },
587            RuleConfig::BreakevenAfterTargets { after_n: 2 },
588            RuleConfig::TimeExit { max_seconds: 3600 },
589        ];
590        let names: Vec<&str> = configs
591            .into_iter()
592            .map(|c| Rule::from_config(c).name())
593            .collect();
594        assert_eq!(
595            names,
596            vec![
597                "FixedStoploss",
598                "TrailingStop",
599                "TakeProfit",
600                "BreakevenWhen",
601                "BreakevenAfterTargets",
602                "TimeExit",
603            ]
604        );
605    }
606}