Skip to main content

pine_broker/
broker.rs

1//! The accounting half of the simulator: order book, position, trades, equity.
2//!
3//! Venue-independent — the only pluggable part is the [`FillModel`]. Trades are
4//! paired first-in-first-out: closing reduces the oldest open lots first, which
5//! is what `strategy.closedtrades` reports.
6
7use crate::{
8    Broker, Commission, Direction, Exit, FillModel, OcaType, Order, OrderKind, Position, Sizing,
9    Trade,
10};
11use pine_core::Bar;
12use std::collections::HashMap;
13
14pub struct BarBroker<F: FillModel> {
15    fills: F,
16    commission: Option<Commission>,
17    /// How an order without an explicit `qty` is sized.
18    sizing: Sizing,
19    /// Maximum concurrent open entries in one direction (`pyramiding`).
20    max_entries: usize,
21    /// Tick size, so `strategy.exit` distances given in ticks become prices.
22    mintick: f64,
23    /// Starting capital, kept so `strategy.netprofit` can be derived from the
24    /// equity identity.
25    initial: f64,
26    /// Cash balance; commission is charged here, price P&L in `realized`.
27    cash: f64,
28    realized: f64,
29
30    /// Pending orders keyed by id, so a resubmission replaces rather than
31    /// stacks — matching Pine's order commands.
32    pending: HashMap<String, Order>,
33    /// Insertion order of `pending`, so fills happen in submission order.
34    order: Vec<String>,
35    /// Exit brackets, evaluated each bar after the pending orders.
36    exits: Vec<Exit>,
37
38    open: Vec<Trade>,
39    closed: Vec<Trade>,
40
41    bar_index: u64,
42}
43
44impl<F: FillModel> BarBroker<F> {
45    pub fn new(fills: F, initial_capital: f64) -> Self {
46        Self {
47            fills,
48            commission: None,
49            sizing: Sizing::Contracts(1.0),
50            max_entries: 1,
51            mintick: 0.0,
52            initial: initial_capital,
53            cash: initial_capital,
54            realized: 0.0,
55            pending: HashMap::new(),
56            order: Vec::new(),
57            exits: Vec::new(),
58            open: Vec::new(),
59            closed: Vec::new(),
60            bar_index: 0,
61        }
62    }
63
64    pub fn with_commission(mut self, commission: Commission) -> Self {
65        self.commission = Some(commission);
66        self
67    }
68
69    pub fn with_sizing(mut self, sizing: Sizing) -> Self {
70        self.sizing = sizing;
71        self
72    }
73
74    pub fn with_mintick(mut self, mintick: f64) -> Self {
75        self.mintick = mintick;
76        self
77    }
78
79    pub fn with_pyramiding(mut self, pyramiding: usize) -> Self {
80        self.max_entries = pyramiding.max(1);
81        self
82    }
83
84    fn open_lots_toward(&self, direction: Direction) -> usize {
85        self.open
86            .iter()
87            .filter(|t| t.size.signum() == direction.sign())
88            .count()
89    }
90
91    fn net_size(&self) -> f64 {
92        self.open.iter().map(|t| t.size).sum()
93    }
94
95    /// Net signed size of the lots matching `target` (all lots if `None`).
96    fn matched_size(&self, target: Option<&str>) -> f64 {
97        self.open
98            .iter()
99            .filter(|t| target.is_none_or(|id| t.entry_id == id))
100            .map(|t| t.size)
101            .sum()
102    }
103
104    /// Average entry price of the lots matching `target`, weighted by size.
105    fn matched_avg(&self, target: Option<&str>) -> f64 {
106        let (value, qty): (f64, f64) = self
107            .open
108            .iter()
109            .filter(|t| target.is_none_or(|id| t.entry_id == id))
110            .fold((0.0, 0.0), |(v, q), t| {
111                (v + t.entry_price * t.size, q + t.size)
112            });
113        if qty == 0.0 {
114            0.0
115        } else {
116            value / qty
117        }
118    }
119
120    fn commission_on(&self, qty: f64, price: f64) -> f64 {
121        self.commission.map_or(0.0, |c| c.charge(qty, price))
122    }
123
124    /// Apply a fill of `signed_qty` contracts at `price`: close opposing lots
125    /// first (FIFO), then open a lot with whatever direction remains. `target`
126    /// restricts which lots may be closed to those from that entry — a reducing
127    /// order leaves the remainder unopened, so it only ever shrinks them.
128    fn apply_fill(&mut self, mut signed_qty: f64, price: f64, id: &str, target: Option<&str>) {
129        // This fill's commission, split across the portions it closes and opens
130        // by contract count, so each closed trade carries its exit commission
131        // and each opened lot its entry commission.
132        let order_qty_abs = signed_qty.abs();
133        let order_commission = self.commission_on(signed_qty, price);
134        self.cash -= order_commission;
135
136        // Close opposing open lots, oldest first. A partial close records a
137        // closed trade for the exited portion and leaves the rest open, as Pine
138        // does, so `strategy.closedtrades` counts partial exits too.
139        while signed_qty != 0.0 {
140            let Some(index) = self.open.iter().position(|t| {
141                t.size.signum() != signed_qty.signum()
142                    && target.is_none_or(|want| t.entry_id == want)
143            }) else {
144                break;
145            };
146
147            let lot = &self.open[index];
148            let closed = signed_qty.abs().min(lot.size.abs());
149            let closed_signed = closed * lot.size.signum();
150            let entry_share = lot.commission * closed / lot.size.abs();
151            let exit_share = order_commission * closed / order_qty_abs;
152
153            self.realized += (price - lot.entry_price) * closed_signed;
154            signed_qty += closed_signed; // moves signed_qty toward zero
155
156            self.closed.push(Trade {
157                entry_id: lot.entry_id.clone(),
158                size: closed_signed,
159                entry_price: lot.entry_price,
160                entry_bar: lot.entry_bar,
161                exit_price: Some(price),
162                exit_bar: Some(self.bar_index),
163                commission: entry_share + exit_share,
164            });
165
166            let lot = &mut self.open[index];
167            lot.size -= closed_signed;
168            lot.commission -= entry_share;
169            if lot.size == 0.0 {
170                self.open.remove(index);
171            }
172        }
173
174        // Whatever quantity is left opens a new lot — but only for an entry. A
175        // targeted reduce never flips into a new position, so it stops here.
176        if signed_qty != 0.0 && target.is_none() {
177            self.open.push(Trade {
178                entry_id: id.to_string(),
179                size: signed_qty,
180                entry_price: price,
181                entry_bar: self.bar_index,
182                exit_price: None,
183                exit_bar: None,
184                commission: order_commission * signed_qty.abs() / order_qty_abs,
185            });
186        }
187    }
188
189    /// The signed quantity an order actually trades at `price`, resolving the
190    /// default quantity and, for a reducing or reversing order, the position.
191    fn resolve_qty(&self, order: &Order, price: f64) -> f64 {
192        if order.reduce_only {
193            // Never flips: close at most the matched position. An explicit qty
194            // wins; otherwise `qty_percent` closes that share, and with neither
195            // `strategy.close` shuts the whole position.
196            let pool = self.matched_size(order.close_target.as_deref());
197            let closable = match (order.qty, order.qty_percent) {
198                (Some(q), _) => pool.abs().min(q.abs()),
199                (None, Some(pct)) => pool.abs() * (pct / 100.0),
200                (None, None) => pool.abs(),
201            };
202            return -pool.signum() * closable;
203        }
204
205        let requested = match order.qty {
206            Some(q) => q.abs(),
207            None => {
208                // Pine sizes a default-qty order from the close of the bar it
209                // was generated on; fall back to the fill price if unstamped.
210                let sizing_price = order.sizing_price.unwrap_or(price);
211                self.sizing
212                    .contracts(sizing_price, self.equity(sizing_price))
213            }
214        };
215        let net = self.net_size();
216        let want = order.direction.sign() * requested;
217        if order.reverses && net != 0.0 && net.signum() != order.direction.sign() {
218            // Close the opposite position and open `requested` the other way.
219            want - net
220        } else {
221            want
222        }
223    }
224
225    /// Evaluate every exit bracket against `bar`: for a matched position, fill
226    /// the stop-loss, trailing stop or take-profit if the bar reaches it (a stop
227    /// wins when several do, the conservative assumption), then retire it.
228    fn evaluate_exits(&mut self, bar: &Bar) {
229        let ids: Vec<String> = self.exits.iter().map(|e| e.id.clone()).collect();
230        for id in ids {
231            let Some(exit) = self.exits.iter().find(|e| e.id == id).cloned() else {
232                continue;
233            };
234            let target = exit.from_entry.as_deref();
235            let pos = self.matched_size(target);
236            if pos == 0.0 {
237                continue; // Nothing to protect yet (the entry has not filled).
238            }
239            let dir = pos.signum();
240            let entry_avg = self.matched_avg(target);
241            let mintick = self.mintick;
242            let exit_dir = if dir > 0.0 {
243                Direction::Short
244            } else {
245                Direction::Long
246            };
247
248            // Take-profit and stop-loss prices, from an explicit level or a tick
249            // distance either side of the entry.
250            let tp = exit
251                .limit
252                .or_else(|| exit.profit_ticks.map(|t| entry_avg + dir * t * mintick));
253            let sl = exit
254                .stop
255                .or_else(|| exit.loss_ticks.map(|t| entry_avg - dir * t * mintick));
256
257            // Arm and advance the trailing stop with this bar: the reference
258            // trails "each time the trade's profit reaches a new high", so it
259            // follows the peak within the bar and can fill the same one.
260            let trail_stop = self.advance_trail(&id, dir, entry_avg, bar);
261
262            // A stop wins over the take-profit when a bar reaches both. The
263            // trailing stop fills at its level — price set the peak this bar,
264            // then retraced to the stop.
265            let hit = sl
266                .and_then(|p| self.leg_fill(OrderKind::Stop(p), exit_dir, bar))
267                .or_else(|| {
268                    trail_stop.filter(|&ts| {
269                        if dir > 0.0 {
270                            bar.low <= ts
271                        } else {
272                            bar.high >= ts
273                        }
274                    })
275                })
276                .or_else(|| tp.and_then(|p| self.leg_fill(OrderKind::Limit(p), exit_dir, bar)));
277
278            if let Some(price) = hit {
279                let requested = match (exit.qty, exit.qty_percent) {
280                    (Some(q), _) => pos.abs().min(q.abs()),
281                    (None, Some(pct)) => pos.abs() * (pct / 100.0),
282                    (None, None) => pos.abs(),
283                };
284                self.apply_fill(-dir * requested, price, &exit.id, target);
285                self.exits.retain(|e| e.id != id);
286            }
287        }
288    }
289
290    /// Arm a trailing exit and advance its peak from `bar`, returning the stop
291    /// price if it is active — `trail_offset` ticks behind the best price seen.
292    fn advance_trail(&mut self, id: &str, dir: f64, entry_avg: f64, bar: &Bar) -> Option<f64> {
293        let mintick = self.mintick;
294        let exit = self.exits.iter_mut().find(|e| e.id == id)?;
295        let offset = exit.trail_offset?;
296        let bar_best = if dir > 0.0 { bar.high } else { bar.low };
297
298        if !exit.activated {
299            let level = exit
300                .trail_price
301                .or_else(|| exit.trail_points.map(|pts| entry_avg + dir * pts * mintick));
302            if let Some(level) = level {
303                exit.activated = if dir > 0.0 {
304                    bar.high >= level
305                } else {
306                    bar.low <= level
307                };
308            }
309        }
310        if !exit.activated {
311            return None;
312        }
313
314        exit.peak = Some(match exit.peak {
315            Some(pk) if dir > 0.0 => pk.max(bar_best),
316            Some(pk) => pk.min(bar_best),
317            None => bar_best,
318        });
319        exit.peak.map(|pk| pk - dir * offset * mintick)
320    }
321
322    /// The fill price of one exit leg against `bar`, or `None` if unreached.
323    fn leg_fill(&self, kind: OrderKind, direction: Direction, bar: &Bar) -> Option<f64> {
324        let leg = Order {
325            kind,
326            ..Order::market("", direction, None)
327        };
328        self.fills.fill(&leg, bar)
329    }
330
331    /// Whether an entry order is blocked by the pyramiding limit: it would add a
332    /// new lot to an already-full stack on its own side.
333    fn pyramiding_blocks(&self, order: &Order) -> bool {
334        if order.reduce_only || !order.reverses {
335            return false; // Only `strategy.entry` obeys pyramiding.
336        }
337        let net = self.net_size();
338        let same_side = net != 0.0 && net.signum() == order.direction.sign();
339        same_side && self.open_lots_toward(order.direction) >= self.max_entries
340    }
341
342    /// Apply an OCA group's effect after `filled` executes: cancel the group's
343    /// other unfilled orders, or reduce them by the filled size.
344    fn apply_oca(&mut self, filled: &Order, filled_qty: f64) {
345        let Some(group) = filled.oca_name.clone() else {
346            return;
347        };
348        if filled.oca_type == OcaType::None {
349            return;
350        }
351        let siblings: Vec<String> = self
352            .pending
353            .values()
354            .filter(|o| o.id != filled.id && o.oca_name.as_deref() == Some(group.as_str()))
355            .map(|o| o.id.clone())
356            .collect();
357        for id in siblings {
358            match filled.oca_type {
359                OcaType::Cancel => {
360                    self.pending.remove(&id);
361                    self.order.retain(|o| o != &id);
362                }
363                OcaType::Reduce => {
364                    if let Some(o) = self.pending.get_mut(&id) {
365                        // Shrink by the filled size; a non-positive remainder
366                        // cancels the order outright.
367                        let base = o.qty.unwrap_or(filled_qty.abs());
368                        let left = base - filled_qty.abs();
369                        if left > 0.0 {
370                            o.qty = Some(left);
371                        } else {
372                            self.pending.remove(&id);
373                            self.order.retain(|o| o != &id);
374                        }
375                    }
376                }
377                OcaType::None => {}
378            }
379        }
380    }
381}
382
383impl<F: FillModel> Broker for BarBroker<F> {
384    fn submit(&mut self, order: Order) {
385        if !self.pending.contains_key(&order.id) {
386            self.order.push(order.id.clone());
387        }
388        self.pending.insert(order.id.clone(), order);
389    }
390
391    fn submit_exit(&mut self, mut exit: Exit) {
392        if let Some(slot) = self.exits.iter_mut().find(|e| e.id == exit.id) {
393            // Re-submitting the same exit each bar must not restart a trailing
394            // stop, so carry its runtime state onto the replacement.
395            exit.activated = slot.activated;
396            exit.peak = slot.peak;
397            *slot = exit;
398        } else {
399            self.exits.push(exit);
400        }
401    }
402
403    fn cancel(&mut self, id: &str) {
404        if self.pending.remove(id).is_some() {
405            self.order.retain(|o| o != id);
406        }
407        self.exits.retain(|e| e.id != id);
408    }
409
410    fn cancel_all(&mut self) {
411        self.pending.clear();
412        self.order.clear();
413        self.exits.clear();
414    }
415
416    fn advance(&mut self, bar: &Bar) {
417        self.bar_index = bar.index;
418
419        // Fill in submission order; a filled order leaves the book.
420        let ids: Vec<String> = self.order.clone();
421        for id in ids {
422            let Some(order) = self.pending.get(&id).cloned() else {
423                continue;
424            };
425            if self.pyramiding_blocks(&order) {
426                // The stack is full: drop the entry, as Pine rejects it.
427                self.pending.remove(&id);
428                self.order.retain(|o| o != &id);
429                continue;
430            }
431            if let Some(price) = self.fills.fill(&order, bar) {
432                let qty = self.resolve_qty(&order, price);
433                if qty != 0.0 {
434                    self.apply_fill(qty, price, &order.id, order.close_target.as_deref());
435                    self.apply_oca(&order, qty);
436                }
437                self.pending.remove(&id);
438                self.order.retain(|o| o != &id);
439            }
440        }
441
442        // Then the protective exits, against the position those fills produced.
443        self.evaluate_exits(bar);
444    }
445
446    fn position(&self) -> Position {
447        let size = self.net_size();
448        if size == 0.0 {
449            return Position::default();
450        }
451        // Average price weighted over the open lots on the net side.
452        let (value, qty): (f64, f64) = self
453            .open
454            .iter()
455            .filter(|t| t.size.signum() == size.signum())
456            .fold((0.0, 0.0), |(v, q), t| {
457                (v + t.entry_price * t.size, q + t.size)
458            });
459        Position {
460            size,
461            avg_price: if qty == 0.0 { 0.0 } else { value / qty },
462        }
463    }
464
465    fn initial_capital(&self) -> f64 {
466        self.initial
467    }
468
469    fn equity(&self, price: f64) -> f64 {
470        let unrealized: f64 = self
471            .open
472            .iter()
473            .map(|t| (price - t.entry_price) * t.size)
474            .sum();
475        self.cash + self.realized + unrealized
476    }
477
478    fn open_trades(&self) -> Vec<&Trade> {
479        self.open.iter().collect()
480    }
481
482    fn closed_trades(&self) -> &[Trade] {
483        &self.closed
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::{Commission, Direction, OrderKind, PineFills};
491
492    fn bar(index: u64, open: f64, high: f64, low: f64, close: f64) -> Bar {
493        Bar {
494            open,
495            high,
496            low,
497            close,
498            volume: 0.0,
499            index,
500            ..Bar::default()
501        }
502    }
503
504    fn broker() -> BarBroker<PineFills> {
505        BarBroker::new(PineFills::default(), 10_000.0)
506    }
507
508    #[test]
509    fn a_market_entry_fills_at_the_open() {
510        let mut b = broker();
511        b.submit(Order::market("long", Direction::Long, Some(2.0)));
512        b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
513
514        let pos = b.position();
515        assert_eq!(pos.size, 2.0);
516        assert_eq!(pos.avg_price, 100.0);
517        // Marked at 104: two contracts up 4 each.
518        assert_eq!(b.equity(104.0), 10_008.0);
519    }
520
521    #[test]
522    fn a_closed_trade_keeps_its_size_and_profit() {
523        let mut b = broker();
524        b.submit(Order::market("L", Direction::Long, Some(2.0)));
525        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
526        b.submit(Order {
527            reduce_only: true,
528            ..Order::market("L", Direction::Short, None)
529        });
530        b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
531
532        let trade = &b.closed_trades()[0];
533        assert_eq!(trade.size, 2.0);
534        assert_eq!(trade.entry_price, 100.0);
535        assert_eq!(trade.exit_price, Some(110.0));
536        assert_eq!(trade.profit(0.0), 20.0); // (110 - 100) * 2, price ignored once closed
537    }
538
539    #[test]
540    fn closing_realises_profit_and_flattens() {
541        let mut b = broker();
542        b.submit(Order::market("long", Direction::Long, Some(1.0)));
543        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
544
545        b.submit(Order {
546            reduce_only: true,
547            ..Order::market("exit", Direction::Short, Some(1.0))
548        });
549        b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
550
551        assert!(b.position().is_flat());
552        assert_eq!(b.closed_trades().len(), 1);
553        assert_eq!(b.equity(110.0), 10_010.0);
554    }
555
556    #[test]
557    fn an_opposite_entry_reverses_the_position() {
558        let mut b = broker();
559        b.submit(Order::market("a", Direction::Long, Some(5.0)));
560        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
561
562        // Short 5 against long 5 sells 10: closes the long and opens short 5.
563        b.submit(Order::market("b", Direction::Short, Some(5.0)));
564        b.advance(&bar(1, 100.0, 100.0, 100.0, 100.0));
565
566        assert_eq!(b.position().size, -5.0);
567        assert_eq!(b.closed_trades().len(), 1);
568    }
569
570    #[test]
571    fn a_buy_limit_waits_for_the_price() {
572        let mut b = broker();
573        b.submit(Order {
574            kind: OrderKind::Limit(95.0),
575            reverses: false,
576            ..Order::market("buy", Direction::Long, Some(1.0))
577        });
578
579        // Bar stays above 95: no fill.
580        b.advance(&bar(0, 100.0, 101.0, 96.0, 99.0));
581        assert!(b.position().is_flat());
582
583        // Next bar dips to 94: fills at the limit.
584        b.advance(&bar(1, 97.0, 98.0, 94.0, 96.0));
585        assert_eq!(b.position().size, 1.0);
586        assert_eq!(b.position().avg_price, 95.0);
587    }
588
589    #[test]
590    fn commission_reduces_equity() {
591        let mut b = broker().with_commission(Commission::Percent(1.0));
592        b.submit(Order::market("long", Direction::Long, Some(1.0)));
593        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
594
595        // 1% of 100 = 1.0 charged on entry.
596        assert_eq!(b.equity(100.0), 9_999.0);
597    }
598
599    #[test]
600    fn a_take_profit_exit_closes_when_price_reaches_it() {
601        let mut b = broker();
602        b.submit(Order::market("L", Direction::Long, Some(1.0)));
603        b.submit_exit(Exit {
604            limit: Some(110.0),
605            ..Exit::resting("X", Some("L".into()), None, None)
606        });
607
608        // Entry fills at 100; this bar's high 105 does not reach 110.
609        b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
610        assert_eq!(b.position().size, 1.0);
611
612        // Next bar reaches 110: the take-profit sells at 110.
613        b.advance(&bar(1, 106.0, 112.0, 105.0, 108.0));
614        assert!(b.position().is_flat());
615        assert_eq!(b.closed_trades().len(), 1);
616        assert_eq!(b.equity(108.0), 10_010.0); // realised +10
617    }
618
619    #[test]
620    fn a_stop_loss_in_ticks_sits_a_distance_from_the_entry() {
621        // mintick 0.5, loss 4 ticks -> stop 2.0 below a long entry.
622        let fills = PineFills {
623            slippage: 0.0,
624            mintick: 0.5,
625        };
626        let mut b = BarBroker::new(fills, 10_000.0).with_mintick(0.5);
627        b.submit(Order::market("L", Direction::Long, Some(1.0)));
628        b.submit_exit(Exit {
629            loss_ticks: Some(4.0),
630            ..Exit::resting("X", Some("L".into()), None, None)
631        });
632
633        // Entry fills at 100; stop is 98. This bar's low 99 stays above it.
634        b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
635        assert_eq!(b.position().size, 1.0);
636
637        // Next bar dips to 97: the stop sells at 98.
638        b.advance(&bar(1, 100.0, 101.0, 97.0, 99.0));
639        assert!(b.position().is_flat());
640        assert_eq!(b.equity(99.0), 9_998.0); // realised -2
641    }
642
643    #[test]
644    fn close_targets_only_the_named_entry() {
645        let mut b = broker();
646        b.submit(Order {
647            reverses: false,
648            ..Order::market("A", Direction::Long, Some(1.0))
649        });
650        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
651        b.submit(Order {
652            reverses: false,
653            ..Order::market("B", Direction::Long, Some(1.0))
654        });
655        b.advance(&bar(1, 101.0, 101.0, 101.0, 101.0));
656        assert_eq!(b.position().size, 2.0);
657
658        // Close only A: its lot goes, B's remains.
659        b.submit(Order {
660            reduce_only: true,
661            close_target: Some("A".into()),
662            qty: None,
663            ..Order::market("A", Direction::Long, None)
664        });
665        b.advance(&bar(2, 102.0, 102.0, 102.0, 102.0));
666        assert_eq!(b.position().size, 1.0);
667        assert_eq!(b.closed_trades().len(), 1);
668        assert_eq!(b.position().avg_price, 101.0); // B's entry
669    }
670
671    #[test]
672    fn cash_sizing_buys_contracts_worth_the_cash() {
673        let mut b = broker().with_sizing(Sizing::Cash(1_000.0));
674        // No explicit qty: 1000 cash / 100 price = 10 contracts.
675        b.submit(Order::market("L", Direction::Long, None));
676        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
677        assert_eq!(b.position().size, 10.0);
678    }
679
680    #[test]
681    fn percent_of_equity_sizing_scales_with_the_account() {
682        let mut b = broker().with_sizing(Sizing::PercentOfEquity(50.0));
683        // 50% of 10000 equity = 5000, at price 100 = 50 contracts.
684        b.submit(Order::market("L", Direction::Long, None));
685        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
686        assert_eq!(b.position().size, 50.0);
687    }
688
689    #[test]
690    fn pyramiding_caps_entries_in_one_direction() {
691        let mut b = broker().with_pyramiding(2);
692        for (i, id) in ["A", "B", "C"].iter().enumerate() {
693            b.submit(Order {
694                reverses: true,
695                ..Order::market(*id, Direction::Long, Some(1.0))
696            });
697            b.advance(&bar(i as u64, 100.0, 100.0, 100.0, 100.0));
698        }
699        // Two lots allowed; the third entry is rejected.
700        assert_eq!(b.position().size, 2.0);
701    }
702
703    #[test]
704    fn oca_cancel_removes_the_sibling_when_one_fills() {
705        let mut b = broker();
706        // A buy stop at 105 and a buy limit at 95, same OCA group.
707        b.submit(Order {
708            kind: OrderKind::Stop(105.0),
709            oca_name: Some("G".into()),
710            oca_type: OcaType::Cancel,
711            ..Order::market("up", Direction::Long, Some(1.0))
712        });
713        b.submit(Order {
714            kind: OrderKind::Limit(95.0),
715            oca_name: Some("G".into()),
716            oca_type: OcaType::Cancel,
717            ..Order::market("down", Direction::Long, Some(1.0))
718        });
719
720        // This bar reaches both 105 and 95; the first to fill cancels the other.
721        b.advance(&bar(0, 100.0, 106.0, 94.0, 100.0));
722        assert_eq!(b.position().size, 1.0);
723    }
724
725    #[test]
726    fn close_qty_percent_reduces_the_position() {
727        let mut b = broker();
728        b.submit(Order::market("L", Direction::Long, Some(4.0)));
729        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
730
731        // Close 50% of the 4-contract position: 2 remain.
732        b.submit(Order {
733            reduce_only: true,
734            close_target: Some("L".into()),
735            qty_percent: Some(50.0),
736            qty: None,
737            ..Order::market("L", Direction::Long, None)
738        });
739        b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
740        assert_eq!(b.position().size, 2.0);
741
742        // The exited half is a closed trade; the rest stays open.
743        assert_eq!(b.closed_trades().len(), 1);
744        assert_eq!(b.closed_trades()[0].size, 2.0);
745        assert_eq!(b.closed_trades()[0].profit(0.0), 20.0); // (110 - 100) * 2
746        assert_eq!(b.open_trades().len(), 1);
747        assert_eq!(b.open_trades()[0].size, 2.0);
748    }
749
750    #[test]
751    fn a_trailing_stop_follows_the_peak_and_fills_at_its_level() {
752        // mintick 0.5: activation 4 ticks (2.0) above entry, trailing 2 ticks
753        // (1.0) behind the peak.
754        let fills = PineFills {
755            slippage: 0.0,
756            mintick: 0.5,
757        };
758        let mut b = BarBroker::new(fills, 10_000.0).with_mintick(0.5);
759        b.submit(Order::market("L", Direction::Long, Some(1.0)));
760        b.submit_exit(Exit {
761            trail_points: Some(4.0),
762            trail_offset: Some(2.0),
763            ..Exit::resting("X", Some("L".into()), None, None)
764        });
765
766        // Entry at 100; high 101 has not reached the 102 activation level.
767        b.advance(&bar(0, 100.0, 101.0, 99.0, 100.0));
768        assert_eq!(b.position().size, 1.0);
769
770        // Same bar arms and fills: price rallies to 105 (peak, so the stop is at
771        // 104), then the low 101 retraces through it — the stop fills at its own
772        // level, 104, not the earlier open, for a profit of 4.
773        b.advance(&bar(1, 102.0, 105.0, 101.0, 104.0));
774        assert!(b.position().is_flat());
775        assert_eq!(b.closed_trades().len(), 1);
776        assert_eq!(b.equity(104.0), 10_004.0);
777    }
778}