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}