Skip to main content

finance_query/backtesting/
signal.rs

1//! Signal types for trading signals generated by strategies.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::models::chart::Candle;
7
8use super::error::{BacktestError, Result};
9
10/// Trading signal direction
11#[non_exhaustive]
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum SignalDirection {
14    /// Buy / Go Long
15    Long,
16    /// Sell / Go Short
17    Short,
18    /// Exit current position
19    Exit,
20    /// No action
21    Hold,
22    /// Add to an existing position (pyramid / scale in).
23    ///
24    /// The fraction of current portfolio equity to allocate is stored in
25    /// [`Signal::scale_fraction`]. No-op when no position is open.
26    ScaleIn,
27    /// Partially exit an existing position (scale out).
28    ///
29    /// The fraction of the current position quantity to close is stored in
30    /// [`Signal::scale_fraction`]. A fraction of `1.0` is equivalent to a
31    /// full [`Exit`](Self::Exit). No-op when no position is open.
32    ScaleOut,
33}
34
35impl std::fmt::Display for SignalDirection {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Long => write!(f, "LONG"),
39            Self::Short => write!(f, "SHORT"),
40            Self::Exit => write!(f, "EXIT"),
41            Self::Hold => write!(f, "HOLD"),
42            Self::ScaleIn => write!(f, "SCALE_IN"),
43            Self::ScaleOut => write!(f, "SCALE_OUT"),
44        }
45    }
46}
47
48/// Order type controlling how a signal's entry is executed.
49///
50/// [`OrderType::Market`] (the default) preserves the existing behaviour:
51/// fill at the next bar's open.  The limit and stop variants queue the order
52/// as a [`PendingOrder`] and fill when the bar's high/low reaches the
53/// specified price level.
54///
55/// This enum is `#[non_exhaustive]` so that adding new order types (e.g.
56/// `MarketOnClose`, `TrailingStopLimit`) in a future release is not a
57/// breaking change for library consumers that match on it exhaustively.
58#[non_exhaustive]
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
60pub enum OrderType {
61    /// Standard market order — fill at next bar's open (default).
62    #[default]
63    Market,
64    /// Limit buy — fill if `candle.low ≤ limit_price`.
65    BuyLimit {
66        /// Maximum price accepted for a long entry.
67        limit_price: f64,
68    },
69    /// Stop buy (breakout) — fill if `candle.high ≥ stop_price`.
70    BuyStop {
71        /// Price level that triggers a long entry.
72        stop_price: f64,
73    },
74    /// Limit sell — fill if `candle.high ≥ limit_price`.
75    SellLimit {
76        /// Minimum price accepted for a short entry.
77        limit_price: f64,
78    },
79    /// Stop sell (breakdown) — fill if `candle.low ≤ stop_price`.
80    SellStop {
81        /// Price level that triggers a short entry.
82        stop_price: f64,
83    },
84    /// Stop-limit buy — triggered when `candle.high ≥ stop_price`,
85    /// filled at the trigger price capped at `limit_price`.
86    ///
87    /// If the bar opens above `limit_price` the order cannot fill that bar.
88    BuyStopLimit {
89        /// Price that activates the limit order.
90        stop_price: f64,
91        /// Maximum acceptable fill price.
92        limit_price: f64,
93    },
94}
95
96impl OrderType {
97    /// Try to fill this order type against `candle`.
98    ///
99    /// Returns the computed fill price if the order's price level was reached,
100    /// or `None` if the level was not touched this bar.
101    ///
102    /// Gap guards ensure the fill is never unrealistically better than the
103    /// market would have provided:
104    /// - Buy orders: if the bar opens *below* the limit, fill at the open.
105    /// - Sell orders: analogous logic in the other direction.
106    pub(crate) fn try_fill(&self, candle: &Candle) -> Option<f64> {
107        match self {
108            Self::Market => None, // Market orders are never in the pending queue.
109            Self::BuyLimit { limit_price } => {
110                if candle.low <= *limit_price {
111                    // Gap guard: open already below the limit → fill at open.
112                    Some(candle.open.min(*limit_price))
113                } else {
114                    None
115                }
116            }
117            Self::BuyStop { stop_price } => {
118                if candle.high >= *stop_price {
119                    // Gap guard: open already above the stop → fill at open.
120                    Some(candle.open.max(*stop_price))
121                } else {
122                    None
123                }
124            }
125            Self::SellLimit { limit_price } => {
126                if candle.high >= *limit_price {
127                    // Gap guard: open already above the limit → fill at open.
128                    Some(candle.open.max(*limit_price))
129                } else {
130                    None
131                }
132            }
133            Self::SellStop { stop_price } => {
134                if candle.low <= *stop_price {
135                    // Gap guard: open already below the stop → fill at open.
136                    Some(candle.open.min(*stop_price))
137                } else {
138                    None
139                }
140            }
141            Self::BuyStopLimit {
142                stop_price,
143                limit_price,
144            } => {
145                // Triggered when price breaks up through stop_price.
146                // Fill at the trigger price (gap guard applied), but only if it
147                // does not exceed limit_price; if the bar gaps above the limit
148                // the order cannot fill this bar.
149                if candle.high >= *stop_price {
150                    let trigger_fill = candle.open.max(*stop_price);
151                    if trigger_fill <= *limit_price {
152                        Some(trigger_fill)
153                    } else {
154                        None // Gapped above the limit; unfillable this bar.
155                    }
156                } else {
157                    None
158                }
159            }
160        }
161    }
162}
163
164/// A queued limit or stop entry order awaiting price-level execution.
165///
166/// Created by the engine when a strategy returns a [`Signal`] whose
167/// [`Signal::order_type`] is not [`OrderType::Market`].  The engine checks
168/// the order each subsequent bar until it fills or expires.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct PendingOrder {
171    /// The original signal returned by the strategy.
172    pub signal: Signal,
173    /// Order type carrying the price level(s) used for fill logic.
174    pub order_type: OrderType,
175    /// Bar index (in the candles slice) when this order was placed.
176    pub created_bar: usize,
177    /// Optional GTC expiry: cancel after this many bars if not filled.
178    ///
179    /// `None` means Good-Till-Cancelled.
180    pub expires_in_bars: Option<usize>,
181}
182
183/// Signal strength/confidence (0.0 to 1.0)
184#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
185pub struct SignalStrength(f64);
186
187impl SignalStrength {
188    /// Create a new signal strength value
189    ///
190    /// # Errors
191    /// Returns error if value is not between 0.0 and 1.0
192    pub fn new(value: f64) -> Result<Self> {
193        if !(0.0..=1.0).contains(&value) {
194            return Err(BacktestError::invalid_param(
195                "signal_strength",
196                "must be between 0.0 and 1.0",
197            ));
198        }
199        Ok(Self(value))
200    }
201
202    /// Create a signal strength without validation (clamped to [0.0, 1.0])
203    pub fn clamped(value: f64) -> Self {
204        Self(value.clamp(0.0, 1.0))
205    }
206
207    /// Get the strength value
208    pub fn value(&self) -> f64 {
209        self.0
210    }
211
212    /// Strong signal (1.0)
213    pub fn strong() -> Self {
214        Self(1.0)
215    }
216
217    /// Medium signal (0.5)
218    pub fn medium() -> Self {
219        Self(0.5)
220    }
221
222    /// Weak signal (0.3)
223    pub fn weak() -> Self {
224        Self(0.3)
225    }
226}
227
228impl Default for SignalStrength {
229    fn default() -> Self {
230        Self(1.0)
231    }
232}
233
234impl std::fmt::Display for SignalStrength {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        write!(f, "{:.2}", self.0)
237    }
238}
239
240/// Metadata attached to signals for analysis
241#[non_exhaustive]
242#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243pub struct SignalMetadata {
244    /// Indicator values at signal time
245    pub indicators: HashMap<String, f64>,
246}
247
248impl SignalMetadata {
249    /// Create empty metadata
250    pub fn new() -> Self {
251        Self::default()
252    }
253
254    /// Add an indicator value
255    pub fn with_indicator(mut self, name: impl Into<String>, value: f64) -> Self {
256        self.indicators.insert(name.into(), value);
257        self
258    }
259}
260
261/// A trading signal generated by a strategy
262#[non_exhaustive]
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Signal {
265    /// Signal direction
266    pub direction: SignalDirection,
267
268    /// Signal strength/confidence
269    pub strength: SignalStrength,
270
271    /// Timestamp when signal was generated
272    pub timestamp: i64,
273
274    /// Price at signal generation
275    pub price: f64,
276
277    /// Optional reason/description
278    pub reason: Option<String>,
279
280    /// Strategy-specific metadata (indicator values, etc.)
281    pub metadata: Option<SignalMetadata>,
282
283    /// User-defined tags for post-hoc trade subgroup analysis.
284    ///
285    /// Tags are propagated to [`Trade::tags`] when a position closes,
286    /// enabling `BacktestResult::trades_by_tag` and `metrics_by_tag` queries.
287    /// Use the `.tag()` builder to attach tags at signal creation time.
288    #[serde(default)]
289    pub tags: Vec<String>,
290
291    /// Fraction for [`SignalDirection::ScaleIn`] and [`SignalDirection::ScaleOut`] signals.
292    ///
293    /// - `ScaleIn`: fraction of current portfolio **equity** to add to the position (`0.0..=1.0`).
294    /// - `ScaleOut`: fraction of current **position quantity** to close (`0.0..=1.0`).
295    ///
296    /// `None` for all other signal directions. Set via [`Signal::scale_in`] /
297    /// [`Signal::scale_out`] constructors.
298    #[serde(default)]
299    pub scale_fraction: Option<f64>,
300
301    /// Order type controlling how this signal's entry is executed.
302    ///
303    /// [`OrderType::Market`] (default) fills at next bar's open.  Limit and
304    /// stop types queue the order as a [`PendingOrder`] and fill when the
305    /// bar's high/low reaches the specified price level.
306    ///
307    /// Only meaningful for [`SignalDirection::Long`] and
308    /// [`SignalDirection::Short`] signals; ignored for Exit / ScaleIn /
309    /// ScaleOut / Hold.
310    #[serde(default)]
311    pub order_type: OrderType,
312
313    /// Expiry for pending limit/stop orders, measured in bars.
314    ///
315    /// When set, the pending order is cancelled if not filled within this
316    /// many bars after it is placed.  `None` means Good-Till-Cancelled.
317    ///
318    /// Only relevant when [`Signal::order_type`] is not [`OrderType::Market`].
319    #[serde(default)]
320    pub expires_in_bars: Option<usize>,
321
322    /// Per-trade stop-loss percentage override (0.0 – 1.0).
323    ///
324    /// When set on an entry signal ([`SignalDirection::Long`] or
325    /// [`SignalDirection::Short`]), this value is stored on the resulting
326    /// [`Position`] and takes precedence over [`BacktestConfig::stop_loss_pct`]
327    /// for the lifetime of that position.
328    ///
329    /// Set via the `.stop_loss(pct)` builder method.
330    ///
331    /// [`Position`]: crate::backtesting::Position
332    /// [`BacktestConfig::stop_loss_pct`]: crate::backtesting::BacktestConfig::stop_loss_pct
333    #[serde(default)]
334    pub bracket_stop_loss_pct: Option<f64>,
335
336    /// Per-trade take-profit percentage override (0.0 – 1.0).
337    ///
338    /// When set on an entry signal, stored on the resulting [`Position`] and
339    /// takes precedence over [`BacktestConfig::take_profit_pct`].
340    ///
341    /// Set via the `.take_profit(pct)` builder method.
342    ///
343    /// [`Position`]: crate::backtesting::Position
344    /// [`BacktestConfig::take_profit_pct`]: crate::backtesting::BacktestConfig::take_profit_pct
345    #[serde(default)]
346    pub bracket_take_profit_pct: Option<f64>,
347
348    /// Per-trade trailing stop percentage override (0.0 – 1.0).
349    ///
350    /// When set on an entry signal, stored on the resulting [`Position`] and
351    /// takes precedence over [`BacktestConfig::trailing_stop_pct`].
352    ///
353    /// Set via the `.trailing_stop(pct)` builder method.
354    ///
355    /// [`Position`]: crate::backtesting::Position
356    /// [`BacktestConfig::trailing_stop_pct`]: crate::backtesting::BacktestConfig::trailing_stop_pct
357    #[serde(default)]
358    pub bracket_trailing_stop_pct: Option<f64>,
359}
360
361impl Signal {
362    /// Create a long signal
363    pub fn long(timestamp: i64, price: f64) -> Self {
364        Self {
365            direction: SignalDirection::Long,
366            strength: SignalStrength::default(),
367            timestamp,
368            price,
369            reason: None,
370            metadata: None,
371            tags: Vec::new(),
372            scale_fraction: None,
373            order_type: OrderType::Market,
374            expires_in_bars: None,
375            bracket_stop_loss_pct: None,
376            bracket_take_profit_pct: None,
377            bracket_trailing_stop_pct: None,
378        }
379    }
380
381    /// Create a short signal
382    pub fn short(timestamp: i64, price: f64) -> Self {
383        Self {
384            direction: SignalDirection::Short,
385            strength: SignalStrength::default(),
386            timestamp,
387            price,
388            reason: None,
389            metadata: None,
390            tags: Vec::new(),
391            scale_fraction: None,
392            order_type: OrderType::Market,
393            expires_in_bars: None,
394            bracket_stop_loss_pct: None,
395            bracket_take_profit_pct: None,
396            bracket_trailing_stop_pct: None,
397        }
398    }
399
400    /// Create an exit signal
401    pub fn exit(timestamp: i64, price: f64) -> Self {
402        Self {
403            direction: SignalDirection::Exit,
404            strength: SignalStrength::default(),
405            timestamp,
406            price,
407            reason: None,
408            metadata: None,
409            tags: Vec::new(),
410            scale_fraction: None,
411            order_type: OrderType::Market,
412            expires_in_bars: None,
413            bracket_stop_loss_pct: None,
414            bracket_take_profit_pct: None,
415            bracket_trailing_stop_pct: None,
416        }
417    }
418
419    /// Create a hold signal (no action)
420    pub fn hold() -> Self {
421        Self {
422            direction: SignalDirection::Hold,
423            strength: SignalStrength::default(),
424            timestamp: 0,
425            price: 0.0,
426            reason: None,
427            metadata: None,
428            tags: Vec::new(),
429            scale_fraction: None,
430            order_type: OrderType::Market,
431            expires_in_bars: None,
432            bracket_stop_loss_pct: None,
433            bracket_take_profit_pct: None,
434            bracket_trailing_stop_pct: None,
435        }
436    }
437
438    /// Check if this is a hold signal
439    pub fn is_hold(&self) -> bool {
440        matches!(self.direction, SignalDirection::Hold)
441    }
442
443    /// Check if this is an entry signal (Long or Short)
444    pub fn is_entry(&self) -> bool {
445        matches!(
446            self.direction,
447            SignalDirection::Long | SignalDirection::Short
448        )
449    }
450
451    /// Check if this is an exit signal
452    pub fn is_exit(&self) -> bool {
453        matches!(self.direction, SignalDirection::Exit)
454    }
455
456    /// Check if this is a scaling signal (scale-in or scale-out)
457    pub fn is_scaling(&self) -> bool {
458        matches!(
459            self.direction,
460            SignalDirection::ScaleIn | SignalDirection::ScaleOut
461        )
462    }
463
464    /// Create a scale-in signal — add to an existing position.
465    ///
466    /// `fraction` is the portion of current portfolio **equity** to allocate to
467    /// the additional shares. Must be in `0.0..=1.0`; values outside this range
468    /// are clamped by the engine. Has no effect if no position is currently open.
469    ///
470    /// # Example
471    ///
472    /// ```rust,no_run
473    /// use finance_query::backtesting::Signal;
474    ///
475    /// // In a custom Strategy::on_candle implementation:
476    /// # let (ctx_timestamp, ctx_price) = (0i64, 0.0f64);
477    /// // Add 10% of current equity to the existing long position.
478    /// let signal = Signal::scale_in(0.10, ctx_timestamp, ctx_price);
479    /// ```
480    pub fn scale_in(fraction: f64, timestamp: i64, price: f64) -> Self {
481        Self {
482            direction: SignalDirection::ScaleIn,
483            strength: SignalStrength::default(),
484            timestamp,
485            price,
486            reason: None,
487            metadata: None,
488            tags: Vec::new(),
489            scale_fraction: Some(fraction.clamp(0.0, 1.0)),
490            order_type: OrderType::Market,
491            expires_in_bars: None,
492            bracket_stop_loss_pct: None,
493            bracket_take_profit_pct: None,
494            bracket_trailing_stop_pct: None,
495        }
496    }
497
498    /// Create a scale-out signal — partially exit an existing position.
499    ///
500    /// `fraction` is the portion of the current position **quantity** to close.
501    /// Must be in `0.0..=1.0`; values outside this range are clamped. A fraction
502    /// of `1.0` closes the entire position (equivalent to [`Signal::exit`]). Has
503    /// no effect if no position is currently open.
504    ///
505    /// # Example
506    ///
507    /// ```rust,no_run
508    /// use finance_query::backtesting::Signal;
509    ///
510    /// // In a custom Strategy::on_candle implementation:
511    /// # let (ctx_timestamp, ctx_price) = (0i64, 0.0f64);
512    /// // Close half the current position to lock in partial profits.
513    /// let signal = Signal::scale_out(0.50, ctx_timestamp, ctx_price);
514    /// ```
515    pub fn scale_out(fraction: f64, timestamp: i64, price: f64) -> Self {
516        Self {
517            direction: SignalDirection::ScaleOut,
518            strength: SignalStrength::default(),
519            timestamp,
520            price,
521            reason: None,
522            metadata: None,
523            tags: Vec::new(),
524            scale_fraction: Some(fraction.clamp(0.0, 1.0)),
525            order_type: OrderType::Market,
526            expires_in_bars: None,
527            bracket_stop_loss_pct: None,
528            bracket_take_profit_pct: None,
529            bracket_trailing_stop_pct: None,
530        }
531    }
532
533    /// Create a limit buy order — enter long when price pulls back to `limit_price`.
534    ///
535    /// The order is queued as a [`PendingOrder`] and fills on the first subsequent
536    /// bar where `candle.low ≤ limit_price`.  Fill price is `limit_price`, or the
537    /// bar's open if a gap-down open is already below the limit (realistic gap fill).
538    ///
539    /// # Example
540    ///
541    /// ```rust,no_run
542    /// use finance_query::backtesting::Signal;
543    ///
544    /// # let (ts, close) = (0i64, 100.0f64);
545    /// // Buy if price dips to 98 within the next 5 bars.
546    /// let signal = Signal::buy_limit(ts, close, 98.0).expires_in_bars(5);
547    /// ```
548    pub fn buy_limit(timestamp: i64, price: f64, limit_price: f64) -> Self {
549        Self {
550            direction: SignalDirection::Long,
551            order_type: OrderType::BuyLimit { limit_price },
552            strength: SignalStrength::default(),
553            timestamp,
554            price,
555            reason: None,
556            metadata: None,
557            tags: Vec::new(),
558            scale_fraction: None,
559            expires_in_bars: None,
560            bracket_stop_loss_pct: None,
561            bracket_take_profit_pct: None,
562            bracket_trailing_stop_pct: None,
563        }
564    }
565
566    /// Create a stop buy order (breakout entry) — enter long when price breaks above
567    /// `stop_price`.
568    ///
569    /// Fills on the first subsequent bar where `candle.high ≥ stop_price`.  Fill
570    /// price is `stop_price`, or the bar's open if a gap-up open is already above
571    /// the stop (open price used instead).
572    ///
573    /// # Example
574    ///
575    /// ```rust,no_run
576    /// use finance_query::backtesting::Signal;
577    ///
578    /// # let (ts, close) = (0i64, 100.0f64);
579    /// // Enter long on a breakout above 105.
580    /// let signal = Signal::buy_stop(ts, close, 105.0);
581    /// ```
582    pub fn buy_stop(timestamp: i64, price: f64, stop_price: f64) -> Self {
583        Self {
584            direction: SignalDirection::Long,
585            order_type: OrderType::BuyStop { stop_price },
586            strength: SignalStrength::default(),
587            timestamp,
588            price,
589            reason: None,
590            metadata: None,
591            tags: Vec::new(),
592            scale_fraction: None,
593            expires_in_bars: None,
594            bracket_stop_loss_pct: None,
595            bracket_take_profit_pct: None,
596            bracket_trailing_stop_pct: None,
597        }
598    }
599
600    /// Create a limit sell order — enter short when price rallies to `limit_price`.
601    ///
602    /// Fills on the first subsequent bar where `candle.high ≥ limit_price`.  Fill
603    /// price is `limit_price`, or the bar's open if a gap-up open is already at or
604    /// above the limit.
605    ///
606    /// # Example
607    ///
608    /// ```rust,no_run
609    /// use finance_query::backtesting::Signal;
610    ///
611    /// # let (ts, close) = (0i64, 100.0f64);
612    /// // Short into a rally reaching 103.
613    /// let signal = Signal::sell_limit(ts, close, 103.0).expires_in_bars(10);
614    /// ```
615    pub fn sell_limit(timestamp: i64, price: f64, limit_price: f64) -> Self {
616        Self {
617            direction: SignalDirection::Short,
618            order_type: OrderType::SellLimit { limit_price },
619            strength: SignalStrength::default(),
620            timestamp,
621            price,
622            reason: None,
623            metadata: None,
624            tags: Vec::new(),
625            scale_fraction: None,
626            expires_in_bars: None,
627            bracket_stop_loss_pct: None,
628            bracket_take_profit_pct: None,
629            bracket_trailing_stop_pct: None,
630        }
631    }
632
633    /// Create a stop sell order (breakdown entry) — enter short when price breaks
634    /// below `stop_price`.
635    ///
636    /// Fills on the first subsequent bar where `candle.low ≤ stop_price`.  Fill
637    /// price is `stop_price`, or the bar's open if a gap-down open is already below
638    /// the stop.
639    ///
640    /// # Example
641    ///
642    /// ```rust,no_run
643    /// use finance_query::backtesting::Signal;
644    ///
645    /// # let (ts, close) = (0i64, 100.0f64);
646    /// // Short on a breakdown below 95.
647    /// let signal = Signal::sell_stop(ts, close, 95.0);
648    /// ```
649    pub fn sell_stop(timestamp: i64, price: f64, stop_price: f64) -> Self {
650        Self {
651            direction: SignalDirection::Short,
652            order_type: OrderType::SellStop { stop_price },
653            strength: SignalStrength::default(),
654            timestamp,
655            price,
656            reason: None,
657            metadata: None,
658            tags: Vec::new(),
659            scale_fraction: None,
660            expires_in_bars: None,
661            bracket_stop_loss_pct: None,
662            bracket_take_profit_pct: None,
663            bracket_trailing_stop_pct: None,
664        }
665    }
666
667    /// Create a stop-limit buy order — triggered by a breakout above `stop_price`
668    /// but capped at `limit_price`.
669    ///
670    /// Fills when `candle.high ≥ stop_price` and the computed trigger price
671    /// (bar open or stop_price, whichever is higher) does not exceed `limit_price`.
672    /// If the bar gaps up above `limit_price`, the order cannot fill that bar and
673    /// remains pending.
674    ///
675    /// # Example
676    ///
677    /// ```rust,no_run
678    /// use finance_query::backtesting::Signal;
679    ///
680    /// # let (ts, close) = (0i64, 100.0f64);
681    /// // Breakout buy above 105, but reject fills above 107.
682    /// let signal = Signal::buy_stop_limit(ts, close, 105.0, 107.0);
683    /// ```
684    pub fn buy_stop_limit(timestamp: i64, price: f64, stop_price: f64, limit_price: f64) -> Self {
685        Self {
686            direction: SignalDirection::Long,
687            order_type: OrderType::BuyStopLimit {
688                stop_price,
689                limit_price,
690            },
691            strength: SignalStrength::default(),
692            timestamp,
693            price,
694            reason: None,
695            metadata: None,
696            tags: Vec::new(),
697            scale_fraction: None,
698            expires_in_bars: None,
699            bracket_stop_loss_pct: None,
700            bracket_take_profit_pct: None,
701            bracket_trailing_stop_pct: None,
702        }
703    }
704
705    /// Set an expiry (in bars) for this pending limit/stop order.
706    ///
707    /// When the order is not filled within `bars` bars after being placed, it is
708    /// automatically cancelled.  Has no effect on [`OrderType::Market`] signals.
709    ///
710    /// # Example
711    ///
712    /// ```rust,no_run
713    /// use finance_query::backtesting::Signal;
714    ///
715    /// # let (ts, close) = (0i64, 100.0f64);
716    /// // Day order: fill or cancel after 1 bar.
717    /// let signal = Signal::buy_limit(ts, close, 98.0).expires_in_bars(1);
718    /// ```
719    pub fn expires_in_bars(mut self, bars: usize) -> Self {
720        self.expires_in_bars = Some(bars);
721        self
722    }
723
724    /// Set signal strength
725    pub fn with_strength(mut self, strength: SignalStrength) -> Self {
726        self.strength = strength;
727        self
728    }
729
730    /// Set reason/description
731    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
732        self.reason = Some(reason.into());
733        self
734    }
735
736    /// Set metadata
737    pub fn with_metadata(mut self, metadata: SignalMetadata) -> Self {
738        self.metadata = Some(metadata);
739        self
740    }
741
742    /// Attach a tag to this signal for post-hoc trade subgroup analysis.
743    ///
744    /// Tags are propagated to [`Trade::tags`] when the position closes,
745    /// enabling `BacktestResult::trades_by_tag` and `metrics_by_tag` queries.
746    /// Multiple tags can be chained: `.tag("breakout").tag("high_volume")`.
747    pub fn tag(mut self, name: impl Into<String>) -> Self {
748        self.tags.push(name.into());
749        self
750    }
751
752    /// Attach a per-trade stop-loss to this entry signal.
753    ///
754    /// `pct` is the loss fraction relative to the entry price (`0.0..=1.0`).
755    /// When set, the resulting position's stop-loss overrides
756    /// [`BacktestConfig::stop_loss_pct`] for the lifetime of that trade.
757    ///
758    /// # Example
759    ///
760    /// ```rust,no_run
761    /// use finance_query::backtesting::Signal;
762    ///
763    /// # let (ts, price) = (0i64, 100.0f64);
764    /// // Stop out at -5% from entry, regardless of the config default.
765    /// let signal = Signal::long(ts, price).stop_loss(0.05);
766    /// ```
767    ///
768    /// [`BacktestConfig::stop_loss_pct`]: crate::backtesting::BacktestConfig::stop_loss_pct
769    pub fn stop_loss(mut self, pct: f64) -> Self {
770        self.bracket_stop_loss_pct = Some(pct.abs());
771        self
772    }
773
774    /// Attach a per-trade take-profit to this entry signal.
775    ///
776    /// `pct` is the profit fraction relative to the entry price (`0.0..=1.0`).
777    /// When set, the resulting position's take-profit overrides
778    /// [`BacktestConfig::take_profit_pct`] for the lifetime of that trade.
779    ///
780    /// # Example
781    ///
782    /// ```rust,no_run
783    /// use finance_query::backtesting::Signal;
784    ///
785    /// # let (ts, price) = (0i64, 100.0f64);
786    /// // Take profit at +15% from entry.
787    /// let signal = Signal::long(ts, price).take_profit(0.15);
788    /// ```
789    ///
790    /// [`BacktestConfig::take_profit_pct`]: crate::backtesting::BacktestConfig::take_profit_pct
791    pub fn take_profit(mut self, pct: f64) -> Self {
792        self.bracket_take_profit_pct = Some(pct.abs());
793        self
794    }
795
796    /// Attach a per-trade trailing stop to this entry signal.
797    ///
798    /// `pct` is the trail fraction from the position's peak/trough price
799    /// (`0.0..=1.0`). When set, the resulting position's trailing stop
800    /// overrides [`BacktestConfig::trailing_stop_pct`] for the lifetime of
801    /// that trade.
802    ///
803    /// # Example
804    ///
805    /// ```rust,no_run
806    /// use finance_query::backtesting::Signal;
807    ///
808    /// # let (ts, price) = (0i64, 100.0f64);
809    /// // Exit if price drops 3% from its peak since entry.
810    /// let signal = Signal::long(ts, price).trailing_stop(0.03);
811    /// ```
812    ///
813    /// [`BacktestConfig::trailing_stop_pct`]: crate::backtesting::BacktestConfig::trailing_stop_pct
814    pub fn trailing_stop(mut self, pct: f64) -> Self {
815        self.bracket_trailing_stop_pct = Some(pct.abs());
816        self
817    }
818}
819
820impl Default for Signal {
821    fn default() -> Self {
822        Self::hold()
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use crate::models::chart::Candle;
830
831    fn make_candle(open: f64, high: f64, low: f64, close: f64) -> Candle {
832        Candle {
833            timestamp: 0,
834            open,
835            high,
836            low,
837            close,
838            volume: 1000,
839            adj_close: None,
840        }
841    }
842
843    #[test]
844    fn test_order_type_constructors() {
845        let sig = Signal::buy_limit(100, 105.0, 98.0);
846        assert_eq!(sig.direction, SignalDirection::Long);
847        assert_eq!(sig.order_type, OrderType::BuyLimit { limit_price: 98.0 });
848        assert!(sig.expires_in_bars.is_none());
849
850        let sig = Signal::buy_stop(100, 105.0, 110.0);
851        assert_eq!(sig.order_type, OrderType::BuyStop { stop_price: 110.0 });
852
853        let sig = Signal::sell_limit(100, 105.0, 108.0);
854        assert_eq!(sig.direction, SignalDirection::Short);
855        assert_eq!(sig.order_type, OrderType::SellLimit { limit_price: 108.0 });
856
857        let sig = Signal::sell_stop(100, 105.0, 100.0);
858        assert_eq!(sig.order_type, OrderType::SellStop { stop_price: 100.0 });
859
860        let sig = Signal::buy_stop_limit(100, 105.0, 110.0, 112.0);
861        assert_eq!(
862            sig.order_type,
863            OrderType::BuyStopLimit {
864                stop_price: 110.0,
865                limit_price: 112.0
866            }
867        );
868    }
869
870    #[test]
871    fn test_expires_in_bars_builder() {
872        let sig = Signal::buy_limit(0, 100.0, 95.0).expires_in_bars(5);
873        assert_eq!(sig.expires_in_bars, Some(5));
874
875        let sig = Signal::buy_stop(0, 100.0, 105.0);
876        assert!(sig.expires_in_bars.is_none());
877    }
878
879    #[test]
880    fn test_try_fill_buy_limit() {
881        // Bar that touches the limit
882        let candle = make_candle(101.0, 103.0, 97.0, 100.0);
883        assert_eq!(
884            OrderType::BuyLimit { limit_price: 98.0 }.try_fill(&candle),
885            Some(98.0)
886        );
887
888        // Gap-down: open already below limit — fill at open
889        let candle = make_candle(96.0, 99.0, 95.0, 98.0);
890        assert_eq!(
891            OrderType::BuyLimit { limit_price: 98.0 }.try_fill(&candle),
892            Some(96.0) // open < limit, fill at open
893        );
894
895        // Bar that does not touch the limit
896        let candle = make_candle(102.0, 104.0, 100.0, 101.0);
897        assert!(
898            OrderType::BuyLimit { limit_price: 98.0 }
899                .try_fill(&candle)
900                .is_none()
901        );
902    }
903
904    #[test]
905    fn test_try_fill_buy_stop() {
906        // Bar that breaks through the stop
907        let candle = make_candle(104.0, 111.0, 103.0, 108.0);
908        assert_eq!(
909            OrderType::BuyStop { stop_price: 110.0 }.try_fill(&candle),
910            Some(110.0)
911        );
912
913        // Gap-up: open already above stop — fill at open
914        let candle = make_candle(112.0, 115.0, 110.0, 113.0);
915        assert_eq!(
916            OrderType::BuyStop { stop_price: 110.0 }.try_fill(&candle),
917            Some(112.0) // open > stop, fill at open
918        );
919
920        // Bar that does not reach the stop
921        let candle = make_candle(105.0, 109.0, 103.0, 107.0);
922        assert!(
923            OrderType::BuyStop { stop_price: 110.0 }
924                .try_fill(&candle)
925                .is_none()
926        );
927    }
928
929    #[test]
930    fn test_try_fill_sell_limit() {
931        // Bar that reaches limit
932        let candle = make_candle(99.0, 109.0, 98.0, 105.0);
933        assert_eq!(
934            OrderType::SellLimit { limit_price: 108.0 }.try_fill(&candle),
935            Some(108.0)
936        );
937
938        // Gap-up: open above limit — fill at open
939        let candle = make_candle(110.0, 112.0, 108.0, 111.0);
940        assert_eq!(
941            OrderType::SellLimit { limit_price: 108.0 }.try_fill(&candle),
942            Some(110.0)
943        );
944
945        // Does not reach limit
946        let candle = make_candle(100.0, 107.0, 99.0, 104.0);
947        assert!(
948            OrderType::SellLimit { limit_price: 108.0 }
949                .try_fill(&candle)
950                .is_none()
951        );
952    }
953
954    #[test]
955    fn test_try_fill_sell_stop() {
956        // Bar that drops through the stop
957        let candle = make_candle(103.0, 105.0, 99.0, 101.0);
958        assert_eq!(
959            OrderType::SellStop { stop_price: 100.0 }.try_fill(&candle),
960            Some(100.0)
961        );
962
963        // Gap-down: open below stop — fill at open
964        let candle = make_candle(98.0, 102.0, 96.0, 99.0);
965        assert_eq!(
966            OrderType::SellStop { stop_price: 100.0 }.try_fill(&candle),
967            Some(98.0)
968        );
969
970        // Does not drop to stop
971        let candle = make_candle(105.0, 107.0, 101.0, 103.0);
972        assert!(
973            OrderType::SellStop { stop_price: 100.0 }
974                .try_fill(&candle)
975                .is_none()
976        );
977    }
978
979    #[test]
980    fn test_try_fill_buy_stop_limit() {
981        // Triggers and fills within limit
982        let candle = make_candle(104.0, 113.0, 103.0, 110.0);
983        assert_eq!(
984            OrderType::BuyStopLimit {
985                stop_price: 110.0,
986                limit_price: 112.0,
987            }
988            .try_fill(&candle),
989            Some(110.0) // trigger fill at stop (< limit)
990        );
991
992        // Gap-up above limit — cannot fill
993        let candle = make_candle(114.0, 116.0, 112.0, 115.0);
994        assert!(
995            OrderType::BuyStopLimit {
996                stop_price: 110.0,
997                limit_price: 112.0,
998            }
999            .try_fill(&candle)
1000            .is_none() // 114 > limit 112
1001        );
1002
1003        // Does not trigger
1004        let candle = make_candle(105.0, 109.0, 103.0, 107.0);
1005        assert!(
1006            OrderType::BuyStopLimit {
1007                stop_price: 110.0,
1008                limit_price: 112.0,
1009            }
1010            .try_fill(&candle)
1011            .is_none()
1012        );
1013    }
1014
1015    #[test]
1016    fn test_market_order_not_filled() {
1017        let candle = make_candle(100.0, 110.0, 90.0, 105.0);
1018        assert!(OrderType::Market.try_fill(&candle).is_none());
1019    }
1020
1021    #[test]
1022    fn test_signal_strength_bounds() {
1023        assert!(SignalStrength::new(0.5).is_ok());
1024        assert!(SignalStrength::new(0.0).is_ok());
1025        assert!(SignalStrength::new(1.0).is_ok());
1026        assert!(SignalStrength::new(-0.1).is_err());
1027        assert!(SignalStrength::new(1.1).is_err());
1028    }
1029
1030    #[test]
1031    fn test_signal_strength_clamped() {
1032        assert_eq!(SignalStrength::clamped(1.5).value(), 1.0);
1033        assert_eq!(SignalStrength::clamped(-0.5).value(), 0.0);
1034        assert_eq!(SignalStrength::clamped(0.7).value(), 0.7);
1035    }
1036
1037    #[test]
1038    fn test_signal_creation() {
1039        let sig = Signal::long(1234567890, 150.0).with_reason("test signal");
1040        assert_eq!(sig.direction, SignalDirection::Long);
1041        assert_eq!(sig.timestamp, 1234567890);
1042        assert_eq!(sig.price, 150.0);
1043        assert_eq!(sig.reason, Some("test signal".to_string()));
1044        assert!(sig.is_entry());
1045        assert!(!sig.is_hold());
1046        assert!(!sig.is_exit());
1047    }
1048
1049    #[test]
1050    fn test_signal_hold() {
1051        let sig = Signal::hold();
1052        assert!(sig.is_hold());
1053        assert!(!sig.is_entry());
1054        assert!(!sig.is_exit());
1055    }
1056
1057    #[test]
1058    fn test_bracket_builders() {
1059        let sig = Signal::long(0, 100.0)
1060            .stop_loss(0.05)
1061            .take_profit(0.15)
1062            .trailing_stop(0.03);
1063        assert_eq!(sig.bracket_stop_loss_pct, Some(0.05));
1064        assert_eq!(sig.bracket_take_profit_pct, Some(0.15));
1065        assert_eq!(sig.bracket_trailing_stop_pct, Some(0.03));
1066
1067        // No bracket by default
1068        let sig = Signal::long(0, 100.0);
1069        assert!(sig.bracket_stop_loss_pct.is_none());
1070        assert!(sig.bracket_take_profit_pct.is_none());
1071        assert!(sig.bracket_trailing_stop_pct.is_none());
1072    }
1073
1074    #[test]
1075    fn test_bracket_builders_abs_on_negative_input() {
1076        // Negative inputs are converted to their absolute value so a fat-finger
1077        // `-0.05` doesn't silently invert the stop-loss math.
1078        let sig = Signal::long(0, 100.0)
1079            .stop_loss(-0.05)
1080            .take_profit(-0.10)
1081            .trailing_stop(-0.03);
1082        assert_eq!(sig.bracket_stop_loss_pct, Some(0.05));
1083        assert_eq!(sig.bracket_take_profit_pct, Some(0.10));
1084        assert_eq!(sig.bracket_trailing_stop_pct, Some(0.03));
1085    }
1086
1087    #[test]
1088    fn test_signal_metadata() {
1089        let metadata = SignalMetadata::new()
1090            .with_indicator("rsi", 30.0)
1091            .with_indicator("sma_20", 150.0);
1092
1093        let sig = Signal::long(0, 0.0).with_metadata(metadata);
1094        let meta = sig.metadata.unwrap();
1095        assert_eq!(meta.indicators.get("rsi"), Some(&30.0));
1096        assert_eq!(meta.indicators.get("sma_20"), Some(&150.0));
1097    }
1098}