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
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
384/// The account settings a `strategy()` declaration configures its broker with,
385/// so a custom [`BrokerFactory`] can honour the script's parameters rather than
386/// inventing its own.
387#[derive(Debug, Clone, Copy, PartialEq)]
388pub struct BrokerConfig {
389    /// Starting capital (`strategy.initial_capital`).
390    pub initial_capital: f64,
391    /// The symbol's tick size, or 0 when unknown.
392    pub mintick: f64,
393    /// How an order's absent `qty` is sized.
394    pub sizing: Sizing,
395    /// How many entries in the same direction may stack (`pyramiding`).
396    pub pyramiding: usize,
397    /// Per-trade commission, or `None` when the script sets none.
398    pub commission: Option<Commission>,
399    /// Slippage applied to fills, in ticks.
400    pub slippage: f64,
401}
402
403/// Builds the [`Broker`] a `strategy` trades against. The default,
404/// [`DefaultBrokerFactory`], produces the built-in bar-fill broker; a host can
405/// supply its own to simulate against a different engine while still honouring
406/// the script's [`BrokerConfig`].
407pub trait BrokerFactory {
408    fn build(&self, config: &BrokerConfig) -> Box<dyn Broker>;
409}
410
411/// The built-in factory: a [`BarBroker`] with [`PineFills`], reproducing Pine's
412/// default fill model.
413pub struct DefaultBrokerFactory;
414
415impl BrokerFactory for DefaultBrokerFactory {
416    fn build(&self, config: &BrokerConfig) -> Box<dyn Broker> {
417        let fills = PineFills {
418            slippage: config.slippage,
419            mintick: config.mintick,
420        };
421        let mut broker = BarBroker::new(fills, config.initial_capital)
422            .with_mintick(config.mintick)
423            .with_sizing(config.sizing)
424            .with_pyramiding(config.pyramiding);
425        if let Some(commission) = config.commission {
426            broker = broker.with_commission(commission);
427        }
428        Box::new(broker)
429    }
430}