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