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}