use crate::{
Broker, Commission, Direction, Exit, FillModel, OcaType, Order, OrderKind, Position, Sizing,
Trade,
};
use pine_core::Bar;
use std::collections::HashMap;
pub struct BarBroker<F: FillModel> {
fills: F,
commission: Option<Commission>,
sizing: Sizing,
max_entries: usize,
mintick: f64,
initial: f64,
cash: f64,
realized: f64,
pending: HashMap<String, Order>,
order: Vec<String>,
exits: Vec<Exit>,
open: Vec<Trade>,
closed: Vec<Trade>,
bar_index: u64,
}
impl<F: FillModel> BarBroker<F> {
pub fn new(fills: F, initial_capital: f64) -> Self {
Self {
fills,
commission: None,
sizing: Sizing::Contracts(1.0),
max_entries: 1,
mintick: 0.0,
initial: initial_capital,
cash: initial_capital,
realized: 0.0,
pending: HashMap::new(),
order: Vec::new(),
exits: Vec::new(),
open: Vec::new(),
closed: Vec::new(),
bar_index: 0,
}
}
pub fn with_commission(mut self, commission: Commission) -> Self {
self.commission = Some(commission);
self
}
pub fn with_sizing(mut self, sizing: Sizing) -> Self {
self.sizing = sizing;
self
}
pub fn with_mintick(mut self, mintick: f64) -> Self {
self.mintick = mintick;
self
}
pub fn with_pyramiding(mut self, pyramiding: usize) -> Self {
self.max_entries = pyramiding.max(1);
self
}
fn open_lots_toward(&self, direction: Direction) -> usize {
self.open
.iter()
.filter(|t| t.size.signum() == direction.sign())
.count()
}
fn net_size(&self) -> f64 {
self.open.iter().map(|t| t.size).sum()
}
fn matched_size(&self, target: Option<&str>) -> f64 {
self.open
.iter()
.filter(|t| target.is_none_or(|id| t.entry_id == id))
.map(|t| t.size)
.sum()
}
fn matched_avg(&self, target: Option<&str>) -> f64 {
let (value, qty): (f64, f64) = self
.open
.iter()
.filter(|t| target.is_none_or(|id| t.entry_id == id))
.fold((0.0, 0.0), |(v, q), t| {
(v + t.entry_price * t.size, q + t.size)
});
if qty == 0.0 {
0.0
} else {
value / qty
}
}
fn commission_on(&self, qty: f64, price: f64) -> f64 {
self.commission.map_or(0.0, |c| c.charge(qty, price))
}
fn apply_fill(&mut self, mut signed_qty: f64, price: f64, id: &str, target: Option<&str>) {
let order_qty_abs = signed_qty.abs();
let order_commission = self.commission_on(signed_qty, price);
self.cash -= order_commission;
while signed_qty != 0.0 {
let Some(index) = self.open.iter().position(|t| {
t.size.signum() != signed_qty.signum()
&& target.is_none_or(|want| t.entry_id == want)
}) else {
break;
};
let lot = &self.open[index];
let closed = signed_qty.abs().min(lot.size.abs());
let closed_signed = closed * lot.size.signum();
let entry_share = lot.commission * closed / lot.size.abs();
let exit_share = order_commission * closed / order_qty_abs;
self.realized += (price - lot.entry_price) * closed_signed;
signed_qty += closed_signed;
self.closed.push(Trade {
entry_id: lot.entry_id.clone(),
size: closed_signed,
entry_price: lot.entry_price,
entry_bar: lot.entry_bar,
exit_price: Some(price),
exit_bar: Some(self.bar_index),
commission: entry_share + exit_share,
});
let lot = &mut self.open[index];
lot.size -= closed_signed;
lot.commission -= entry_share;
if lot.size == 0.0 {
self.open.remove(index);
}
}
if signed_qty != 0.0 && target.is_none() {
self.open.push(Trade {
entry_id: id.to_string(),
size: signed_qty,
entry_price: price,
entry_bar: self.bar_index,
exit_price: None,
exit_bar: None,
commission: order_commission * signed_qty.abs() / order_qty_abs,
});
}
}
fn resolve_qty(&self, order: &Order, price: f64) -> f64 {
if order.reduce_only {
let pool = self.matched_size(order.close_target.as_deref());
let closable = match (order.qty, order.qty_percent) {
(Some(q), _) => pool.abs().min(q.abs()),
(None, Some(pct)) => pool.abs() * (pct / 100.0),
(None, None) => pool.abs(),
};
return -pool.signum() * closable;
}
let requested = match order.qty {
Some(q) => q.abs(),
None => {
let sizing_price = order.sizing_price.unwrap_or(price);
self.sizing
.contracts(sizing_price, self.equity(sizing_price))
}
};
let net = self.net_size();
let want = order.direction.sign() * requested;
if order.reverses && net != 0.0 && net.signum() != order.direction.sign() {
want - net
} else {
want
}
}
fn evaluate_exits(&mut self, bar: &Bar) {
let ids: Vec<String> = self.exits.iter().map(|e| e.id.clone()).collect();
for id in ids {
let Some(exit) = self.exits.iter().find(|e| e.id == id).cloned() else {
continue;
};
let target = exit.from_entry.as_deref();
let pos = self.matched_size(target);
if pos == 0.0 {
continue; }
let dir = pos.signum();
let entry_avg = self.matched_avg(target);
let mintick = self.mintick;
let exit_dir = if dir > 0.0 {
Direction::Short
} else {
Direction::Long
};
let tp = exit
.limit
.or_else(|| exit.profit_ticks.map(|t| entry_avg + dir * t * mintick));
let sl = exit
.stop
.or_else(|| exit.loss_ticks.map(|t| entry_avg - dir * t * mintick));
let trail_stop = self.advance_trail(&id, dir, entry_avg, bar);
let hit = sl
.and_then(|p| self.leg_fill(OrderKind::Stop(p), exit_dir, bar))
.or_else(|| {
trail_stop.filter(|&ts| {
if dir > 0.0 {
bar.low <= ts
} else {
bar.high >= ts
}
})
})
.or_else(|| tp.and_then(|p| self.leg_fill(OrderKind::Limit(p), exit_dir, bar)));
if let Some(price) = hit {
let requested = match (exit.qty, exit.qty_percent) {
(Some(q), _) => pos.abs().min(q.abs()),
(None, Some(pct)) => pos.abs() * (pct / 100.0),
(None, None) => pos.abs(),
};
self.apply_fill(-dir * requested, price, &exit.id, target);
self.exits.retain(|e| e.id != id);
}
}
}
fn advance_trail(&mut self, id: &str, dir: f64, entry_avg: f64, bar: &Bar) -> Option<f64> {
let mintick = self.mintick;
let exit = self.exits.iter_mut().find(|e| e.id == id)?;
let offset = exit.trail_offset?;
let bar_best = if dir > 0.0 { bar.high } else { bar.low };
if !exit.activated {
let level = exit
.trail_price
.or_else(|| exit.trail_points.map(|pts| entry_avg + dir * pts * mintick));
if let Some(level) = level {
exit.activated = if dir > 0.0 {
bar.high >= level
} else {
bar.low <= level
};
}
}
if !exit.activated {
return None;
}
exit.peak = Some(match exit.peak {
Some(pk) if dir > 0.0 => pk.max(bar_best),
Some(pk) => pk.min(bar_best),
None => bar_best,
});
exit.peak.map(|pk| pk - dir * offset * mintick)
}
fn leg_fill(&self, kind: OrderKind, direction: Direction, bar: &Bar) -> Option<f64> {
let leg = Order {
kind,
..Order::market("", direction, None)
};
self.fills.fill(&leg, bar)
}
fn pyramiding_blocks(&self, order: &Order) -> bool {
if order.reduce_only || !order.reverses {
return false; }
let net = self.net_size();
let same_side = net != 0.0 && net.signum() == order.direction.sign();
same_side && self.open_lots_toward(order.direction) >= self.max_entries
}
fn apply_oca(&mut self, filled: &Order, filled_qty: f64) {
let Some(group) = filled.oca_name.clone() else {
return;
};
if filled.oca_type == OcaType::None {
return;
}
let siblings: Vec<String> = self
.pending
.values()
.filter(|o| o.id != filled.id && o.oca_name.as_deref() == Some(group.as_str()))
.map(|o| o.id.clone())
.collect();
for id in siblings {
match filled.oca_type {
OcaType::Cancel => {
self.pending.remove(&id);
self.order.retain(|o| o != &id);
}
OcaType::Reduce => {
if let Some(o) = self.pending.get_mut(&id) {
let base = o.qty.unwrap_or(filled_qty.abs());
let left = base - filled_qty.abs();
if left > 0.0 {
o.qty = Some(left);
} else {
self.pending.remove(&id);
self.order.retain(|o| o != &id);
}
}
}
OcaType::None => {}
}
}
}
}
impl<F: FillModel> Broker for BarBroker<F> {
fn submit(&mut self, order: Order) {
if !self.pending.contains_key(&order.id) {
self.order.push(order.id.clone());
}
self.pending.insert(order.id.clone(), order);
}
fn submit_exit(&mut self, mut exit: Exit) {
if let Some(slot) = self.exits.iter_mut().find(|e| e.id == exit.id) {
exit.activated = slot.activated;
exit.peak = slot.peak;
*slot = exit;
} else {
self.exits.push(exit);
}
}
fn cancel(&mut self, id: &str) {
if self.pending.remove(id).is_some() {
self.order.retain(|o| o != id);
}
self.exits.retain(|e| e.id != id);
}
fn cancel_all(&mut self) {
self.pending.clear();
self.order.clear();
self.exits.clear();
}
fn advance(&mut self, bar: &Bar) {
self.bar_index = bar.index;
let ids: Vec<String> = self.order.clone();
for id in ids {
let Some(order) = self.pending.get(&id).cloned() else {
continue;
};
if self.pyramiding_blocks(&order) {
self.pending.remove(&id);
self.order.retain(|o| o != &id);
continue;
}
if let Some(price) = self.fills.fill(&order, bar) {
let qty = self.resolve_qty(&order, price);
if qty != 0.0 {
self.apply_fill(qty, price, &order.id, order.close_target.as_deref());
self.apply_oca(&order, qty);
}
self.pending.remove(&id);
self.order.retain(|o| o != &id);
}
}
self.evaluate_exits(bar);
}
fn position(&self) -> Position {
let size = self.net_size();
if size == 0.0 {
return Position::default();
}
let (value, qty): (f64, f64) = self
.open
.iter()
.filter(|t| t.size.signum() == size.signum())
.fold((0.0, 0.0), |(v, q), t| {
(v + t.entry_price * t.size, q + t.size)
});
Position {
size,
avg_price: if qty == 0.0 { 0.0 } else { value / qty },
}
}
fn initial_capital(&self) -> f64 {
self.initial
}
fn equity(&self, price: f64) -> f64 {
let unrealized: f64 = self
.open
.iter()
.map(|t| (price - t.entry_price) * t.size)
.sum();
self.cash + self.realized + unrealized
}
fn open_trades(&self) -> Vec<&Trade> {
self.open.iter().collect()
}
fn closed_trades(&self) -> &[Trade] {
&self.closed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Commission, Direction, OrderKind, PineFills};
fn bar(index: u64, open: f64, high: f64, low: f64, close: f64) -> Bar {
Bar {
open,
high,
low,
close,
volume: 0.0,
index,
..Bar::default()
}
}
fn broker() -> BarBroker<PineFills> {
BarBroker::new(PineFills::default(), 10_000.0)
}
#[test]
fn a_market_entry_fills_at_the_open() {
let mut b = broker();
b.submit(Order::market("long", Direction::Long, Some(2.0)));
b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
let pos = b.position();
assert_eq!(pos.size, 2.0);
assert_eq!(pos.avg_price, 100.0);
assert_eq!(b.equity(104.0), 10_008.0);
}
#[test]
fn a_closed_trade_keeps_its_size_and_profit() {
let mut b = broker();
b.submit(Order::market("L", Direction::Long, Some(2.0)));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
b.submit(Order {
reduce_only: true,
..Order::market("L", Direction::Short, None)
});
b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
let trade = &b.closed_trades()[0];
assert_eq!(trade.size, 2.0);
assert_eq!(trade.entry_price, 100.0);
assert_eq!(trade.exit_price, Some(110.0));
assert_eq!(trade.profit(0.0), 20.0); }
#[test]
fn closing_realises_profit_and_flattens() {
let mut b = broker();
b.submit(Order::market("long", Direction::Long, Some(1.0)));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
b.submit(Order {
reduce_only: true,
..Order::market("exit", Direction::Short, Some(1.0))
});
b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
assert!(b.position().is_flat());
assert_eq!(b.closed_trades().len(), 1);
assert_eq!(b.equity(110.0), 10_010.0);
}
#[test]
fn an_opposite_entry_reverses_the_position() {
let mut b = broker();
b.submit(Order::market("a", Direction::Long, Some(5.0)));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
b.submit(Order::market("b", Direction::Short, Some(5.0)));
b.advance(&bar(1, 100.0, 100.0, 100.0, 100.0));
assert_eq!(b.position().size, -5.0);
assert_eq!(b.closed_trades().len(), 1);
}
#[test]
fn a_buy_limit_waits_for_the_price() {
let mut b = broker();
b.submit(Order {
kind: OrderKind::Limit(95.0),
reverses: false,
..Order::market("buy", Direction::Long, Some(1.0))
});
b.advance(&bar(0, 100.0, 101.0, 96.0, 99.0));
assert!(b.position().is_flat());
b.advance(&bar(1, 97.0, 98.0, 94.0, 96.0));
assert_eq!(b.position().size, 1.0);
assert_eq!(b.position().avg_price, 95.0);
}
#[test]
fn commission_reduces_equity() {
let mut b = broker().with_commission(Commission::Percent(1.0));
b.submit(Order::market("long", Direction::Long, Some(1.0)));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
assert_eq!(b.equity(100.0), 9_999.0);
}
#[test]
fn a_take_profit_exit_closes_when_price_reaches_it() {
let mut b = broker();
b.submit(Order::market("L", Direction::Long, Some(1.0)));
b.submit_exit(Exit {
limit: Some(110.0),
..Exit::resting("X", Some("L".into()), None, None)
});
b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
assert_eq!(b.position().size, 1.0);
b.advance(&bar(1, 106.0, 112.0, 105.0, 108.0));
assert!(b.position().is_flat());
assert_eq!(b.closed_trades().len(), 1);
assert_eq!(b.equity(108.0), 10_010.0); }
#[test]
fn a_stop_loss_in_ticks_sits_a_distance_from_the_entry() {
let fills = PineFills {
slippage: 0.0,
mintick: 0.5,
};
let mut b = BarBroker::new(fills, 10_000.0).with_mintick(0.5);
b.submit(Order::market("L", Direction::Long, Some(1.0)));
b.submit_exit(Exit {
loss_ticks: Some(4.0),
..Exit::resting("X", Some("L".into()), None, None)
});
b.advance(&bar(0, 100.0, 105.0, 99.0, 104.0));
assert_eq!(b.position().size, 1.0);
b.advance(&bar(1, 100.0, 101.0, 97.0, 99.0));
assert!(b.position().is_flat());
assert_eq!(b.equity(99.0), 9_998.0); }
#[test]
fn close_targets_only_the_named_entry() {
let mut b = broker();
b.submit(Order {
reverses: false,
..Order::market("A", Direction::Long, Some(1.0))
});
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
b.submit(Order {
reverses: false,
..Order::market("B", Direction::Long, Some(1.0))
});
b.advance(&bar(1, 101.0, 101.0, 101.0, 101.0));
assert_eq!(b.position().size, 2.0);
b.submit(Order {
reduce_only: true,
close_target: Some("A".into()),
qty: None,
..Order::market("A", Direction::Long, None)
});
b.advance(&bar(2, 102.0, 102.0, 102.0, 102.0));
assert_eq!(b.position().size, 1.0);
assert_eq!(b.closed_trades().len(), 1);
assert_eq!(b.position().avg_price, 101.0); }
#[test]
fn cash_sizing_buys_contracts_worth_the_cash() {
let mut b = broker().with_sizing(Sizing::Cash(1_000.0));
b.submit(Order::market("L", Direction::Long, None));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
assert_eq!(b.position().size, 10.0);
}
#[test]
fn percent_of_equity_sizing_scales_with_the_account() {
let mut b = broker().with_sizing(Sizing::PercentOfEquity(50.0));
b.submit(Order::market("L", Direction::Long, None));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
assert_eq!(b.position().size, 50.0);
}
#[test]
fn pyramiding_caps_entries_in_one_direction() {
let mut b = broker().with_pyramiding(2);
for (i, id) in ["A", "B", "C"].iter().enumerate() {
b.submit(Order {
reverses: true,
..Order::market(*id, Direction::Long, Some(1.0))
});
b.advance(&bar(i as u64, 100.0, 100.0, 100.0, 100.0));
}
assert_eq!(b.position().size, 2.0);
}
#[test]
fn oca_cancel_removes_the_sibling_when_one_fills() {
let mut b = broker();
b.submit(Order {
kind: OrderKind::Stop(105.0),
oca_name: Some("G".into()),
oca_type: OcaType::Cancel,
..Order::market("up", Direction::Long, Some(1.0))
});
b.submit(Order {
kind: OrderKind::Limit(95.0),
oca_name: Some("G".into()),
oca_type: OcaType::Cancel,
..Order::market("down", Direction::Long, Some(1.0))
});
b.advance(&bar(0, 100.0, 106.0, 94.0, 100.0));
assert_eq!(b.position().size, 1.0);
}
#[test]
fn close_qty_percent_reduces_the_position() {
let mut b = broker();
b.submit(Order::market("L", Direction::Long, Some(4.0)));
b.advance(&bar(0, 100.0, 100.0, 100.0, 100.0));
b.submit(Order {
reduce_only: true,
close_target: Some("L".into()),
qty_percent: Some(50.0),
qty: None,
..Order::market("L", Direction::Long, None)
});
b.advance(&bar(1, 110.0, 110.0, 110.0, 110.0));
assert_eq!(b.position().size, 2.0);
assert_eq!(b.closed_trades().len(), 1);
assert_eq!(b.closed_trades()[0].size, 2.0);
assert_eq!(b.closed_trades()[0].profit(0.0), 20.0); assert_eq!(b.open_trades().len(), 1);
assert_eq!(b.open_trades()[0].size, 2.0);
}
#[test]
fn a_trailing_stop_follows_the_peak_and_fills_at_its_level() {
let fills = PineFills {
slippage: 0.0,
mintick: 0.5,
};
let mut b = BarBroker::new(fills, 10_000.0).with_mintick(0.5);
b.submit(Order::market("L", Direction::Long, Some(1.0)));
b.submit_exit(Exit {
trail_points: Some(4.0),
trail_offset: Some(2.0),
..Exit::resting("X", Some("L".into()), None, None)
});
b.advance(&bar(0, 100.0, 101.0, 99.0, 100.0));
assert_eq!(b.position().size, 1.0);
b.advance(&bar(1, 102.0, 105.0, 101.0, 104.0));
assert!(b.position().is_flat());
assert_eq!(b.closed_trades().len(), 1);
assert_eq!(b.equity(104.0), 10_004.0);
}
}