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, EntryFilter, Exit, FillModel, OcaType, Order, OrderKind,
9    Position, RiskRule, RiskType, Sizing, 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    // Risk rules (`strategy.risk.*`), and the running state that enforces them.
44    entry_filter: EntryFilter,
45    max_position_size: Option<f64>,
46    max_drawdown: Option<RiskType>,
47    max_intraday_loss: Option<RiskType>,
48    max_cons_loss_days: Option<u32>,
49    max_intraday_filled_orders: Option<u32>,
50    /// Highest equity seen over the whole run (for `max_drawdown`).
51    peak_equity: f64,
52    /// The current trading day, as a UTC day bucket of `bar.time`.
53    day: Option<i64>,
54    /// Equity entering the current day (for the daily loss/win verdict).
55    day_start_equity: f64,
56    /// Highest equity seen so far today (for `max_intraday_loss`).
57    intraday_peak: f64,
58    /// The previous bar's close equity — the day's final value at a rollover.
59    last_equity: f64,
60    /// Orders filled so far today (for `max_intraday_filled_orders`).
61    filled_today: u32,
62    consecutive_loss_days: u32,
63    /// Halted for the rest of the run (`max_drawdown`, `max_cons_loss_days`).
64    halted: bool,
65    /// The bar the run halted on, once a rest-of-run rule fired.
66    halted_bar: Option<u64>,
67    /// Halted for the rest of the day (`max_intraday_loss`).
68    halted_today: bool,
69}
70
71impl<F: FillModel> BarBroker<F> {
72    pub fn new(fills: F, initial_capital: f64) -> Self {
73        Self {
74            fills,
75            commission: None,
76            sizing: Sizing::Contracts(1.0),
77            max_entries: 1,
78            mintick: 0.0,
79            initial: initial_capital,
80            cash: initial_capital,
81            realized: 0.0,
82            pending: HashMap::new(),
83            order: Vec::new(),
84            exits: Vec::new(),
85            open: Vec::new(),
86            closed: Vec::new(),
87            bar_index: 0,
88            entry_filter: EntryFilter::All,
89            max_position_size: None,
90            max_drawdown: None,
91            max_intraday_loss: None,
92            max_cons_loss_days: None,
93            max_intraday_filled_orders: None,
94            peak_equity: initial_capital,
95            day: None,
96            day_start_equity: initial_capital,
97            intraday_peak: initial_capital,
98            last_equity: initial_capital,
99            filled_today: 0,
100            consecutive_loss_days: 0,
101            halted: false,
102            halted_bar: None,
103            halted_today: false,
104        }
105    }
106
107    pub fn with_commission(mut self, commission: Commission) -> Self {
108        self.commission = Some(commission);
109        self
110    }
111
112    pub fn with_sizing(mut self, sizing: Sizing) -> Self {
113        self.sizing = sizing;
114        self
115    }
116
117    pub fn with_mintick(mut self, mintick: f64) -> Self {
118        self.mintick = mintick;
119        self
120    }
121
122    pub fn with_pyramiding(mut self, pyramiding: usize) -> Self {
123        self.max_entries = pyramiding.max(1);
124        self
125    }
126
127    fn open_lots_toward(&self, direction: Direction) -> usize {
128        self.open
129            .iter()
130            .filter(|t| t.size.signum() == direction.sign())
131            .count()
132    }
133
134    fn net_size(&self) -> f64 {
135        self.open.iter().map(|t| t.size).sum()
136    }
137
138    /// Net signed size of the lots matching `target` (all lots if `None`).
139    fn matched_size(&self, target: Option<&str>) -> f64 {
140        self.open
141            .iter()
142            .filter(|t| target.is_none_or(|id| t.entry_id == id))
143            .map(|t| t.size)
144            .sum()
145    }
146
147    /// Average entry price of the lots matching `target`, weighted by size.
148    fn matched_avg(&self, target: Option<&str>) -> f64 {
149        let (value, qty): (f64, f64) = self
150            .open
151            .iter()
152            .filter(|t| target.is_none_or(|id| t.entry_id == id))
153            .fold((0.0, 0.0), |(v, q), t| {
154                (v + t.entry_price * t.size, q + t.size)
155            });
156        if qty == 0.0 {
157            0.0
158        } else {
159            value / qty
160        }
161    }
162
163    fn commission_on(&self, qty: f64, price: f64) -> f64 {
164        self.commission.map_or(0.0, |c| c.charge(qty, price))
165    }
166
167    /// Apply a fill of `signed_qty` contracts at `price`: close opposing lots
168    /// first (FIFO), then open a lot with whatever direction remains. `target`
169    /// restricts which lots may be closed to those from that entry — a reducing
170    /// order leaves the remainder unopened, so it only ever shrinks them.
171    fn apply_fill(&mut self, mut signed_qty: f64, price: f64, id: &str, target: Option<&str>) {
172        // This fill's commission, split across the portions it closes and opens
173        // by contract count, so each closed trade carries its exit commission
174        // and each opened lot its entry commission.
175        let order_qty_abs = signed_qty.abs();
176        let order_commission = self.commission_on(signed_qty, price);
177        self.cash -= order_commission;
178
179        // Close opposing open lots, oldest first. A partial close records a
180        // closed trade for the exited portion and leaves the rest open, as Pine
181        // does, so `strategy.closedtrades` counts partial exits too.
182        while signed_qty != 0.0 {
183            let Some(index) = self.open.iter().position(|t| {
184                t.size.signum() != signed_qty.signum()
185                    && target.is_none_or(|want| t.entry_id == want)
186            }) else {
187                break;
188            };
189
190            let lot = &self.open[index];
191            let closed = signed_qty.abs().min(lot.size.abs());
192            let closed_signed = closed * lot.size.signum();
193            let entry_share = lot.commission * closed / lot.size.abs();
194            let exit_share = order_commission * closed / order_qty_abs;
195
196            self.realized += (price - lot.entry_price) * closed_signed;
197            signed_qty += closed_signed; // moves signed_qty toward zero
198
199            self.closed.push(Trade {
200                entry_id: lot.entry_id.clone(),
201                size: closed_signed,
202                entry_price: lot.entry_price,
203                entry_bar: lot.entry_bar,
204                exit_price: Some(price),
205                exit_bar: Some(self.bar_index),
206                commission: entry_share + exit_share,
207            });
208
209            let lot = &mut self.open[index];
210            lot.size -= closed_signed;
211            lot.commission -= entry_share;
212            if lot.size == 0.0 {
213                self.open.remove(index);
214            }
215        }
216
217        // Whatever quantity is left opens a new lot — but only for an entry. A
218        // targeted reduce never flips into a new position, so it stops here.
219        if signed_qty != 0.0 && target.is_none() {
220            self.open.push(Trade {
221                entry_id: id.to_string(),
222                size: signed_qty,
223                entry_price: price,
224                entry_bar: self.bar_index,
225                exit_price: None,
226                exit_bar: None,
227                commission: order_commission * signed_qty.abs() / order_qty_abs,
228            });
229        }
230    }
231
232    /// The signed quantity an order actually trades at `price`, resolving the
233    /// default quantity and, for a reducing or reversing order, the position.
234    fn resolve_qty(&self, order: &Order, price: f64) -> f64 {
235        if order.reduce_only {
236            // Never flips: close at most the matched position. An explicit qty
237            // wins; otherwise `qty_percent` closes that share, and with neither
238            // `strategy.close` shuts the whole position.
239            let pool = self.matched_size(order.close_target.as_deref());
240            let closable = match (order.qty, order.qty_percent) {
241                (Some(q), _) => pool.abs().min(q.abs()),
242                (None, Some(pct)) => pool.abs() * (pct / 100.0),
243                (None, None) => pool.abs(),
244            };
245            return -pool.signum() * closable;
246        }
247
248        let requested = match order.qty {
249            Some(q) => q.abs(),
250            None => {
251                // Pine sizes a default-qty order from the close of the bar it
252                // was generated on; fall back to the fill price if unstamped.
253                let sizing_price = order.sizing_price.unwrap_or(price);
254                self.sizing
255                    .contracts(sizing_price, self.equity(sizing_price))
256            }
257        };
258        let net = self.net_size();
259        let want = order.direction.sign() * requested;
260        if order.reverses && net != 0.0 && net.signum() != order.direction.sign() {
261            // Close the opposite position and open `requested` the other way.
262            want - net
263        } else {
264            want
265        }
266    }
267
268    /// Evaluate every exit bracket against `bar`: for a matched position, fill
269    /// the stop-loss, trailing stop or take-profit if the bar reaches it (a stop
270    /// wins when several do, the conservative assumption), then retire it.
271    fn evaluate_exits(&mut self, bar: &Bar) {
272        let ids: Vec<String> = self.exits.iter().map(|e| e.id.clone()).collect();
273        for id in ids {
274            let Some(exit) = self.exits.iter().find(|e| e.id == id).cloned() else {
275                continue;
276            };
277            let target = exit.from_entry.as_deref();
278            let pos = self.matched_size(target);
279            if pos == 0.0 {
280                continue; // Nothing to protect yet (the entry has not filled).
281            }
282            let dir = pos.signum();
283            let entry_avg = self.matched_avg(target);
284            let mintick = self.mintick;
285            let exit_dir = if dir > 0.0 {
286                Direction::Short
287            } else {
288                Direction::Long
289            };
290
291            // Take-profit and stop-loss prices, from an explicit level or a tick
292            // distance either side of the entry.
293            let tp = exit
294                .limit
295                .or_else(|| exit.profit_ticks.map(|t| entry_avg + dir * t * mintick));
296            let sl = exit
297                .stop
298                .or_else(|| exit.loss_ticks.map(|t| entry_avg - dir * t * mintick));
299
300            // Arm and advance the trailing stop with this bar: the reference
301            // trails "each time the trade's profit reaches a new high", so it
302            // follows the peak within the bar and can fill the same one.
303            let trail_stop = self.advance_trail(&id, dir, entry_avg, bar);
304
305            // A stop wins over the take-profit when a bar reaches both. The
306            // trailing stop fills at its level — price set the peak this bar,
307            // then retraced to the stop.
308            let hit = sl
309                .and_then(|p| self.leg_fill(OrderKind::Stop(p), exit_dir, bar))
310                .or_else(|| {
311                    trail_stop.filter(|&ts| {
312                        if dir > 0.0 {
313                            bar.low <= ts
314                        } else {
315                            bar.high >= ts
316                        }
317                    })
318                })
319                .or_else(|| tp.and_then(|p| self.leg_fill(OrderKind::Limit(p), exit_dir, bar)));
320
321            if let Some(price) = hit {
322                let requested = match (exit.qty, exit.qty_percent) {
323                    (Some(q), _) => pos.abs().min(q.abs()),
324                    (None, Some(pct)) => pos.abs() * (pct / 100.0),
325                    (None, None) => pos.abs(),
326                };
327                self.apply_fill(-dir * requested, price, &exit.id, target);
328                self.exits.retain(|e| e.id != id);
329            }
330        }
331    }
332
333    /// Arm a trailing exit and advance its peak from `bar`, returning the stop
334    /// price if it is active — `trail_offset` ticks behind the best price seen.
335    fn advance_trail(&mut self, id: &str, dir: f64, entry_avg: f64, bar: &Bar) -> Option<f64> {
336        let mintick = self.mintick;
337        let exit = self.exits.iter_mut().find(|e| e.id == id)?;
338        let offset = exit.trail_offset?;
339        let bar_best = if dir > 0.0 { bar.high } else { bar.low };
340
341        if !exit.activated {
342            let level = exit
343                .trail_price
344                .or_else(|| exit.trail_points.map(|pts| entry_avg + dir * pts * mintick));
345            if let Some(level) = level {
346                exit.activated = if dir > 0.0 {
347                    bar.high >= level
348                } else {
349                    bar.low <= level
350                };
351            }
352        }
353        if !exit.activated {
354            return None;
355        }
356
357        exit.peak = Some(match exit.peak {
358            Some(pk) if dir > 0.0 => pk.max(bar_best),
359            Some(pk) => pk.min(bar_best),
360            None => bar_best,
361        });
362        exit.peak.map(|pk| pk - dir * offset * mintick)
363    }
364
365    /// The fill price of one exit leg against `bar`, or `None` if unreached.
366    fn leg_fill(&self, kind: OrderKind, direction: Direction, bar: &Bar) -> Option<f64> {
367        let leg = Order {
368            kind,
369            ..Order::market("", direction, None)
370        };
371        self.fills.fill(&leg, bar)
372    }
373
374    /// Whether an entry order is blocked by the pyramiding limit: it would add a
375    /// new lot to an already-full stack on its own side.
376    fn pyramiding_blocks(&self, order: &Order) -> bool {
377        if order.reduce_only || !order.reverses {
378            return false; // Only `strategy.entry` obeys pyramiding.
379        }
380        let net = self.net_size();
381        let same_side = net != 0.0 && net.signum() == order.direction.sign();
382        same_side && self.open_lots_toward(order.direction) >= self.max_entries
383    }
384
385    /// Apply an OCA group's effect after `filled` executes: cancel the group's
386    /// other unfilled orders, or reduce them by the filled size.
387    fn apply_oca(&mut self, filled: &Order, filled_qty: f64) {
388        let Some(group) = filled.oca_name.clone() else {
389            return;
390        };
391        if filled.oca_type == OcaType::None {
392            return;
393        }
394        let siblings: Vec<String> = self
395            .pending
396            .values()
397            .filter(|o| o.id != filled.id && o.oca_name.as_deref() == Some(group.as_str()))
398            .map(|o| o.id.clone())
399            .collect();
400        for id in siblings {
401            match filled.oca_type {
402                OcaType::Cancel => {
403                    self.pending.remove(&id);
404                    self.order.retain(|o| o != &id);
405                }
406                OcaType::Reduce => {
407                    if let Some(o) = self.pending.get_mut(&id) {
408                        // Shrink by the filled size; a non-positive remainder
409                        // cancels the order outright.
410                        let base = o.qty.unwrap_or(filled_qty.abs());
411                        let left = base - filled_qty.abs();
412                        if left > 0.0 {
413                            o.qty = Some(left);
414                        } else {
415                            self.pending.remove(&id);
416                            self.order.retain(|o| o != &id);
417                        }
418                    }
419                }
420                OcaType::None => {}
421            }
422        }
423    }
424
425    /// Whether a risk rule rejects `order` outright at submission. Exits and
426    /// reduce-only orders always pass — a rule may stop new exposure but never
427    /// traps an open position.
428    fn risk_rejects(&self, order: &Order) -> bool {
429        if order.reduce_only {
430            return false;
431        }
432        if self.halted || self.halted_today {
433            return true;
434        }
435        !self.entry_filter.allows(order.direction)
436        // The daily fill cap is enforced per fill in `advance`, not here — an
437        // order under the cap at submission may still be over it by the time it
438        // fills.
439    }
440
441    /// Halt the strategy for the rest of the run, recording the bar it happened
442    /// on (a rest-of-run risk rule fired).
443    fn halt(&mut self) {
444        self.halted = true;
445        self.halted_bar = Some(self.bar_index);
446    }
447
448    /// Roll intraday state when `time` lands on a new UTC day, and settle the day
449    /// that just ended for `max_cons_loss_days`.
450    fn roll_day(&mut self, time: i64) {
451        // TODO: this buckets by the UTC calendar day. TradingView rolls the
452        // trading day at the exchange session start in `syminfo.timezone`, so the
453        // intraday rules (max_intraday_loss / max_intraday_filled_orders and the
454        // per-day P&L behind max_cons_loss_days) diverge for sub-daily
455        // equity/futures. Correct once the broker is given the symbol's timezone
456        // and session; UTC is exact for 24/7 (crypto) symbols.
457        let bucket = time.div_euclid(86_400_000);
458        match self.day {
459            Some(current) if current == bucket => return,
460            Some(_) => {
461                // The day just ended: a losing day advances the streak, a
462                // non-losing one resets it.
463                if let Some(limit) = self.max_cons_loss_days {
464                    if self.last_equity < self.day_start_equity {
465                        self.consecutive_loss_days += 1;
466                        if self.consecutive_loss_days >= limit {
467                            self.halt();
468                        }
469                    } else {
470                        self.consecutive_loss_days = 0;
471                    }
472                }
473            }
474            None => {}
475        }
476        // Start the new day from the equity carried across the boundary.
477        self.day = Some(bucket);
478        self.day_start_equity = self.last_equity;
479        self.intraday_peak = self.last_equity;
480        self.filled_today = 0;
481        self.halted_today = false;
482    }
483
484    /// Reduce an entry `qty` so the resulting position stays within
485    /// `max_position_size`; returns 0 when even the smallest step would exceed it
486    /// (Pine then places nothing).
487    fn clamp_to_max_position(&self, order: &Order, qty: f64) -> f64 {
488        let Some(max) = self.max_position_size else {
489            return qty;
490        };
491        if order.reduce_only {
492            return qty;
493        }
494        let after = self.position().size + qty;
495        if after.abs() <= max {
496            return qty;
497        }
498        // Allow only up to `max` in the resulting direction; if that flips the
499        // order's sign, the position is already at the cap — place nothing.
500        let clamped = max * after.signum() - self.position().size;
501        if clamped == 0.0 || clamped.signum() != qty.signum() {
502            0.0
503        } else {
504            clamped
505        }
506    }
507
508    /// Close the whole position at `price` — the forced exit a breached drawdown
509    /// or intraday-loss rule performs.
510    fn flatten(&mut self, price: f64) {
511        let size = self.position().size;
512        if size != 0.0 {
513            self.apply_fill(-size, price, "risk_flatten", None);
514        }
515    }
516
517    /// Mark equity at the bar's close, update the peaks, and enforce the
518    /// equity-drop rules — cancelling and flattening on a breach.
519    fn mark_and_check_risk(&mut self, bar: &Bar) {
520        let equity = self.equity(bar.close);
521        self.peak_equity = self.peak_equity.max(equity);
522        self.intraday_peak = self.intraday_peak.max(equity);
523
524        // Drawdown is measured to the bar's adverse intrabar extreme (equity
525        // marked at the high and the low), so the rule agrees with the reported
526        // `strategy.max_drawdown`. The peak still tracks close equity, so an
527        // intrabar swing does not move the mark.
528        let intrabar_low = self.equity(bar.high).min(self.equity(bar.low));
529
530        if let Some(rule) = self.max_drawdown {
531            if !self.halted && self.peak_equity - intrabar_low >= rule.threshold(self.peak_equity) {
532                self.cancel_all();
533                self.flatten(bar.close);
534                self.halt();
535            }
536        }
537        if let Some(rule) = self.max_intraday_loss {
538            if !self.halted_today
539                && self.intraday_peak - intrabar_low >= rule.threshold(self.intraday_peak)
540            {
541                self.cancel_all();
542                self.flatten(bar.close);
543                self.halted_today = true;
544            }
545        }
546
547        // Recompute after a possible flatten, so the day P&L and next mark start
548        // from the settled equity.
549        self.last_equity = self.equity(bar.close);
550    }
551}
552
553impl<F: FillModel> Broker for BarBroker<F> {
554    fn submit(&mut self, order: Order) {
555        if self.risk_rejects(&order) {
556            return;
557        }
558        if !self.pending.contains_key(&order.id) {
559            self.order.push(order.id.clone());
560        }
561        self.pending.insert(order.id.clone(), order);
562    }
563
564    fn set_risk(&mut self, rule: RiskRule) {
565        match rule {
566            RiskRule::AllowEntryIn(filter) => self.entry_filter = filter,
567            RiskRule::MaxPositionSize(size) => self.max_position_size = Some(size.abs()),
568            RiskRule::MaxDrawdown(threshold) => self.max_drawdown = Some(threshold),
569            RiskRule::MaxIntradayLoss(threshold) => self.max_intraday_loss = Some(threshold),
570            RiskRule::MaxConsLossDays(days) => self.max_cons_loss_days = Some(days),
571            RiskRule::MaxIntradayFilledOrders(count) => {
572                self.max_intraday_filled_orders = Some(count)
573            }
574        }
575    }
576
577    fn submit_exit(&mut self, mut exit: Exit) {
578        if let Some(slot) = self.exits.iter_mut().find(|e| e.id == exit.id) {
579            // Re-submitting the same exit each bar must not restart a trailing
580            // stop, so carry its runtime state onto the replacement.
581            exit.activated = slot.activated;
582            exit.peak = slot.peak;
583            *slot = exit;
584        } else {
585            self.exits.push(exit);
586        }
587    }
588
589    fn cancel(&mut self, id: &str) {
590        if self.pending.remove(id).is_some() {
591            self.order.retain(|o| o != id);
592        }
593        self.exits.retain(|e| e.id != id);
594    }
595
596    fn cancel_all(&mut self) {
597        self.pending.clear();
598        self.order.clear();
599        self.exits.clear();
600    }
601
602    fn advance(&mut self, bar: &Bar) {
603        self.bar_index = bar.index;
604        self.roll_day(bar.time);
605
606        // Fill in submission order; a filled order leaves the book.
607        let ids: Vec<String> = self.order.clone();
608        for id in ids {
609            let Some(order) = self.pending.get(&id).cloned() else {
610                continue;
611            };
612            // While halted (for the run or the day), drop new entries; a
613            // reduce-only exit still fills so an open position can be closed.
614            if (self.halted || self.halted_today) && !order.reduce_only {
615                self.pending.remove(&id);
616                self.order.retain(|o| o != &id);
617                continue;
618            }
619            if self.pyramiding_blocks(&order) {
620                // The stack is full: drop the entry, as Pine rejects it.
621                self.pending.remove(&id);
622                self.order.retain(|o| o != &id);
623                continue;
624            }
625            // Once the day's fill cap is reached, no more orders fill — except a
626            // reduce-only exit of the current position. Enforced here, per fill,
627            // so orders already pending when the bar opens are capped too.
628            if !order.reduce_only {
629                if let Some(cap) = self.max_intraday_filled_orders {
630                    if self.filled_today >= cap {
631                        self.pending.remove(&id);
632                        self.order.retain(|o| o != &id);
633                        continue;
634                    }
635                }
636            }
637            if let Some(price) = self.fills.fill(&order, bar) {
638                let qty = self.clamp_to_max_position(&order, self.resolve_qty(&order, price));
639                if qty != 0.0 {
640                    self.apply_fill(qty, price, &order.id, order.close_target.as_deref());
641                    self.apply_oca(&order, qty);
642                    self.filled_today += 1;
643                }
644                self.pending.remove(&id);
645                self.order.retain(|o| o != &id);
646            }
647        }
648
649        // Then the protective exits, against the position those fills produced.
650        self.evaluate_exits(bar);
651
652        // Finally settle equity for the bar and enforce the equity-drop rules.
653        self.mark_and_check_risk(bar);
654    }
655
656    fn position(&self) -> Position {
657        let size = self.net_size();
658        if size == 0.0 {
659            return Position::default();
660        }
661        // Average price weighted over the open lots on the net side.
662        let (value, qty): (f64, f64) = self
663            .open
664            .iter()
665            .filter(|t| t.size.signum() == size.signum())
666            .fold((0.0, 0.0), |(v, q), t| {
667                (v + t.entry_price * t.size, q + t.size)
668            });
669        Position {
670            size,
671            avg_price: if qty == 0.0 { 0.0 } else { value / qty },
672        }
673    }
674
675    fn initial_capital(&self) -> f64 {
676        self.initial
677    }
678
679    fn equity(&self, price: f64) -> f64 {
680        let unrealized: f64 = self
681            .open
682            .iter()
683            .map(|t| (price - t.entry_price) * t.size)
684            .sum();
685        self.cash + self.realized + unrealized
686    }
687
688    fn open_trades(&self) -> Vec<&Trade> {
689        self.open.iter().collect()
690    }
691
692    fn closed_trades(&self) -> &[Trade] {
693        &self.closed
694    }
695
696    fn halted_bar(&self) -> Option<u64> {
697        self.halted_bar
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use crate::{Commission, Direction, OrderKind, PineFills};
705
706    fn bar(index: u64, open: f64, high: f64, low: f64, close: f64) -> Bar {
707        Bar {
708            open,
709            high,
710            low,
711            close,
712            volume: 0.0,
713            index,
714            ..Bar::default()
715        }
716    }
717
718    fn broker() -> BarBroker<PineFills> {
719        BarBroker::new(PineFills::default(), 10_000.0)
720    }
721
722    /// A bar carrying a `time`, so day-boundary rules can be exercised.
723    fn bar_at(index: u64, time: i64, open: f64, high: f64, low: f64, close: f64) -> Bar {
724        Bar {
725            time,
726            ..bar(index, open, high, low, close)
727        }
728    }
729
730    const DAY: i64 = 86_400_000;
731
732    #[test]
733    fn allow_entry_in_blocks_the_disallowed_direction() {
734        let mut b = broker();
735        b.set_risk(RiskRule::AllowEntryIn(EntryFilter::LongOnly));
736        b.submit(Order::market("s", Direction::Short, Some(1.0))); // rejected
737        b.submit(Order::market("l", Direction::Long, Some(1.0))); // allowed
738        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
739        assert_eq!(b.position().size, 1.0);
740    }
741
742    #[test]
743    fn max_position_size_caps_the_entry() {
744        let mut b = broker();
745        b.set_risk(RiskRule::MaxPositionSize(3.0));
746        b.submit(Order::market("l", Direction::Long, Some(10.0)));
747        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
748        assert_eq!(b.position().size, 3.0);
749    }
750
751    #[test]
752    fn max_drawdown_flattens_and_halts() {
753        let mut b = broker();
754        b.set_risk(RiskRule::MaxDrawdown(RiskType::Cash(500.0)));
755        b.submit(Order::market("l", Direction::Long, Some(100.0)));
756        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0)); // equity 10_000 (peak)
757        b.advance(&bar(1, 100.0, 100.0, 90.0, 90.0)); // −1_000 > 500 → flatten + halt
758        assert!(b.position().is_flat());
759        assert_eq!(b.halted_bar(), Some(1));
760
761        // A new entry after the halt is rejected for the rest of the run.
762        b.submit(Order::market("l2", Direction::Long, Some(1.0)));
763        b.advance(&bar(2, 90.0, 90.0, 90.0, 90.0));
764        assert!(b.position().is_flat());
765    }
766
767    #[test]
768    fn max_drawdown_measures_the_intrabar_low() {
769        let mut b = broker();
770        b.set_risk(RiskRule::MaxDrawdown(RiskType::Cash(500.0)));
771        b.submit(Order::market("l", Direction::Long, Some(100.0)));
772        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0)); // long 100 @ 100, peak 10_000
773
774        // The close recovers to 96 (−400, within the cap) but the low hit 90
775        // (−1_000): the intrabar drawdown trips the rule the close alone would not.
776        b.advance(&bar(1, 100.0, 100.0, 90.0, 96.0));
777        assert!(b.position().is_flat());
778    }
779
780    #[test]
781    fn max_intraday_filled_orders_resets_next_day() {
782        let mut b = broker();
783        b.set_risk(RiskRule::MaxIntradayFilledOrders(1));
784
785        b.submit(Order::market("a", Direction::Long, Some(1.0)));
786        b.advance(&bar_at(0, 0, 100.0, 100.0, 100.0, 100.0)); // fills → 1/1
787                                                              // A second order the same day is blocked by the cap (a reversal, so
788                                                              // pyramiding is not what stops it).
789        b.submit(Order::market("b", Direction::Short, Some(2.0)));
790        b.advance(&bar_at(1, 1_000, 100.0, 100.0, 100.0, 100.0));
791        assert_eq!(b.position().size, 1.0);
792
793        // The next day resets the count, so a fresh order fills.
794        b.advance(&bar_at(2, DAY, 100.0, 100.0, 100.0, 100.0));
795        b.submit(Order::market("c", Direction::Short, Some(1.0)));
796        b.advance(&bar_at(3, DAY + 1_000, 100.0, 100.0, 100.0, 100.0));
797        assert_eq!(b.position().size, -1.0);
798    }
799
800    #[test]
801    fn max_intraday_filled_orders_caps_already_pending_orders() {
802        let mut b = broker();
803        b.set_risk(RiskRule::MaxIntradayFilledOrders(1));
804
805        // Both are under the cap at submission and both are pending when the bar
806        // opens; only one may fill, since the cap is enforced per fill.
807        b.submit(Order::market("a", Direction::Long, Some(1.0)));
808        b.submit(Order::market("b", Direction::Short, Some(3.0)));
809        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
810
811        // "a" filled (long 1); "b" was blocked, not reversing the position.
812        assert_eq!(b.position().size, 1.0);
813    }
814
815    #[test]
816    fn max_cons_loss_days_halts_after_two_losing_days() {
817        let mut b = broker();
818        b.set_risk(RiskRule::MaxConsLossDays(2));
819
820        // A long carried across three down-closing days.
821        b.submit(Order::market("l", Direction::Long, Some(10.0)));
822        b.advance(&bar_at(0, 0, 100.0, 100.0, 100.0, 99.0)); // day 0 ends at a loss
823        b.advance(&bar_at(1, DAY, 99.0, 99.0, 98.0, 98.0)); // rolls day 0 → streak 1
824        b.advance(&bar_at(2, 2 * DAY, 98.0, 98.0, 97.0, 97.0)); // rolls day 1 → streak 2 → halt
825
826        // Halted: a reversing entry is rejected, so the position is unchanged.
827        b.submit(Order::market("rev", Direction::Short, Some(20.0)));
828        b.advance(&bar_at(3, 2 * DAY + 1_000, 97.0, 97.0, 97.0, 97.0));
829        assert_eq!(b.position().size, 10.0);
830    }
831
832    #[test]
833    fn a_market_entry_fills_at_the_open() {
834        let mut b = broker();
835        b.submit(Order::market("long", Direction::Long, Some(2.0)));
836        b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
837
838        let pos = b.position();
839        assert_eq!(pos.size, 2.0);
840        assert_eq!(pos.avg_price, 100.0);
841        // Marked at 104: two contracts up 4 each.
842        assert_eq!(b.equity(104.0), 10_008.0);
843    }
844
845    #[test]
846    fn a_closed_trade_keeps_its_size_and_profit() {
847        let mut b = broker();
848        b.submit(Order::market("L", Direction::Long, Some(2.0)));
849        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
850        b.submit(Order {
851            reduce_only: true,
852            ..Order::market("L", Direction::Short, None)
853        });
854        b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
855
856        let trade = &b.closed_trades()[0];
857        assert_eq!(trade.size, 2.0);
858        assert_eq!(trade.entry_price, 100.0);
859        assert_eq!(trade.exit_price, Some(110.0));
860        assert_eq!(trade.profit(0.0), 20.0); // (110 - 100) * 2, price ignored once closed
861    }
862
863    #[test]
864    fn closing_realises_profit_and_flattens() {
865        let mut b = broker();
866        b.submit(Order::market("long", Direction::Long, Some(1.0)));
867        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
868
869        b.submit(Order {
870            reduce_only: true,
871            ..Order::market("exit", Direction::Short, Some(1.0))
872        });
873        b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
874
875        assert!(b.position().is_flat());
876        assert_eq!(b.closed_trades().len(), 1);
877        assert_eq!(b.equity(110.0), 10_010.0);
878    }
879
880    #[test]
881    fn an_opposite_entry_reverses_the_position() {
882        let mut b = broker();
883        b.submit(Order::market("a", Direction::Long, Some(5.0)));
884        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
885
886        // Short 5 against long 5 sells 10: closes the long and opens short 5.
887        b.submit(Order::market("b", Direction::Short, Some(5.0)));
888        b.advance(&bar(1, 100.0, 100.0, 100.0, 100.0));
889
890        assert_eq!(b.position().size, -5.0);
891        assert_eq!(b.closed_trades().len(), 1);
892    }
893
894    #[test]
895    fn a_buy_limit_waits_for_the_price() {
896        let mut b = broker();
897        b.submit(Order {
898            kind: OrderKind::Limit(95.0),
899            reverses: false,
900            ..Order::market("buy", Direction::Long, Some(1.0))
901        });
902
903        // Bar stays above 95: no fill.
904        b.advance(&bar(0, 100.0, 101.0, 96.0, 99.0));
905        assert!(b.position().is_flat());
906
907        // Next bar dips to 94: fills at the limit.
908        b.advance(&bar(1, 97.0, 98.0, 94.0, 96.0));
909        assert_eq!(b.position().size, 1.0);
910        assert_eq!(b.position().avg_price, 95.0);
911    }
912
913    #[test]
914    fn commission_reduces_equity() {
915        let mut b = broker().with_commission(Commission::Percent(1.0));
916        b.submit(Order::market("long", Direction::Long, Some(1.0)));
917        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
918
919        // 1% of 100 = 1.0 charged on entry.
920        assert_eq!(b.equity(100.0), 9_999.0);
921    }
922
923    #[test]
924    fn a_take_profit_exit_closes_when_price_reaches_it() {
925        let mut b = broker();
926        b.submit(Order::market("L", Direction::Long, Some(1.0)));
927        b.submit_exit(Exit {
928            limit: Some(110.0),
929            ..Exit::resting("X", Some("L".into()), None, None)
930        });
931
932        // Entry fills at 100; this bar's high 105 does not reach 110.
933        b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
934        assert_eq!(b.position().size, 1.0);
935
936        // Next bar reaches 110: the take-profit sells at 110.
937        b.advance(&bar(1, 106.0, 112.0, 105.0, 108.0));
938        assert!(b.position().is_flat());
939        assert_eq!(b.closed_trades().len(), 1);
940        assert_eq!(b.equity(108.0), 10_010.0); // realised +10
941    }
942
943    #[test]
944    fn a_stop_loss_in_ticks_sits_a_distance_from_the_entry() {
945        // mintick 0.5, loss 4 ticks -> stop 2.0 below a long entry.
946        let fills = PineFills {
947            slippage: 0.0,
948            mintick: 0.5,
949        };
950        let mut b = BarBroker::new(fills, 10_000.0).with_mintick(0.5);
951        b.submit(Order::market("L", Direction::Long, Some(1.0)));
952        b.submit_exit(Exit {
953            loss_ticks: Some(4.0),
954            ..Exit::resting("X", Some("L".into()), None, None)
955        });
956
957        // Entry fills at 100; stop is 98. This bar's low 99 stays above it.
958        b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
959        assert_eq!(b.position().size, 1.0);
960
961        // Next bar dips to 97: the stop sells at 98.
962        b.advance(&bar(1, 100.0, 101.0, 97.0, 99.0));
963        assert!(b.position().is_flat());
964        assert_eq!(b.equity(99.0), 9_998.0); // realised -2
965    }
966
967    #[test]
968    fn close_targets_only_the_named_entry() {
969        let mut b = broker();
970        b.submit(Order {
971            reverses: false,
972            ..Order::market("A", Direction::Long, Some(1.0))
973        });
974        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
975        b.submit(Order {
976            reverses: false,
977            ..Order::market("B", Direction::Long, Some(1.0))
978        });
979        b.advance(&bar(1, 101.0, 101.0, 101.0, 101.0));
980        assert_eq!(b.position().size, 2.0);
981
982        // Close only A: its lot goes, B's remains.
983        b.submit(Order {
984            reduce_only: true,
985            close_target: Some("A".into()),
986            qty: None,
987            ..Order::market("A", Direction::Long, None)
988        });
989        b.advance(&bar(2, 102.0, 102.0, 102.0, 102.0));
990        assert_eq!(b.position().size, 1.0);
991        assert_eq!(b.closed_trades().len(), 1);
992        assert_eq!(b.position().avg_price, 101.0); // B's entry
993    }
994
995    #[test]
996    fn cash_sizing_buys_contracts_worth_the_cash() {
997        let mut b = broker().with_sizing(Sizing::Cash(1_000.0));
998        // No explicit qty: 1000 cash / 100 price = 10 contracts.
999        b.submit(Order::market("L", Direction::Long, None));
1000        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
1001        assert_eq!(b.position().size, 10.0);
1002    }
1003
1004    #[test]
1005    fn percent_of_equity_sizing_scales_with_the_account() {
1006        let mut b = broker().with_sizing(Sizing::PercentOfEquity(50.0));
1007        // 50% of 10000 equity = 5000, at price 100 = 50 contracts.
1008        b.submit(Order::market("L", Direction::Long, None));
1009        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
1010        assert_eq!(b.position().size, 50.0);
1011    }
1012
1013    #[test]
1014    fn pyramiding_caps_entries_in_one_direction() {
1015        let mut b = broker().with_pyramiding(2);
1016        for (i, id) in ["A", "B", "C"].iter().enumerate() {
1017            b.submit(Order {
1018                reverses: true,
1019                ..Order::market(*id, Direction::Long, Some(1.0))
1020            });
1021            b.advance(&bar(i as u64, 100.0, 100.0, 100.0, 100.0));
1022        }
1023        // Two lots allowed; the third entry is rejected.
1024        assert_eq!(b.position().size, 2.0);
1025    }
1026
1027    #[test]
1028    fn oca_cancel_removes_the_sibling_when_one_fills() {
1029        let mut b = broker();
1030        // A buy stop at 105 and a buy limit at 95, same OCA group.
1031        b.submit(Order {
1032            kind: OrderKind::Stop(105.0),
1033            oca_name: Some("G".into()),
1034            oca_type: OcaType::Cancel,
1035            ..Order::market("up", Direction::Long, Some(1.0))
1036        });
1037        b.submit(Order {
1038            kind: OrderKind::Limit(95.0),
1039            oca_name: Some("G".into()),
1040            oca_type: OcaType::Cancel,
1041            ..Order::market("down", Direction::Long, Some(1.0))
1042        });
1043
1044        // This bar reaches both 105 and 95; the first to fill cancels the other.
1045        b.advance(&bar(0, 100.0, 106.0, 94.0, 100.0));
1046        assert_eq!(b.position().size, 1.0);
1047    }
1048
1049    #[test]
1050    fn close_qty_percent_reduces_the_position() {
1051        let mut b = broker();
1052        b.submit(Order::market("L", Direction::Long, Some(4.0)));
1053        b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
1054
1055        // Close 50% of the 4-contract position: 2 remain.
1056        b.submit(Order {
1057            reduce_only: true,
1058            close_target: Some("L".into()),
1059            qty_percent: Some(50.0),
1060            qty: None,
1061            ..Order::market("L", Direction::Long, None)
1062        });
1063        b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
1064        assert_eq!(b.position().size, 2.0);
1065
1066        // The exited half is a closed trade; the rest stays open.
1067        assert_eq!(b.closed_trades().len(), 1);
1068        assert_eq!(b.closed_trades()[0].size, 2.0);
1069        assert_eq!(b.closed_trades()[0].profit(0.0), 20.0); // (110 - 100) * 2
1070        assert_eq!(b.open_trades().len(), 1);
1071        assert_eq!(b.open_trades()[0].size, 2.0);
1072    }
1073
1074    #[test]
1075    fn a_trailing_stop_follows_the_peak_and_fills_at_its_level() {
1076        // mintick 0.5: activation 4 ticks (2.0) above entry, trailing 2 ticks
1077        // (1.0) behind the peak.
1078        let fills = PineFills {
1079            slippage: 0.0,
1080            mintick: 0.5,
1081        };
1082        let mut b = BarBroker::new(fills, 10_000.0).with_mintick(0.5);
1083        b.submit(Order::market("L", Direction::Long, Some(1.0)));
1084        b.submit_exit(Exit {
1085            trail_points: Some(4.0),
1086            trail_offset: Some(2.0),
1087            ..Exit::resting("X", Some("L".into()), None, None)
1088        });
1089
1090        // Entry at 100; high 101 has not reached the 102 activation level.
1091        b.advance(&bar(0, 100.0, 101.0, 99.0, 100.0));
1092        assert_eq!(b.position().size, 1.0);
1093
1094        // Same bar arms and fills: price rallies to 105 (peak, so the stop is at
1095        // 104), then the low 101 retraces through it — the stop fills at its own
1096        // level, 104, not the earlier open, for a profit of 4.
1097        b.advance(&bar(1, 102.0, 105.0, 101.0, 104.0));
1098        assert!(b.position().is_flat());
1099        assert_eq!(b.closed_trades().len(), 1);
1100        assert_eq!(b.equity(104.0), 10_004.0);
1101    }
1102}