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
100/// How a risk threshold's value is measured (`strategy.risk.max_drawdown` and
101/// `strategy.risk.max_intraday_loss`).
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum RiskType {
104 /// A percentage of the reference (peak) equity.
105 Percent(f64),
106 /// A fixed cash amount.
107 Cash(f64),
108}
109
110impl RiskType {
111 /// The loss threshold in cash, given the `reference` equity a percentage is
112 /// taken against.
113 fn threshold(self, reference: f64) -> f64 {
114 match self {
115 RiskType::Percent(pct) => reference.abs() * pct / 100.0,
116 RiskType::Cash(cash) => cash,
117 }
118 }
119}
120
121/// Which entry directions `strategy.risk.allow_entry_in` permits.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum EntryFilter {
124 #[default]
125 All,
126 LongOnly,
127 ShortOnly,
128}
129
130impl EntryFilter {
131 /// Whether an entry in `direction` is allowed.
132 fn allows(self, direction: Direction) -> bool {
133 match self {
134 EntryFilter::All => true,
135 EntryFilter::LongOnly => direction == Direction::Long,
136 EntryFilter::ShortOnly => direction == Direction::Short,
137 }
138 }
139}
140
141/// A risk-management rule set by a `strategy.risk.*` call, applied to the broker.
142#[derive(Debug, Clone, Copy, PartialEq)]
143pub enum RiskRule {
144 /// Restrict entries to one direction (`allow_entry_in`).
145 AllowEntryIn(EntryFilter),
146 /// Cap the absolute position size in contracts (`max_position_size`).
147 MaxPositionSize(f64),
148 /// Halt the strategy once equity falls this far from its peak (`max_drawdown`).
149 MaxDrawdown(RiskType),
150 /// Halt for the rest of the day once equity falls this far from the day's
151 /// peak (`max_intraday_loss`).
152 MaxIntradayLoss(RiskType),
153 /// Halt after this many consecutive losing days (`max_cons_loss_days`).
154 MaxConsLossDays(u32),
155 /// Block new orders after this many fills in a day (`max_intraday_filled_orders`).
156 MaxIntradayFilledOrders(u32),
157}
158
159impl Sizing {
160 /// The contract count this sizing buys at `price`, given current `equity`.
161 fn contracts(self, price: f64, equity: f64) -> f64 {
162 match self {
163 Sizing::Contracts(c) => c,
164 Sizing::Cash(cash) if price > 0.0 => cash / price,
165 Sizing::PercentOfEquity(pct) if price > 0.0 => (pct / 100.0 * equity) / price,
166 _ => 0.0,
167 }
168 }
169}
170
171/// What happens to the other orders in a One-Cancels-All group when one of them
172/// fills, from `strategy.oca.*`.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum OcaType {
175 /// Not in an OCA group.
176 #[default]
177 None,
178 /// Cancel the group's other unfilled orders.
179 Cancel,
180 /// Shrink the group's other unfilled orders by the filled size.
181 Reduce,
182}
183
184impl From<&str> for OcaType {
185 /// From the `strategy.oca.*` constants; anything else is no group.
186 fn from(tag: &str) -> Self {
187 match tag {
188 "cancel" => OcaType::Cancel,
189 "reduce" => OcaType::Reduce,
190 _ => OcaType::None,
191 }
192 }
193}
194
195/// A submitted order, before it fills. Replaces any pending order with the same
196/// `id`, as Pine's order commands do.
197#[derive(Debug, Clone)]
198pub struct Order {
199 pub id: String,
200 pub direction: Direction,
201 /// Contracts to trade; `None` means the strategy's default quantity, which
202 /// the broker supplies.
203 pub qty: Option<f64>,
204 /// For a reducing order, the percentage of the position to close when `qty`
205 /// is absent (`strategy.close`'s `qty_percent`).
206 pub qty_percent: Option<f64>,
207 /// The price used to size a `cash`/`percent_of_equity` order without an
208 /// explicit `qty` — Pine sizes from the close of the bar the order is
209 /// generated on, not the later fill price. `None` falls back to the fill.
210 pub sizing_price: Option<f64>,
211 pub kind: OrderKind,
212 /// True for `strategy.close`/`close_all`: only ever shrinks the position.
213 pub reduce_only: bool,
214 /// `strategy.entry` reverses an opposite position and obeys pyramiding;
215 /// `strategy.order` does neither.
216 pub reverses: bool,
217 /// For a reducing order, the entry id whose lots it closes; `None` closes
218 /// the whole position (`strategy.close_all`).
219 pub close_target: Option<String>,
220 /// The One-Cancels-All group this order belongs to, if any.
221 pub oca_name: Option<String>,
222 pub oca_type: OcaType,
223 pub comment: String,
224}
225
226impl Order {
227 /// A market order to trade `qty` in `direction`, as `strategy.entry` makes.
228 pub fn market(id: impl Into<String>, direction: Direction, qty: Option<f64>) -> Self {
229 Self {
230 id: id.into(),
231 direction,
232 qty,
233 qty_percent: None,
234 sizing_price: None,
235 kind: OrderKind::Market,
236 reduce_only: false,
237 reverses: true,
238 close_target: None,
239 oca_name: None,
240 oca_type: OcaType::None,
241 comment: String::new(),
242 }
243 }
244}
245
246/// A stop-loss / take-profit bracket attached to a position, from
247/// `strategy.exit`. Its legs are evaluated each bar once the position exists;
248/// whichever fills first closes it and cancels the other (one-cancels-all).
249#[derive(Debug, Clone)]
250pub struct Exit {
251 pub id: String,
252 /// The entry whose position this exits; `None` exits the whole position.
253 pub from_entry: Option<String>,
254 /// Contracts to exit; `None` exits the whole matched position (or
255 /// `qty_percent` of it).
256 pub qty: Option<f64>,
257 /// Percentage of the matched position to exit when `qty` is absent.
258 pub qty_percent: Option<f64>,
259 /// Take-profit as a price (`limit`) or a distance in ticks from the entry
260 /// (`profit`). A price wins if both are given.
261 pub limit: Option<f64>,
262 pub profit_ticks: Option<f64>,
263 /// Stop-loss as a price (`stop`) or a distance in ticks (`loss`).
264 pub stop: Option<f64>,
265 pub loss_ticks: Option<f64>,
266 /// Trailing stop: it activates once price moves `trail_points` ticks past
267 /// the entry favourably, or touches `trail_price`, then trails
268 /// `trail_offset` ticks behind the best price reached.
269 pub trail_price: Option<f64>,
270 pub trail_points: Option<f64>,
271 pub trail_offset: Option<f64>,
272 /// Runtime state of the trailing stop, carried across bars: whether it has
273 /// activated and the best price seen since.
274 pub activated: bool,
275 pub peak: Option<f64>,
276}
277
278impl Exit {
279 /// A bracket with no trailing stop and no runtime state yet.
280 pub fn resting(
281 id: impl Into<String>,
282 from_entry: Option<String>,
283 qty: Option<f64>,
284 qty_percent: Option<f64>,
285 ) -> Self {
286 Self {
287 id: id.into(),
288 from_entry,
289 qty,
290 qty_percent,
291 limit: None,
292 profit_ticks: None,
293 stop: None,
294 loss_ticks: None,
295 trail_price: None,
296 trail_points: None,
297 trail_offset: None,
298 activated: false,
299 peak: None,
300 }
301 }
302}
303
304/// One trade: an entry, and its exit once closed. `size` is signed — positive is
305/// long, negative short — matching `strategy.*trades.size`.
306#[derive(Debug, Clone)]
307pub struct Trade {
308 pub entry_id: String,
309 pub size: f64,
310 pub entry_price: f64,
311 pub entry_bar: u64,
312 pub exit_price: Option<f64>,
313 pub exit_bar: Option<u64>,
314 /// Commission on entry, plus exit once closed.
315 pub commission: f64,
316}
317
318impl Trade {
319 /// Realised profit once closed, or profit at `price` while open.
320 pub fn profit(&self, price: f64) -> f64 {
321 let exit = self.exit_price.unwrap_or(price);
322 (exit - self.entry_price) * self.size - self.commission
323 }
324
325 pub fn is_open(&self) -> bool {
326 self.exit_price.is_none()
327 }
328}
329
330/// The current net position: signed size and the average price it was opened at.
331#[derive(Debug, Clone, Copy, Default)]
332pub struct Position {
333 /// Signed: positive long, negative short, zero flat.
334 pub size: f64,
335 pub avg_price: f64,
336}
337
338impl Position {
339 pub fn is_flat(&self) -> bool {
340 self.size == 0.0
341 }
342}
343
344/// The simulated broker a strategy trades against.
345///
346/// Driven one bar at a time: submit orders from the script body, then
347/// [`advance`](Broker::advance) to fill whatever the bar allows.
348pub trait Broker {
349 /// Submit an order, replacing any pending one with the same id.
350 fn submit(&mut self, order: Order);
351
352 /// Submit a stop-loss / take-profit bracket, replacing any with the same id.
353 fn submit_exit(&mut self, exit: Exit);
354
355 /// Cancel a pending order by id; a filled order is unaffected.
356 fn cancel(&mut self, id: &str);
357
358 /// Cancel every pending order.
359 fn cancel_all(&mut self);
360
361 /// Apply a risk-management rule (from a `strategy.risk.*` call).
362 fn set_risk(&mut self, rule: RiskRule);
363
364 /// Fill whatever `bar` allows, updating the position and trade log.
365 fn advance(&mut self, bar: &Bar);
366
367 /// The current net position.
368 fn position(&self) -> Position;
369
370 /// The capital the account started with, before any trade or commission.
371 fn initial_capital(&self) -> f64;
372
373 /// Account value: capital plus realised and unrealised profit, marked at
374 /// `price` (typically the latest close).
375 fn equity(&self, price: f64) -> f64;
376
377 /// Trades still open, oldest first.
378 fn open_trades(&self) -> Vec<&Trade>;
379
380 /// Trades already closed, in the order they closed.
381 fn closed_trades(&self) -> &[Trade];
382
383 /// The bar the run halted on if a rest-of-run risk rule fired
384 /// (`max_drawdown`, `max_cons_loss_days`), else `None`.
385 fn halted_bar(&self) -> Option<u64>;
386}
387
388/// The account settings a `strategy()` declaration configures its broker with,
389/// so a custom [`BrokerFactory`] can honour the script's parameters rather than
390/// inventing its own.
391#[derive(Debug, Clone, Copy, PartialEq)]
392pub struct BrokerConfig {
393 /// Starting capital (`strategy.initial_capital`).
394 pub initial_capital: f64,
395 /// The symbol's tick size, or 0 when unknown.
396 pub mintick: f64,
397 /// How an order's absent `qty` is sized.
398 pub sizing: Sizing,
399 /// How many entries in the same direction may stack (`pyramiding`).
400 pub pyramiding: usize,
401 /// Per-trade commission, or `None` when the script sets none.
402 pub commission: Option<Commission>,
403 /// Slippage applied to fills, in ticks.
404 pub slippage: f64,
405}
406
407/// Builds the [`Broker`] a `strategy` trades against. The default,
408/// [`DefaultBrokerFactory`], produces the built-in bar-fill broker; a host can
409/// supply its own to simulate against a different engine while still honouring
410/// the script's [`BrokerConfig`].
411pub trait BrokerFactory {
412 fn build(&self, config: &BrokerConfig) -> Box<dyn Broker>;
413}
414
415/// The built-in factory: a [`BarBroker`] with [`PineFills`], reproducing Pine's
416/// default fill model.
417pub struct DefaultBrokerFactory;
418
419impl BrokerFactory for DefaultBrokerFactory {
420 fn build(&self, config: &BrokerConfig) -> Box<dyn Broker> {
421 let fills = PineFills {
422 slippage: config.slippage,
423 mintick: config.mintick,
424 };
425 let mut broker = BarBroker::new(fills, config.initial_capital)
426 .with_mintick(config.mintick)
427 .with_sizing(config.sizing)
428 .with_pyramiding(config.pyramiding);
429 if let Some(commission) = config.commission {
430 broker = broker.with_commission(commission);
431 }
432 Box::new(broker)
433 }
434}