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_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 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 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 fn apply_fill(&mut self, mut signed_qty: f64, price: f64, id: &str, target: Option<&str>) {
169 let order_qty_abs = signed_qty.abs();
173 let order_commission = self.commission_on(signed_qty, price);
174 self.cash -= order_commission;
175
176 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; 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 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 fn resolve_qty(&self, order: &Order, price: f64) -> f64 {
232 if order.reduce_only {
233 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 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 want - net
260 } else {
261 want
262 }
263 }
264
265 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; }
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 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 let trail_stop = self.advance_trail(&id, dir, entry_avg, bar);
301
302 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 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 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 fn pyramiding_blocks(&self, order: &Order) -> bool {
374 if order.reduce_only || !order.reverses {
375 return false; }
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 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 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 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 matches!(self.max_intraday_filled_orders, Some(cap) if self.filled_today >= cap)
437 }
438
439 fn roll_day(&mut self, time: i64) {
442 let bucket = time.div_euclid(86_400_000);
449 match self.day {
450 Some(current) if current == bucket => return,
451 Some(_) => {
452 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 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 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 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 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 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 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 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 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 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 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 self.evaluate_exits(bar);
624
625 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 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 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))); b.submit(Order::market("l", Direction::Long, Some(1.0))); 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)); b.advance(&bar(1, 100.0, 100.0, 90.0, 90.0)); assert!(b.position().is_flat());
728
729 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)); b.submit(Order::market("b", Direction::Long, Some(1.0))); 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 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 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)); 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)));
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 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); }
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 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 b.advance(&bar(0, 100.0, 101.0, 96.0, 99.0));
844 assert!(b.position().is_flat());
845
846 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 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 b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
873 assert_eq!(b.position().size, 1.0);
874
875 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); }
881
882 #[test]
883 fn a_stop_loss_in_ticks_sits_a_distance_from_the_entry() {
884 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 b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
898 assert_eq!(b.position().size, 1.0);
899
900 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); }
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 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); }
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 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 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 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 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 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 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 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); 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 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 b.advance(&bar(0, 100.0, 101.0, 99.0, 100.0));
1031 assert_eq!(b.position().size, 1.0);
1032
1033 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}