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