1use 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 sizing: Sizing,
19 max_entries: usize,
21 mintick: f64,
23 initial: f64,
26 cash: f64,
28 realized: f64,
29
30 pending: HashMap<String, Order>,
33 order: Vec<String>,
35 exits: Vec<Exit>,
37
38 open: Vec<Trade>,
39 closed: Vec<Trade>,
40
41 bar_index: u64,
42
43 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 peak_equity: f64,
52 day: Option<i64>,
54 day_start_equity: f64,
56 intraday_peak: f64,
58 last_equity: f64,
60 filled_today: u32,
62 consecutive_loss_days: u32,
63 halted: bool,
65 halted_bar: Option<u64>,
67 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 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 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 fn apply_fill(&mut self, mut signed_qty: f64, price: f64, id: &str, target: Option<&str>) {
172 let order_qty_abs = signed_qty.abs();
176 let order_commission = self.commission_on(signed_qty, price);
177 self.cash -= order_commission;
178
179 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; 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 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 fn resolve_qty(&self, order: &Order, price: f64) -> f64 {
235 if order.reduce_only {
236 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 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 want - net
263 } else {
264 want
265 }
266 }
267
268 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; }
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 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 let trail_stop = self.advance_trail(&id, dir, entry_avg, bar);
304
305 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 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 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 fn pyramiding_blocks(&self, order: &Order) -> bool {
377 if order.reduce_only || !order.reverses {
378 return false; }
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 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 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 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 }
440
441 fn halt(&mut self) {
444 self.halted = true;
445 self.halted_bar = Some(self.bar_index);
446 }
447
448 fn roll_day(&mut self, time: i64) {
451 let bucket = time.div_euclid(86_400_000);
458 match self.day {
459 Some(current) if current == bucket => return,
460 Some(_) => {
461 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 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 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 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 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 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 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 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 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 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 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 self.pending.remove(&id);
622 self.order.retain(|o| o != &id);
623 continue;
624 }
625 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 self.evaluate_exits(bar);
651
652 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 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 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))); b.submit(Order::market("l", Direction::Long, Some(1.0))); 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)); b.advance(&bar(1, 100.0, 100.0, 90.0, 90.0)); assert!(b.position().is_flat());
759 assert_eq!(b.halted_bar(), Some(1));
760
761 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)); 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)); 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 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 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 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 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)); b.advance(&bar_at(1, DAY, 99.0, 99.0, 98.0, 98.0)); b.advance(&bar_at(2, 2 * DAY, 98.0, 98.0, 97.0, 97.0)); 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 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); }
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 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 b.advance(&bar(0, 100.0, 101.0, 96.0, 99.0));
905 assert!(b.position().is_flat());
906
907 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 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 b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
934 assert_eq!(b.position().size, 1.0);
935
936 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); }
942
943 #[test]
944 fn a_stop_loss_in_ticks_sits_a_distance_from_the_entry() {
945 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 b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
959 assert_eq!(b.position().size, 1.0);
960
961 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); }
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 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); }
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 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 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 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 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 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 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 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); 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 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 b.advance(&bar(0, 100.0, 101.0, 99.0, 100.0));
1092 assert_eq!(b.position().size, 1.0);
1093
1094 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}