Skip to main content

pine_broker/
lib.rs

1//! A simulated broker: the emulator a Pine `strategy` trades against.
2//!
3//! There is no order book and no exchange. A backtest replays historical bars
4//! and asks, bar by bar, what *would* have happened — so filling an order is a
5//! modelling assumption ([`FillModel`]), not a match against a real resting
6//! order. Everything else — position, average price, commission, the trade log,
7//! equity — is plain accounting that does not depend on the venue, so there is
8//! one [`BarBroker`], not one per exchange.
9//!
10//! Placing real orders is deliberately out of scope: in Pine that happens
11//! outside the strategy, when an alert is delivered to an external system. This
12//! crate only simulates.
13
14use pine_core::Bar;
15
16mod broker;
17mod fill;
18
19pub use broker::BarBroker;
20pub use fill::{FillModel, PineFills};
21
22/// Long or short.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Direction {
25    Long,
26    Short,
27}
28
29impl Direction {
30    /// +1 for long, -1 for short — the sign a position of this direction has.
31    pub fn sign(self) -> f64 {
32        match self {
33            Direction::Long => 1.0,
34            Direction::Short => -1.0,
35        }
36    }
37}
38
39impl From<&str> for Direction {
40    /// From the `strategy.long`/`strategy.short` constants; anything else long.
41    fn from(tag: &str) -> Self {
42        if tag == "short" {
43            Direction::Short
44        } else {
45            Direction::Long
46        }
47    }
48}
49
50/// The price condition that decides when an order fills.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum OrderKind {
53    /// Fills at the next bar's open (or this bar's close under
54    /// `process_orders_on_close`).
55    Market,
56    /// Fills when price reaches `price` or better.
57    Limit(f64),
58    /// Fills when price reaches `price` or worse.
59    Stop(f64),
60    /// A limit at `limit`, armed once price reaches `stop`.
61    StopLimit { stop: f64, limit: f64 },
62}
63
64/// How a `strategy` declaration charges commission.
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum Commission {
67    /// A percentage of the traded value.
68    Percent(f64),
69    /// A fixed amount per contract traded.
70    CashPerContract(f64),
71    /// A fixed amount per order.
72    CashPerOrder(f64),
73}
74
75impl Commission {
76    /// The commission on filling `qty` contracts at `price`.
77    fn charge(self, qty: f64, price: f64) -> f64 {
78        match self {
79            Commission::Percent(pct) => qty.abs() * price * pct / 100.0,
80            Commission::CashPerContract(cash) => qty.abs() * cash,
81            Commission::CashPerOrder(cash) => cash,
82        }
83    }
84}
85
86/// How an order without an explicit `qty` is sized, from the `strategy`
87/// declaration's `default_qty_type`/`default_qty_value`.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum Sizing {
90    /// A fixed number of contracts (`strategy.fixed`).
91    Contracts(f64),
92    /// A fixed amount of cash, converted to contracts at the fill price
93    /// (`strategy.cash`).
94    Cash(f64),
95    /// A percentage of current equity, converted at the fill price
96    /// (`strategy.percent_of_equity`).
97    PercentOfEquity(f64),
98}
99
100impl Sizing {
101    /// The contract count this sizing buys at `price`, given current `equity`.
102    fn contracts(self, price: f64, equity: f64) -> f64 {
103        match self {
104            Sizing::Contracts(c) => c,
105            Sizing::Cash(cash) if price > 0.0 => cash / price,
106            Sizing::PercentOfEquity(pct) if price > 0.0 => (pct / 100.0 * equity) / price,
107            _ => 0.0,
108        }
109    }
110}
111
112/// What happens to the other orders in a One-Cancels-All group when one of them
113/// fills, from `strategy.oca.*`.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum OcaType {
116    /// Not in an OCA group.
117    #[default]
118    None,
119    /// Cancel the group's other unfilled orders.
120    Cancel,
121    /// Shrink the group's other unfilled orders by the filled size.
122    Reduce,
123}
124
125impl From<&str> for OcaType {
126    /// From the `strategy.oca.*` constants; anything else is no group.
127    fn from(tag: &str) -> Self {
128        match tag {
129            "cancel" => OcaType::Cancel,
130            "reduce" => OcaType::Reduce,
131            _ => OcaType::None,
132        }
133    }
134}
135
136/// A submitted order, before it fills. Replaces any pending order with the same
137/// `id`, as Pine's order commands do.
138#[derive(Debug, Clone)]
139pub struct Order {
140    pub id: String,
141    pub direction: Direction,
142    /// Contracts to trade; `None` means the strategy's default quantity, which
143    /// the broker supplies.
144    pub qty: Option<f64>,
145    /// For a reducing order, the percentage of the position to close when `qty`
146    /// is absent (`strategy.close`'s `qty_percent`).
147    pub qty_percent: Option<f64>,
148    /// The price used to size a `cash`/`percent_of_equity` order without an
149    /// explicit `qty` — Pine sizes from the close of the bar the order is
150    /// generated on, not the later fill price. `None` falls back to the fill.
151    pub sizing_price: Option<f64>,
152    pub kind: OrderKind,
153    /// True for `strategy.close`/`close_all`: only ever shrinks the position.
154    pub reduce_only: bool,
155    /// `strategy.entry` reverses an opposite position and obeys pyramiding;
156    /// `strategy.order` does neither.
157    pub reverses: bool,
158    /// For a reducing order, the entry id whose lots it closes; `None` closes
159    /// the whole position (`strategy.close_all`).
160    pub close_target: Option<String>,
161    /// The One-Cancels-All group this order belongs to, if any.
162    pub oca_name: Option<String>,
163    pub oca_type: OcaType,
164    pub comment: String,
165}
166
167impl Order {
168    /// A market order to trade `qty` in `direction`, as `strategy.entry` makes.
169    pub fn market(id: impl Into<String>, direction: Direction, qty: Option<f64>) -> Self {
170        Self {
171            id: id.into(),
172            direction,
173            qty,
174            qty_percent: None,
175            sizing_price: None,
176            kind: OrderKind::Market,
177            reduce_only: false,
178            reverses: true,
179            close_target: None,
180            oca_name: None,
181            oca_type: OcaType::None,
182            comment: String::new(),
183        }
184    }
185}
186
187/// A stop-loss / take-profit bracket attached to a position, from
188/// `strategy.exit`. Its legs are evaluated each bar once the position exists;
189/// whichever fills first closes it and cancels the other (one-cancels-all).
190#[derive(Debug, Clone)]
191pub struct Exit {
192    pub id: String,
193    /// The entry whose position this exits; `None` exits the whole position.
194    pub from_entry: Option<String>,
195    /// Contracts to exit; `None` exits the whole matched position (or
196    /// `qty_percent` of it).
197    pub qty: Option<f64>,
198    /// Percentage of the matched position to exit when `qty` is absent.
199    pub qty_percent: Option<f64>,
200    /// Take-profit as a price (`limit`) or a distance in ticks from the entry
201    /// (`profit`). A price wins if both are given.
202    pub limit: Option<f64>,
203    pub profit_ticks: Option<f64>,
204    /// Stop-loss as a price (`stop`) or a distance in ticks (`loss`).
205    pub stop: Option<f64>,
206    pub loss_ticks: Option<f64>,
207    /// Trailing stop: it activates once price moves `trail_points` ticks past
208    /// the entry favourably, or touches `trail_price`, then trails
209    /// `trail_offset` ticks behind the best price reached.
210    pub trail_price: Option<f64>,
211    pub trail_points: Option<f64>,
212    pub trail_offset: Option<f64>,
213    /// Runtime state of the trailing stop, carried across bars: whether it has
214    /// activated and the best price seen since.
215    pub activated: bool,
216    pub peak: Option<f64>,
217}
218
219impl Exit {
220    /// A bracket with no trailing stop and no runtime state yet.
221    pub fn resting(
222        id: impl Into<String>,
223        from_entry: Option<String>,
224        qty: Option<f64>,
225        qty_percent: Option<f64>,
226    ) -> Self {
227        Self {
228            id: id.into(),
229            from_entry,
230            qty,
231            qty_percent,
232            limit: None,
233            profit_ticks: None,
234            stop: None,
235            loss_ticks: None,
236            trail_price: None,
237            trail_points: None,
238            trail_offset: None,
239            activated: false,
240            peak: None,
241        }
242    }
243}
244
245/// One trade: an entry, and its exit once closed. `size` is signed — positive is
246/// long, negative short — matching `strategy.*trades.size`.
247#[derive(Debug, Clone)]
248pub struct Trade {
249    pub entry_id: String,
250    pub size: f64,
251    pub entry_price: f64,
252    pub entry_bar: u64,
253    pub exit_price: Option<f64>,
254    pub exit_bar: Option<u64>,
255    /// Commission on entry, plus exit once closed.
256    pub commission: f64,
257}
258
259impl Trade {
260    /// Realised profit once closed, or profit at `price` while open.
261    pub fn profit(&self, price: f64) -> f64 {
262        let exit = self.exit_price.unwrap_or(price);
263        (exit - self.entry_price) * self.size - self.commission
264    }
265
266    pub fn is_open(&self) -> bool {
267        self.exit_price.is_none()
268    }
269}
270
271/// The current net position: signed size and the average price it was opened at.
272#[derive(Debug, Clone, Copy, Default)]
273pub struct Position {
274    /// Signed: positive long, negative short, zero flat.
275    pub size: f64,
276    pub avg_price: f64,
277}
278
279impl Position {
280    pub fn is_flat(&self) -> bool {
281        self.size == 0.0
282    }
283}
284
285/// The simulated broker a strategy trades against.
286///
287/// Driven one bar at a time: submit orders from the script body, then
288/// [`advance`](Broker::advance) to fill whatever the bar allows.
289pub trait Broker {
290    /// Submit an order, replacing any pending one with the same id.
291    fn submit(&mut self, order: Order);
292
293    /// Submit a stop-loss / take-profit bracket, replacing any with the same id.
294    fn submit_exit(&mut self, exit: Exit);
295
296    /// Cancel a pending order by id; a filled order is unaffected.
297    fn cancel(&mut self, id: &str);
298
299    /// Cancel every pending order.
300    fn cancel_all(&mut self);
301
302    /// Fill whatever `bar` allows, updating the position and trade log.
303    fn advance(&mut self, bar: &Bar);
304
305    /// The current net position.
306    fn position(&self) -> Position;
307
308    /// The capital the account started with, before any trade or commission.
309    fn initial_capital(&self) -> f64;
310
311    /// Account value: capital plus realised and unrealised profit, marked at
312    /// `price` (typically the latest close).
313    fn equity(&self, price: f64) -> f64;
314
315    /// Trades still open, oldest first.
316    fn open_trades(&self) -> Vec<&Trade>;
317
318    /// Trades already closed, in the order they closed.
319    fn closed_trades(&self) -> &[Trade];
320}