Skip to main content

finance_query/backtesting/
position.rs

1//! Position and trade types for tracking open and closed positions.
2
3use serde::{Deserialize, Serialize};
4
5use super::signal::Signal;
6
7/// Position direction
8#[non_exhaustive]
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum PositionSide {
11    /// Long position (profit when price rises)
12    Long,
13    /// Short position (profit when price falls)
14    Short,
15}
16
17impl std::fmt::Display for PositionSide {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            Self::Long => write!(f, "LONG"),
21            Self::Short => write!(f, "SHORT"),
22        }
23    }
24}
25
26/// An open position
27#[non_exhaustive]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Position {
30    /// Position direction
31    pub side: PositionSide,
32
33    /// Entry timestamp
34    pub entry_timestamp: i64,
35
36    /// Entry price (after slippage)
37    pub entry_price: f64,
38
39    /// Number of shares/units
40    pub quantity: f64,
41
42    /// Number of shares/units at entry (before any dividend reinvestment).
43    #[serde(default)]
44    pub entry_quantity: f64,
45
46    /// Entry commission paid
47    pub entry_commission: f64,
48
49    /// Transaction tax paid on entry (long entries and short covers only).
50    #[serde(default)]
51    pub entry_transaction_tax: f64,
52
53    /// Signal that triggered entry
54    pub entry_signal: Signal,
55
56    /// Accumulated dividend income received while this position was open.
57    ///
58    /// Added to trade P&L on close. Zero when dividends are not supplied to
59    /// the engine or when the position receives no dividends.
60    pub dividend_income: f64,
61
62    /// Dividend income that was NOT reinvested (i.e. remains as cash).
63    /// Used internally for correct cash-accounting.
64    #[serde(default)]
65    pub unreinvested_dividends: f64,
66
67    /// Number of times this position has been scaled into (pyramid adds).
68    ///
69    /// Starts at `0` (initial entry). Incremented by
70    /// [`Position::scale_in`] on each successful add.
71    #[serde(default)]
72    pub scale_in_count: usize,
73
74    /// Number of partial closes executed so far.
75    ///
76    /// Used to assign a monotonically increasing [`Trade::scale_sequence`]
77    /// to each [`Trade`] returned by [`Position::partial_close`].
78    #[serde(default)]
79    pub partial_close_count: usize,
80
81    /// Per-trade stop-loss percentage override.
82    ///
83    /// Populated from [`Signal::bracket_stop_loss_pct`] when the position is
84    /// opened. Takes precedence over [`BacktestConfig::stop_loss_pct`] when
85    /// `Some`. `None` means fall back to the config-level default.
86    ///
87    /// [`Signal::bracket_stop_loss_pct`]: crate::backtesting::Signal::bracket_stop_loss_pct
88    /// [`BacktestConfig::stop_loss_pct`]: crate::backtesting::BacktestConfig::stop_loss_pct
89    #[serde(default)]
90    pub bracket_stop_loss_pct: Option<f64>,
91
92    /// Per-trade take-profit percentage override.
93    ///
94    /// Populated from [`Signal::bracket_take_profit_pct`] when the position is
95    /// opened. Takes precedence over [`BacktestConfig::take_profit_pct`] when
96    /// `Some`.
97    ///
98    /// [`Signal::bracket_take_profit_pct`]: crate::backtesting::Signal::bracket_take_profit_pct
99    /// [`BacktestConfig::take_profit_pct`]: crate::backtesting::BacktestConfig::take_profit_pct
100    #[serde(default)]
101    pub bracket_take_profit_pct: Option<f64>,
102
103    /// Per-trade trailing stop percentage override.
104    ///
105    /// Populated from [`Signal::bracket_trailing_stop_pct`] when the position
106    /// is opened. Takes precedence over [`BacktestConfig::trailing_stop_pct`]
107    /// when `Some`.
108    ///
109    /// [`Signal::bracket_trailing_stop_pct`]: crate::backtesting::Signal::bracket_trailing_stop_pct
110    /// [`BacktestConfig::trailing_stop_pct`]: crate::backtesting::BacktestConfig::trailing_stop_pct
111    #[serde(default)]
112    pub bracket_trailing_stop_pct: Option<f64>,
113}
114
115impl Position {
116    /// Create a new position.
117    pub fn new(
118        side: PositionSide,
119        entry_timestamp: i64,
120        entry_price: f64,
121        quantity: f64,
122        entry_commission: f64,
123        entry_signal: Signal,
124    ) -> Self {
125        Self::new_with_tax(
126            side,
127            entry_timestamp,
128            entry_price,
129            quantity,
130            entry_commission,
131            0.0,
132            entry_signal,
133        )
134    }
135
136    /// Create a new position including an entry transaction tax.
137    pub(crate) fn new_with_tax(
138        side: PositionSide,
139        entry_timestamp: i64,
140        entry_price: f64,
141        quantity: f64,
142        entry_commission: f64,
143        entry_transaction_tax: f64,
144        entry_signal: Signal,
145    ) -> Self {
146        let bracket_stop_loss_pct = entry_signal.bracket_stop_loss_pct;
147        let bracket_take_profit_pct = entry_signal.bracket_take_profit_pct;
148        let bracket_trailing_stop_pct = entry_signal.bracket_trailing_stop_pct;
149        Self {
150            side,
151            entry_timestamp,
152            entry_price,
153            quantity,
154            entry_quantity: quantity,
155            entry_commission,
156            entry_transaction_tax,
157            entry_signal,
158            dividend_income: 0.0,
159            unreinvested_dividends: 0.0,
160            scale_in_count: 0,
161            partial_close_count: 0,
162            bracket_stop_loss_pct,
163            bracket_take_profit_pct,
164            bracket_trailing_stop_pct,
165        }
166    }
167
168    /// Net contribution of this position to portfolio equity at `current_price`.
169    ///
170    /// **Sign convention (important):** returns a *positive* value for long
171    /// positions and a *negative* value for short positions.  The negative
172    /// short value is deliberate: when the engine opens a short it credits
173    /// `cash` with the sale proceeds (`cash += entry_price × quantity`), so
174    /// the correct running equity is `cash + current_value(price)`.  As the
175    /// price falls the negative value grows less negative, and the net equity
176    /// rises — exactly the expected profit behaviour for a short.
177    ///
178    /// If you need the raw notional exposure (always positive), use
179    /// `self.quantity * current_price` directly.
180    pub fn current_value(&self, current_price: f64) -> f64 {
181        match self.side {
182            PositionSide::Long => self.quantity * current_price,
183            PositionSide::Short => -(self.quantity * current_price),
184        }
185    }
186
187    /// Calculate unrealized P&L at given price (before exit commission)
188    pub fn unrealized_pnl(&self, current_price: f64) -> f64 {
189        let initial_value = self.entry_price * self.entry_quantity;
190        let current_value = self.current_value(current_price);
191
192        let gross_pnl = match self.side {
193            PositionSide::Long => current_value - initial_value,
194            // For shorts: `current_value` is negative `-(quantity * price)`.
195            // Initial value is assumed positive margin equivalent, so PnL = expected margin - cost to cover.
196            // Wait, current_value for short is `-(self.quantity * current_price)`.
197            // The cost to open was `entry_value` = `entry_price * entry_quantity`.
198            // Better to be explicit:
199            PositionSide::Short => {
200                (self.entry_price * self.entry_quantity) - (current_price * self.quantity)
201            }
202        };
203        gross_pnl - self.entry_commission - self.entry_transaction_tax + self.unreinvested_dividends
204    }
205
206    /// Calculate unrealized return percentage
207    pub fn unrealized_return_pct(&self, current_price: f64) -> f64 {
208        let entry_value = self.entry_price * self.entry_quantity;
209        if entry_value == 0.0 {
210            return 0.0;
211        }
212        let pnl = self.unrealized_pnl(current_price);
213        (pnl / entry_value) * 100.0
214    }
215
216    /// Check if position is profitable at given price
217    pub fn is_profitable(&self, current_price: f64) -> bool {
218        self.unrealized_pnl(current_price) > 0.0
219    }
220
221    /// Check if this is a long position
222    pub fn is_long(&self) -> bool {
223        matches!(self.side, PositionSide::Long)
224    }
225
226    /// Check if this is a short position
227    pub fn is_short(&self) -> bool {
228        matches!(self.side, PositionSide::Short)
229    }
230
231    /// Credit dividend cashflow to this position.
232    ///
233    /// `income` **must be pre-signed by the caller**:
234    /// - Long positions *receive* dividends → pass `+per_share × quantity`
235    /// - Short positions *owe* dividends to the stock lender → pass
236    ///   `-(per_share × quantity)`
237    ///
238    /// The engine's `credit_dividends` helper handles this negation
239    /// automatically.  Passing an unsigned (always-positive) value to a short
240    /// position would incorrectly record dividend *income* instead of a
241    /// *liability*.
242    ///
243    /// When `reinvest` is `true`, only **positive** `income` is reinvested
244    /// into additional units (long-side reinvestment only).
245    pub fn credit_dividend(&mut self, income: f64, close_price: f64, reinvest: bool) {
246        if reinvest && income > 0.0 && close_price > 0.0 {
247            self.quantity += income / close_price;
248        } else {
249            self.unreinvested_dividends += income;
250        }
251        self.dividend_income += income;
252    }
253
254    /// Add shares to this position (pyramid / scale-in).
255    ///
256    /// Updates the weighted-average `entry_price` and `entry_quantity` to reflect
257    /// the blended cost basis and increments `scale_in_count`. The caller is
258    /// responsible for debiting the entry cost from available cash and for applying
259    /// slippage/spread to `fill_price` before calling this method.
260    ///
261    /// # Arguments
262    ///
263    /// * `fill_price`      – Adjusted entry price for the new shares.
264    /// * `additional_qty`  – Number of shares to add. No-op if `<= 0.0`.
265    /// * `commission`      – Commission paid for this add (already applied to cash).
266    /// * `entry_tax`       – Transaction tax for this add (already applied to cash).
267    pub fn scale_in(
268        &mut self,
269        fill_price: f64,
270        additional_qty: f64,
271        commission: f64,
272        entry_tax: f64,
273    ) {
274        if additional_qty <= 0.0 {
275            return;
276        }
277
278        let old_value = self.entry_price * self.quantity;
279        let new_value = fill_price * additional_qty;
280        let total_qty = self.quantity + additional_qty;
281
282        self.entry_price = (old_value + new_value) / total_qty;
283        self.quantity = total_qty;
284        // Keep entry_quantity in sync so close_with_tax computes the correct cost basis.
285        self.entry_quantity = total_qty;
286        // Track commission and tax in their respective fields for correct proportional
287        // slicing in subsequent partial_close calls.
288        self.entry_commission += commission;
289        self.entry_transaction_tax += entry_tax;
290        self.scale_in_count += 1;
291    }
292
293    /// Partially close this position and return a completed [`Trade`].
294    ///
295    /// Closes `fraction` of the current position quantity, allocating a
296    /// proportional share of accumulated entry costs and dividend income to the
297    /// trade P&L. The remaining position stays open with reduced quantity,
298    /// dividend balances, and entry cost bases.
299    ///
300    /// [`Trade::is_partial`] is `true` for all trades returned by this method.
301    /// For a full close prefer [`Position::close_with_tax`], which sets
302    /// `is_partial = false`. The engine's `scale_out_position` delegates
303    /// `fraction >= 1.0` to `close_position` for exactly this reason.
304    ///
305    /// The caller is responsible for updating cash from the returned trade's
306    /// exit proceeds.
307    ///
308    /// # Arguments
309    ///
310    /// * `fraction`   – Portion of current quantity to close (`0.0..=1.0`).
311    /// * `exit_ts`    – Timestamp of the fill.
312    /// * `exit_price` – Adjusted exit price (after slippage/spread).
313    /// * `commission` – Exit-side commission for this close.
314    /// * `exit_tax`   – Exit-side transaction tax for this close.
315    /// * `signal`     – Signal that triggered the partial exit.
316    #[must_use = "the returned Trade must be used to update cash and record the partial close"]
317    pub fn partial_close(
318        &mut self,
319        fraction: f64,
320        exit_ts: i64,
321        exit_price: f64,
322        commission: f64,
323        exit_tax: f64,
324        signal: Signal,
325    ) -> Trade {
326        let fraction = fraction.clamp(0.0, 1.0);
327        let qty_closed = self.quantity * fraction;
328        let qty_remaining = self.quantity - qty_closed;
329
330        // Proportional dividend income for the closed slice.
331        let div_income = self.dividend_income * fraction;
332        let unreinvested = self.unreinvested_dividends * fraction;
333        let entry_comm_slice = self.entry_commission * fraction;
334        let entry_tax_slice = self.entry_transaction_tax * fraction;
335
336        // Reduce the open position; keep entry_quantity in sync with quantity so
337        // close_with_tax computes the correct cost basis for the remainder.
338        self.quantity = qty_remaining;
339        self.entry_quantity = qty_remaining;
340        self.dividend_income -= div_income;
341        self.unreinvested_dividends -= unreinvested;
342        self.entry_commission -= entry_comm_slice;
343        self.entry_transaction_tax -= entry_tax_slice;
344
345        let gross_pnl = match self.side {
346            PositionSide::Long => (exit_price - self.entry_price) * qty_closed,
347            PositionSide::Short => (self.entry_price - exit_price) * qty_closed,
348        };
349        let partial_commission = entry_comm_slice + commission;
350        let partial_tax = entry_tax_slice + exit_tax;
351
352        let pnl = gross_pnl - partial_commission - partial_tax + unreinvested;
353        let entry_value = self.entry_price * qty_closed;
354        let return_pct = if entry_value > 0.0 {
355            (pnl / entry_value) * 100.0
356        } else {
357            0.0
358        };
359
360        let seq = self.partial_close_count;
361        self.partial_close_count += 1;
362
363        Trade {
364            side: self.side,
365            entry_timestamp: self.entry_timestamp,
366            exit_timestamp: exit_ts,
367            entry_price: self.entry_price,
368            exit_price,
369            quantity: qty_closed,
370            entry_quantity: qty_closed,
371            commission: partial_commission,
372            transaction_tax: partial_tax,
373            pnl,
374            return_pct,
375            dividend_income: div_income,
376            unreinvested_dividends: unreinvested,
377            entry_signal: self.entry_signal.clone(),
378            exit_signal: signal,
379            tags: self.entry_signal.tags.clone(),
380            is_partial: true,
381            scale_sequence: seq,
382        }
383    }
384
385    /// Close this position and create a Trade.
386    ///
387    /// `dividend_income` accumulated during the hold is added to P&L and
388    /// preserved on the returned `Trade` for reporting purposes.
389    pub fn close(
390        self,
391        exit_timestamp: i64,
392        exit_price: f64,
393        exit_commission: f64,
394        exit_signal: Signal,
395    ) -> Trade {
396        self.close_with_tax(
397            exit_timestamp,
398            exit_price,
399            exit_commission,
400            0.0,
401            exit_signal,
402        )
403    }
404
405    /// Close the position, including an exit transaction tax (e.g. on short covers).
406    pub(crate) fn close_with_tax(
407        self,
408        exit_timestamp: i64,
409        exit_price: f64,
410        exit_commission: f64,
411        exit_transaction_tax: f64,
412        exit_signal: Signal,
413    ) -> Trade {
414        let total_commission = self.entry_commission + exit_commission;
415        let total_transaction_tax = self.entry_transaction_tax + exit_transaction_tax;
416
417        let initial_value = self.entry_price * self.entry_quantity;
418        let exit_value = exit_price * self.quantity;
419
420        let gross_pnl = match self.side {
421            PositionSide::Long => exit_value - initial_value,
422            PositionSide::Short => initial_value - exit_value,
423        };
424        let pnl =
425            gross_pnl - total_commission - total_transaction_tax + self.unreinvested_dividends;
426
427        let entry_value = self.entry_price * self.entry_quantity;
428        let return_pct = if entry_value > 0.0 {
429            (pnl / entry_value) * 100.0
430        } else {
431            0.0
432        };
433
434        Trade {
435            side: self.side,
436            entry_timestamp: self.entry_timestamp,
437            exit_timestamp,
438            entry_price: self.entry_price,
439            exit_price,
440            quantity: self.quantity,
441            entry_quantity: self.entry_quantity,
442            commission: total_commission,
443            transaction_tax: total_transaction_tax,
444            pnl,
445            return_pct,
446            dividend_income: self.dividend_income,
447            unreinvested_dividends: self.unreinvested_dividends,
448            tags: self.entry_signal.tags.clone(),
449            entry_signal: self.entry_signal,
450            exit_signal,
451            is_partial: false,
452            scale_sequence: 0,
453        }
454    }
455}
456
457/// A completed trade (closed position)
458#[non_exhaustive]
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct Trade {
461    /// Trade direction
462    pub side: PositionSide,
463
464    /// Entry timestamp
465    pub entry_timestamp: i64,
466
467    /// Exit timestamp
468    pub exit_timestamp: i64,
469
470    /// Entry price
471    pub entry_price: f64,
472
473    /// Exit price
474    pub exit_price: f64,
475
476    /// Number of shares/units at exit
477    pub quantity: f64,
478
479    /// Number of shares/units at entry
480    #[serde(default)]
481    pub entry_quantity: f64,
482
483    /// Total commission paid (entry + exit).
484    pub commission: f64,
485
486    /// Total transaction tax paid (entry + exit).
487    ///
488    /// Non-zero only when [`BacktestConfig::transaction_tax_pct`] is set.
489    /// Deducted from P&L along with commission.
490    ///
491    /// [`BacktestConfig::transaction_tax_pct`]: crate::backtesting::BacktestConfig::transaction_tax_pct
492    #[serde(default)]
493    pub transaction_tax: f64,
494
495    /// Realized P&L (after commission and transaction tax, including any unreinvested dividend income)
496    pub pnl: f64,
497
498    /// Return as percentage
499    pub return_pct: f64,
500
501    /// Dividend income received while this position was open
502    pub dividend_income: f64,
503
504    /// Dividend income that was NOT reinvested (i.e. remains as cash).
505    /// Used internally for correct cash-accounting.
506    #[serde(default)]
507    pub unreinvested_dividends: f64,
508
509    /// Signal that triggered entry
510    pub entry_signal: Signal,
511
512    /// Signal that triggered exit
513    pub exit_signal: Signal,
514
515    /// Tags inherited from the entry signal for subgroup analysis.
516    ///
517    /// Populated automatically from [`Signal::tags`] when the position closes.
518    /// Query via `BacktestResult::trades_by_tag` and `metrics_by_tag`.
519    ///
520    /// Placed last so that JSON field order is consistent with [`Signal::tags`]
521    /// (both appear after all other fields).
522    #[serde(default)]
523    pub tags: Vec<String>,
524
525    /// `true` when this trade represents a **partial** close of a position
526    /// (generated by [`Position::partial_close`] / a `ScaleOut` signal).
527    ///
528    /// `false` for full position closes and for the final close of a scaled
529    /// position.
530    #[serde(default)]
531    pub is_partial: bool,
532
533    /// Zero-based sequence number among the partial closes of this position.
534    ///
535    /// For the first `ScaleOut` on a given position this is `0`, the second is
536    /// `1`, etc. Always `0` for non-partial trades.
537    #[serde(default)]
538    pub scale_sequence: usize,
539}
540
541impl Trade {
542    /// Check if trade was profitable
543    pub fn is_profitable(&self) -> bool {
544        self.pnl > 0.0
545    }
546
547    /// Check if trade was a loss
548    pub fn is_loss(&self) -> bool {
549        self.pnl < 0.0
550    }
551
552    /// Check if this was a long trade
553    pub fn is_long(&self) -> bool {
554        matches!(self.side, PositionSide::Long)
555    }
556
557    /// Check if this was a short trade
558    pub fn is_short(&self) -> bool {
559        matches!(self.side, PositionSide::Short)
560    }
561
562    /// Get trade duration in seconds
563    pub fn duration_secs(&self) -> i64 {
564        self.exit_timestamp - self.entry_timestamp
565    }
566
567    /// Get entry value (cost basis)
568    pub fn entry_value(&self) -> f64 {
569        self.entry_price * self.entry_quantity
570    }
571
572    /// Get exit value
573    pub fn exit_value(&self) -> f64 {
574        self.exit_price * self.quantity
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    fn make_entry_signal() -> Signal {
583        Signal::long(1000, 100.0)
584    }
585
586    fn make_exit_signal() -> Signal {
587        Signal::exit(2000, 110.0)
588    }
589
590    #[test]
591    fn test_position_long_profit() {
592        let pos = Position::new(
593            PositionSide::Long,
594            1000,
595            100.0,
596            10.0,
597            1.0, // $1 commission
598            make_entry_signal(),
599        );
600
601        // Price goes up to 110
602        let pnl = pos.unrealized_pnl(110.0);
603        // (110 - 100) * 10 - 1 = 99
604        assert!((pnl - 99.0).abs() < 0.01);
605        assert!(pos.is_profitable(110.0));
606    }
607
608    #[test]
609    fn test_position_long_loss() {
610        let pos = Position::new(
611            PositionSide::Long,
612            1000,
613            100.0,
614            10.0,
615            1.0,
616            make_entry_signal(),
617        );
618
619        // Price goes down to 90
620        let pnl = pos.unrealized_pnl(90.0);
621        // (90 - 100) * 10 - 1 = -101
622        assert!((pnl - (-101.0)).abs() < 0.01);
623        assert!(!pos.is_profitable(90.0));
624    }
625
626    #[test]
627    fn test_position_short_profit() {
628        let pos = Position::new(
629            PositionSide::Short,
630            1000,
631            100.0,
632            10.0,
633            1.0,
634            Signal::short(1000, 100.0),
635        );
636
637        // Price goes down to 90 (profit for short)
638        let pnl = pos.unrealized_pnl(90.0);
639        // (100 - 90) * 10 - 1 = 99
640        assert!((pnl - 99.0).abs() < 0.01);
641        assert!(pos.is_profitable(90.0));
642    }
643
644    #[test]
645    fn test_position_close_to_trade() {
646        let pos = Position::new(
647            PositionSide::Long,
648            1000,
649            100.0,
650            10.0,
651            1.0,
652            make_entry_signal(),
653        );
654
655        let trade = pos.close(2000, 110.0, 1.0, make_exit_signal());
656
657        assert_eq!(trade.entry_price, 100.0);
658        assert_eq!(trade.exit_price, 110.0);
659        assert_eq!(trade.quantity, 10.0);
660        assert_eq!(trade.commission, 2.0); // 1 + 1
661        // (110 - 100) * 10 - 2 = 98
662        assert!((trade.pnl - 98.0).abs() < 0.01);
663        assert!(trade.is_profitable());
664        assert!(trade.is_long());
665        assert_eq!(trade.duration_secs(), 1000);
666    }
667
668    #[test]
669    fn test_credit_dividend_no_reinvest() {
670        let mut pos = Position::new(
671            PositionSide::Long,
672            1000,
673            100.0,
674            10.0,
675            0.0,
676            make_entry_signal(),
677        );
678        pos.credit_dividend(5.0, 110.0, false);
679        assert!((pos.dividend_income - 5.0).abs() < 1e-10);
680        assert!((pos.quantity - 10.0).abs() < 1e-10); // unchanged
681    }
682
683    #[test]
684    fn test_credit_dividend_reinvest() {
685        let mut pos = Position::new(
686            PositionSide::Long,
687            1000,
688            100.0,
689            10.0,
690            0.0,
691            make_entry_signal(),
692        );
693        // $1/share × 10 shares = $10 income; reinvested at $110 → 10/110 ≈ 0.0909 new shares
694        pos.credit_dividend(10.0, 110.0, true);
695        assert!((pos.dividend_income - 10.0).abs() < 1e-10);
696        let expected_qty = 10.0 + 10.0 / 110.0;
697        assert!((pos.quantity - expected_qty).abs() < 1e-10);
698    }
699
700    #[test]
701    fn test_credit_dividend_zero_price_no_reinvest() {
702        let mut pos = Position::new(
703            PositionSide::Long,
704            1000,
705            100.0,
706            10.0,
707            0.0,
708            make_entry_signal(),
709        );
710        // reinvest=true but price=0.0 → should not divide by zero
711        pos.credit_dividend(5.0, 0.0, true);
712        assert!((pos.dividend_income - 5.0).abs() < 1e-10);
713        assert!((pos.quantity - 10.0).abs() < 1e-10); // quantity unchanged
714    }
715
716    #[test]
717    fn test_credit_dividend_short_is_negative_and_not_reinvested() {
718        let mut pos = Position::new(
719            PositionSide::Short,
720            1000,
721            100.0,
722            10.0,
723            0.0,
724            make_entry_signal(),
725        );
726
727        // Short positions pay dividends (negative cashflow).
728        pos.credit_dividend(-5.0, 110.0, true);
729
730        assert!((pos.dividend_income + 5.0).abs() < 1e-10);
731        assert!((pos.quantity - 10.0).abs() < 1e-10);
732    }
733
734    #[test]
735    fn test_trade_return_pct() {
736        let pos = Position::new(
737            PositionSide::Long,
738            1000,
739            100.0,
740            10.0,
741            0.0,
742            make_entry_signal(),
743        );
744
745        let trade = pos.close(2000, 110.0, 0.0, make_exit_signal());
746
747        // Entry value = 1000, PnL = 100, return = 10%
748        assert!((trade.return_pct - 10.0).abs() < 0.01);
749    }
750
751    // ── scale_in ─────────────────────────────────────────────────────────────
752
753    #[test]
754    fn test_scale_in_updates_weighted_avg_price() {
755        // Entry: 10 shares @ $100 → entry_price = $100
756        let mut pos = Position::new(
757            PositionSide::Long,
758            1000,
759            100.0,
760            10.0,
761            0.0,
762            make_entry_signal(),
763        );
764
765        // Scale in: 10 more shares @ $120
766        pos.scale_in(120.0, 10.0, 0.0, 0.0);
767
768        // Weighted avg = (100*10 + 120*10) / 20 = 2200/20 = $110
769        assert!((pos.entry_price - 110.0).abs() < 1e-10);
770        assert!((pos.quantity - 20.0).abs() < 1e-10);
771        // entry_quantity must stay in sync for close_with_tax cost-basis arithmetic.
772        assert!((pos.entry_quantity - 20.0).abs() < 1e-10);
773        assert_eq!(pos.scale_in_count, 1);
774    }
775
776    #[test]
777    fn test_scale_in_commission_accumulated() {
778        let mut pos = Position::new(
779            PositionSide::Long,
780            1000,
781            100.0,
782            10.0,
783            2.0, // initial commission
784            make_entry_signal(),
785        );
786
787        pos.scale_in(110.0, 5.0, 1.5, 0.25); // commission=1.5, tax=0.25
788
789        // Commission and tax stored in separate fields (not conflated).
790        assert!((pos.entry_commission - 3.5).abs() < 1e-10); // 2.0 + 1.5
791        assert!((pos.entry_transaction_tax - 0.25).abs() < 1e-10); // 0.0 + 0.25
792    }
793
794    #[test]
795    fn test_scale_in_multiple_tranches() {
796        let mut pos = Position::new(
797            PositionSide::Long,
798            1000,
799            100.0,
800            10.0,
801            0.0,
802            make_entry_signal(),
803        );
804
805        pos.scale_in(110.0, 10.0, 0.0, 0.0); // avg = (1000+1100)/20 = 105
806        pos.scale_in(120.0, 10.0, 0.0, 0.0); // avg = (2100+1200)/30 = 110
807
808        assert!((pos.entry_price - 110.0).abs() < 1e-10);
809        assert!((pos.quantity - 30.0).abs() < 1e-10);
810        assert_eq!(pos.scale_in_count, 2);
811    }
812
813    // ── partial_close ─────────────────────────────────────────────────────────
814
815    #[test]
816    fn test_partial_close_reduces_quantity() {
817        let mut pos = Position::new(
818            PositionSide::Long,
819            1000,
820            100.0,
821            10.0,
822            0.0,
823            make_entry_signal(),
824        );
825
826        let trade = pos.partial_close(0.5, 2000, 110.0, 0.0, 0.0, make_exit_signal());
827
828        // 50% of 10 shares closed = 5 shares remaining
829        assert!((pos.quantity - 5.0).abs() < 1e-10);
830        // entry_quantity must track quantity for close_with_tax cost-basis arithmetic.
831        assert!((pos.entry_quantity - 5.0).abs() < 1e-10);
832        assert!((trade.quantity - 5.0).abs() < 1e-10);
833        assert!(trade.is_partial);
834        assert_eq!(trade.scale_sequence, 0);
835    }
836
837    #[test]
838    fn test_partial_close_pnl_is_proportional() {
839        let mut pos = Position::new(
840            PositionSide::Long,
841            1000,
842            100.0,
843            10.0,
844            0.0,
845            make_entry_signal(),
846        );
847
848        // Close 50% at $120 → closed 5 shares, gross PnL = (120-100)*5 = $100
849        // return_pct = pnl / (entry_price * qty_closed) = 100 / 500 = 20%
850        let trade = pos.partial_close(0.5, 2000, 120.0, 0.0, 0.0, make_exit_signal());
851
852        assert!((trade.pnl - 100.0).abs() < 1e-10);
853        assert!((trade.return_pct - 20.0).abs() < 0.01);
854    }
855
856    #[test]
857    fn test_partial_close_sequence_increments() {
858        let mut pos = Position::new(
859            PositionSide::Long,
860            1000,
861            100.0,
862            20.0,
863            0.0,
864            make_entry_signal(),
865        );
866
867        let t1 = pos.partial_close(0.25, 1000, 110.0, 0.0, 0.0, make_exit_signal());
868        let t2 = pos.partial_close(0.25, 2000, 115.0, 0.0, 0.0, make_exit_signal());
869
870        assert_eq!(t1.scale_sequence, 0);
871        assert_eq!(t2.scale_sequence, 1);
872        assert!(t1.is_partial);
873        assert!(t2.is_partial);
874        // After two 25% closes: 20 * 0.75 * 0.75 = 11.25 remaining
875        assert!((pos.quantity - 11.25).abs() < 1e-10);
876    }
877
878    #[test]
879    fn test_partial_close_full_fraction_closes_position() {
880        let mut pos = Position::new(
881            PositionSide::Long,
882            1000,
883            100.0,
884            10.0,
885            0.0,
886            make_entry_signal(),
887        );
888
889        // fraction = 1.0 → qty_remaining = 0
890        let trade = pos.partial_close(1.0, 2000, 110.0, 0.0, 0.0, make_exit_signal());
891
892        assert!((pos.quantity - 0.0).abs() < 1e-10);
893        assert!((trade.quantity - 10.0).abs() < 1e-10);
894        assert!(trade.is_partial);
895    }
896
897    #[test]
898    fn test_close_after_scale_in_uses_correct_cost_basis() {
899        // Tests for entry_quantity not updated after scale_in causing
900        // close_with_tax to compute gross_pnl = exit_value - (avg_price × orig_qty)
901        // instead of exit_value - (avg_price × total_qty).
902        //
903        // Enter 10 @ $100, scale_in 10 @ $120 → avg=$110, total=20
904        // Exit all 20 @ $115 with no commission.
905        // Expected gross_pnl = (115 − 110) × 20 = $100.
906        let mut pos = Position::new(
907            PositionSide::Long,
908            1000,
909            100.0,
910            10.0,
911            0.0,
912            make_entry_signal(),
913        );
914
915        pos.scale_in(120.0, 10.0, 0.0, 0.0);
916        assert!((pos.entry_price - 110.0).abs() < 1e-10);
917
918        let trade = pos.close(2000, 115.0, 0.0, make_exit_signal());
919
920        // gross_pnl = (115 - 110) * 20 = 100
921        assert!(
922            (trade.pnl - 100.0).abs() < 1e-6,
923            "expected pnl=100.0, got {:.6} (entry_quantity not synced after scale_in?)",
924            trade.pnl
925        );
926        assert!((trade.quantity - 20.0).abs() < 1e-10);
927        assert!(!trade.is_partial);
928    }
929
930    #[test]
931    fn test_close_after_partial_close_uses_remaining_cost_basis() {
932        // Tests for entry_quantity not updated after partial_close causing
933        // the final close_with_tax to use the full original entry_quantity.
934        //
935        // Enter 20 @ $100, partial_close 50% @ $110, final close @ $120.
936        // After partial: 10 shares remain, entry_quantity should = 10.
937        // Expected final gross_pnl = (120 − 100) × 10 = $200.
938        let mut pos = Position::new(
939            PositionSide::Long,
940            1000,
941            100.0,
942            20.0,
943            0.0,
944            make_entry_signal(),
945        );
946
947        let _partial = pos.partial_close(0.5, 1500, 110.0, 0.0, 0.0, make_exit_signal());
948        assert!((pos.entry_quantity - 10.0).abs() < 1e-10);
949
950        let trade = pos.close(2000, 120.0, 0.0, make_exit_signal());
951
952        assert!(
953            (trade.pnl - 200.0).abs() < 1e-6,
954            "expected pnl=200.0, got {:.6} (entry_quantity not synced after partial_close?)",
955            trade.pnl
956        );
957        assert!(!trade.is_partial);
958    }
959
960    #[test]
961    fn test_scale_in_then_partial_close_full_exit() {
962        // Pyramid: buy 10@100, add 10@120, exit half, exit rest
963        let mut pos = Position::new(
964            PositionSide::Long,
965            1000,
966            100.0,
967            10.0,
968            0.0,
969            make_entry_signal(),
970        );
971
972        pos.scale_in(120.0, 10.0, 0.0, 0.0);
973        // Entry price = (100*10 + 120*10) / 20 = 110, qty = 20
974
975        // Scale out 50% at $130
976        let partial_trade = pos.partial_close(0.5, 2000, 130.0, 0.0, 0.0, make_exit_signal());
977        // closed 10 shares; gross PnL = (130 - 110) * 10 = $200
978        assert!((partial_trade.pnl - 200.0).abs() < 1e-10);
979        assert!((pos.quantity - 10.0).abs() < 1e-10);
980
981        // Full close at $140
982        let final_trade = pos.close(3000, 140.0, 0.0, make_exit_signal());
983        // closed 10 shares; gross PnL = (140 - 110) * 10 = $300
984        assert!((final_trade.pnl - 300.0).abs() < 1e-10);
985        assert!(!final_trade.is_partial);
986    }
987}