Skip to main content

egui_charts/ui/trading/ticket/
mod.rs

1//! The order ticket: a panel beside the chart with an Order tab, one order sized by the symbol's
2//! trading rules with optional exits, and a DOM tab, the depth ladder.
3
4use serde::{Deserialize, Serialize};
5
6mod form;
7mod view;
8
9use super::dom::Ladder;
10use super::format::{Numbers, TradingFormatter};
11use super::widgets::{grouped, side_name};
12use crate::chart::TradingLineAction;
13use crate::model::{
14    Capabilities, Order, OrderBook, OrderId, OrderPreview, OrderRequest, OrderSide, OrderType,
15    Position, TakeProfitLevel, Tick, TimeInForce, TradingRules,
16};
17use crate::scales::{decimals, to_step};
18
19/// How a take-profit or stop-loss distance is entered.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub enum BracketMode {
22    /// The exit price itself.
23    #[default]
24    Price,
25    /// Ticks of the symbol's tick size away from the entry.
26    Ticks,
27    /// Percent of the entry price away from the entry.
28    Percent,
29    /// The profit or loss at the exit in the account's currency.
30    Money,
31}
32
33impl BracketMode {
34    const ALL: [Self; 4] = [Self::Price, Self::Ticks, Self::Percent, Self::Money];
35
36    fn name(self, currency: &str) -> &str {
37        match self {
38            Self::Price => "price",
39            Self::Ticks => "ticks",
40            Self::Percent => "%",
41            Self::Money if currency.is_empty() => "money",
42            Self::Money => currency,
43        }
44    }
45}
46
47/// A take-profit or stop-loss as entered in the ticket.
48#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
49pub struct Bracket {
50    /// How [`value`](Self::value) is read.
51    pub mode: BracketMode,
52    /// A price, a number of ticks, a percentage or an amount of money.
53    pub value: f64,
54}
55
56impl Bracket {
57    /// The direction from the entry to the exit: up for a take profit (`profit`) of a buy and a
58    /// stop loss of a sell, down for the other two.
59    fn sign(side: OrderSide, profit: bool) -> f64 {
60        if (side == OrderSide::Buy) == profit {
61            1.0
62        } else {
63            -1.0
64        }
65    }
66
67    /// The exit price for an order on `side` entering at `entry`, where `per_point` is the profit
68    /// or loss of the order's quantity per price unit: a take profit (`profit`) lies above the
69    /// entry of a buy and below that of a sell, a stop loss the other way.
70    pub(crate) fn price(
71        &self,
72        side: OrderSide,
73        entry: f64,
74        tick: f64,
75        per_point: f64,
76        profit: bool,
77    ) -> f64 {
78        let sign = Self::sign(side, profit);
79        match self.mode {
80            BracketMode::Price => self.value,
81            BracketMode::Ticks => entry + sign * self.value * tick,
82            BracketMode::Percent => entry * (1.0 + sign * self.value / 100.0),
83            BracketMode::Money => entry + sign * self.value / per_point,
84        }
85    }
86
87    /// The bracket in `mode` whose [`price`](Self::price) is `price`.
88    fn at(
89        mode: BracketMode,
90        price: f64,
91        side: OrderSide,
92        entry: f64,
93        tick: f64,
94        per_point: f64,
95        profit: bool,
96    ) -> Self {
97        let distance = Self::sign(side, profit) * (price - entry);
98        let value = match mode {
99            BracketMode::Price => price,
100            BracketMode::Ticks => (distance / tick).round(),
101            BracketMode::Percent => distance / entry * 100.0,
102            BracketMode::Money => distance * per_point,
103        };
104        Self { mode, value }
105    }
106}
107
108/// A named quantity and brackets the ticket applies in one step. Hosts persist
109/// [`Trading::presets`](super::Trading::presets) themselves.
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111pub struct OrderPreset {
112    /// Shown in the ticket's preset list.
113    pub name: String,
114    /// In the symbol's trading units.
115    pub quantity: f64,
116    /// The take profit, if the preset sets one.
117    pub take_profit: Option<Bracket>,
118    /// The stop loss, if the preset sets one.
119    pub stop_loss: Option<Bracket>,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123enum Kind {
124    Market,
125    Limit,
126    Stop,
127    StopLimit,
128}
129
130impl Kind {
131    const ALL: [(Self, &'static str); 4] = [
132        (Self::Market, "Market"),
133        (Self::Limit, "Limit"),
134        (Self::Stop, "Stop"),
135        (Self::StopLimit, "Stop limit"),
136    ];
137
138    fn accepted(self, capabilities: &Capabilities) -> bool {
139        match self {
140            Self::Market => capabilities.market,
141            Self::Limit => capabilities.limit,
142            Self::Stop => capabilities.stop,
143            Self::StopLimit => capabilities.stop_limit,
144        }
145    }
146}
147
148/// How the quantity is entered.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150enum Sizing {
151    /// The quantity itself.
152    Units,
153    /// The order's value in the account's currency.
154    Value,
155    /// The loss at the stop loss, in percent of the account's balance.
156    Risk,
157}
158
159/// The ticket's two tabs.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161enum Tab {
162    Order,
163    Dom,
164}
165
166/// What the ticket needs from the chart and the account for one frame.
167pub(crate) struct Market<'a> {
168    /// The chart's symbol, which the host's formatter receives with each number.
169    pub symbol: &'a str,
170    pub formatter: Option<&'a dyn TradingFormatter>,
171    /// The value in the account's currency of one pip for one unit, if known.
172    pub pip_value: Option<f64>,
173    /// The last price, the entry of a market order without a quote.
174    pub last: Option<f64>,
175    /// The best bid and ask, the entries of a market sell and buy.
176    pub bid: Option<f64>,
177    pub ask: Option<f64>,
178    /// The symbol's tick size.
179    pub tick: f64,
180    /// Decimals of prices.
181    pub decimals: usize,
182    /// The account's currency.
183    pub currency: &'a str,
184    /// The account's balance: risk sizing takes a percentage of it,
185    /// and an order's margin is set against it.
186    pub balance: Option<f64>,
187    pub capabilities: Capabilities,
188    pub rules: &'a TradingRules,
189    pub rules_ready: bool,
190    /// The broker's status while it reports that it is not live, which the header shows; the
191    /// ticket sends nothing meanwhile. `None` while it is live, before it reports a status and
192    /// without a broker.
193    pub offline: Option<crate::ui::model::ConnectionState>,
194    /// The sizes the depth ladder shows: the book summed over the orders the chart's order book
195    /// view accepts.
196    pub ladder: Option<std::borrow::Cow<'a, OrderBook>>,
197    /// Why the ladder has no sizes when the view needs orders the feed does not send.
198    pub ladder_unavailable: Option<crate::data::OrderFilterUnavailable>,
199    /// Prices whose largest known individual order reaches a tier of the view, as (is bid,
200    /// price, colour).
201    pub highlights: Vec<(bool, f64, egui::Color32)>,
202    /// The account's working orders and open positions, of every symbol.
203    pub orders: &'a [Order],
204    pub positions: &'a [Position],
205    /// The retained feed trades, oldest first.
206    pub trades: &'a [&'a Tick],
207    /// Opening instant of the exchange session of the newest retained trade.
208    pub session_open: Option<chrono::DateTime<chrono::Utc>>,
209    /// The latest account fill for this symbol.
210    pub last_fill: Option<f64>,
211    /// URI of the symbol logo, from the chart instrument or the host description of the symbol.
212    pub logo: Option<&'a str>,
213}
214
215impl<'a> Market<'a> {
216    /// How the numbers of the chart's symbol are written.
217    pub fn numbers(&self) -> Numbers<'a> {
218        Numbers {
219            symbol: self.symbol,
220            decimals: self.decimals,
221            formatter: self.formatter,
222        }
223    }
224}
225
226/// What the user asked the ticket for.
227#[derive(Debug, PartialEq)]
228pub(crate) enum Action {
229    /// Send this order now.
230    Place(OrderRequest),
231    /// Ask the broker to estimate this order without placing it.
232    Preview(OrderRequest),
233    /// Cancel these working orders now.
234    Cancel(Vec<OrderId>),
235    /// Ask the user to confirm this change before sending it.
236    Confirm(Box<TradingLineAction>),
237}
238
239/// The state of the order ticket panel.
240pub(crate) struct OrderTicket {
241    pub open: bool,
242    pub symbol: String,
243    side: OrderSide,
244    kind: Kind,
245    /// The limit or stop price, and the stop price of a stop-limit order.
246    price: f64,
247    /// The limit price of a stop-limit order.
248    limit: f64,
249    /// The price field takes ticks from the ask or bid instead of the price.
250    price_in_ticks: bool,
251    sizing: Sizing,
252    quantity: f64,
253    /// The value or risk entered for [`Sizing::Value`] and [`Sizing::Risk`].
254    amount: f64,
255    take_profit: (bool, Bracket),
256    /// The take-profit levels after the first, each with its share of the order quantity in
257    /// percent. The first level, `take_profit`, takes the rest.
258    levels: Vec<(Bracket, f64)>,
259    stop_loss: (bool, Bracket),
260    time_in_force: TimeInForce,
261    preset_name: String,
262    tab: Tab,
263    exits_open: bool,
264    ladder: Ladder,
265    /// An order from the ladder, waiting for the user to confirm it.
266    confirm: Option<OrderRequest>,
267    cancel: Option<Vec<OrderId>>,
268    /// Why the ladder refused its last order, shown on the DOM tab.
269    dom_error: Option<String>,
270}
271
272impl Default for OrderTicket {
273    fn default() -> Self {
274        let bracket = Bracket {
275            mode: BracketMode::Price,
276            value: 0.0,
277        };
278        Self {
279            open: false,
280            symbol: String::new(),
281            side: OrderSide::Buy,
282            kind: Kind::Market,
283            price: 0.0,
284            limit: 0.0,
285            price_in_ticks: false,
286            sizing: Sizing::Units,
287            quantity: 1.0,
288            amount: 1_000.0,
289            take_profit: (false, bracket),
290            levels: Vec::new(),
291            stop_loss: (false, bracket),
292            time_in_force: TimeInForce::Day,
293            preset_name: String::new(),
294            tab: Tab::Order,
295            exits_open: true,
296            ladder: Ladder::default(),
297            confirm: None,
298            cancel: None,
299            dom_error: None,
300        }
301    }
302}
303
304impl OrderTicket {
305    /// Fills the ticket from `request` and opens it on its Order tab.
306    pub fn open(&mut self, request: &OrderRequest) {
307        self.confirm = None;
308        self.cancel = None;
309        if self.symbol != request.symbol {
310            self.ladder = Ladder::default();
311            self.take_profit = (
312                false,
313                Bracket {
314                    mode: BracketMode::Price,
315                    value: 0.0,
316                },
317            );
318            self.stop_loss = self.take_profit;
319        }
320        self.open = true;
321        self.tab = Tab::Order;
322        self.symbol.clone_from(&request.symbol);
323        self.side = request.side;
324        self.sizing = Sizing::Units;
325        self.quantity = request.quantity;
326        (self.kind, self.price, self.limit) = match request.order_type {
327            OrderType::Limit(price) => (Kind::Limit, price, price),
328            OrderType::Stop(price) => (Kind::Stop, price, price),
329            OrderType::StopLimit { stop, limit } => (Kind::StopLimit, stop, limit),
330            _ => (Kind::Market, 0.0, 0.0),
331        };
332        self.time_in_force = request.time_in_force;
333        let at_price = |value| Bracket {
334            mode: BracketMode::Price,
335            value,
336        };
337        let (first, rest) = match request.take_profit_levels.split_first() {
338            Some((first, rest)) => (Some(first.price), rest),
339            None => (request.take_profit, &[][..]),
340        };
341        self.levels = rest
342            .iter()
343            .map(|level| {
344                let share = level.quantity / request.quantity * 100.0;
345                (at_price(level.price), share)
346            })
347            .collect();
348        for (target, value) in [
349            (&mut self.take_profit, first),
350            (&mut self.stop_loss, request.stop_loss),
351        ] {
352            target.0 = value.is_some();
353            if let Some(value) = value {
354                target.1 = at_price(value);
355            }
356        }
357    }
358
359    /// Discards price and confirmation state when the chart changes symbols.
360    pub(super) fn change_symbol(&mut self, symbol: &str) {
361        let request = OrderRequest::new(symbol, self.side, self.quantity, OrderType::Market);
362        self.open(&request);
363    }
364
365    /// The ask for a buy and the bid for a sell, if the market quotes them.
366    fn touch(&self, market: &Market<'_>) -> Option<f64> {
367        match self.side {
368            OrderSide::Buy => market.ask,
369            OrderSide::Sell => market.bid,
370        }
371        .filter(|price| price.is_finite())
372    }
373
374    /// The price the order enters at: its limit price, its stop price, or for a market order the
375    /// ask of a buy or the bid of a sell, or the last price without them.
376    fn entry(&self, market: &Market<'_>) -> Option<f64> {
377        match self.kind {
378            Kind::Market => self.touch(market).or(market.last),
379            Kind::Limit | Kind::Stop => Some(self.price),
380            Kind::StopLimit => Some(self.limit),
381        }
382        .filter(|price| price.is_finite() && *price > 0.0)
383    }
384
385    /// The enabled take profit or stop loss of an order entering at `entry`, snapped to the tick,
386    /// or none without brackets. Fails when it is not on its side of the entry. Without a
387    /// `quantity`, a stop loss in money cannot be priced.
388    fn exit(
389        &self,
390        market: &Market<'_>,
391        side: OrderSide,
392        entry: f64,
393        quantity: Option<f64>,
394        profit: bool,
395    ) -> Result<Option<f64>, String> {
396        let (enabled, bracket) = if profit {
397            self.take_profit
398        } else {
399            self.stop_loss
400        };
401        if !(market.capabilities.brackets && enabled) {
402            return Ok(None);
403        }
404        let per_point = match (bracket.mode, quantity) {
405            (BracketMode::Money, None) => {
406                return Err("Sizing by risk needs the stop loss as a price, ticks or %".into());
407            }
408            (_, quantity) => quantity.unwrap_or(1.0) * market.rules.point_value,
409        };
410        exit_price(market, side, entry, bracket, per_point, profit).map(Some)
411    }
412
413    /// The enabled take profits of an order of `quantity` entering at `entry`: one price, or
414    /// for an account that accepts levels and a ticket with more than one, the levels, each
415    /// rounded down to the quantity step with the rest on the first. Fails like `exit`, or when
416    /// a share is not above 0 or a level falls below the minimum quantity.
417    fn take_profits(
418        &self,
419        market: &Market<'_>,
420        side: OrderSide,
421        entry: f64,
422        quantity: f64,
423    ) -> Result<(Option<f64>, Vec<TakeProfitLevel>), String> {
424        let capabilities = &market.capabilities;
425        if !(self.take_profit.0 && capabilities.brackets && capabilities.take_profit_levels)
426            || self.levels.is_empty()
427        {
428            let take_profit = self.exit(market, side, entry, Some(quantity), true)?;
429            return Ok((take_profit, Vec::new()));
430        }
431        let rules = market.rules;
432        let mut levels = Vec::with_capacity(self.levels.len() + 1);
433        levels.push((self.take_profit.1, 0.0));
434        for (bracket, share) in &self.levels {
435            if !share.is_finite() || *share <= 0.0 {
436                return Err("Enter a share above 0% for each take-profit level".into());
437            }
438            levels.push((
439                *bracket,
440                round_down(quantity * share / 100.0, rules.quantity_step),
441            ));
442        }
443        let extra: f64 = levels.iter().map(|(_, quantity)| quantity).sum();
444        levels[0].1 = to_step(quantity - extra, rules.quantity_step, f64::round);
445        let levels = levels
446            .into_iter()
447            .map(|(bracket, quantity)| {
448                if quantity < rules.min_quantity {
449                    return Err(format!(
450                        "Each take-profit level needs at least the minimum quantity of {}",
451                        rules.min_quantity
452                    ));
453                }
454                let per_point = quantity * rules.point_value;
455                let price = exit_price(market, side, entry, bracket, per_point, true)?;
456                Ok(TakeProfitLevel::new(price, quantity))
457            })
458            .collect::<Result<_, String>>()?;
459        Ok((None, levels))
460    }
461
462    /// `order` from the depth ladder with the ticket's enabled take profit and stop loss, priced
463    /// from the order's own side, entry and quantity.
464    fn with_brackets(
465        &self,
466        mut order: OrderRequest,
467        market: &Market<'_>,
468    ) -> Result<OrderRequest, String> {
469        let entry = match order.order_type {
470            OrderType::Limit(price) | OrderType::Stop(price) => Some(price),
471            OrderType::StopLimit { limit, .. } => Some(limit),
472            OrderType::Market => match order.side {
473                OrderSide::Buy => market.ask,
474                OrderSide::Sell => market.bid,
475            }
476            .or(market.last),
477        }
478        .map(|price| to_step(price, market.tick, f64::round))
479        .filter(|price| price.is_finite() && *price > 0.0)
480        .ok_or("Enter a positive price")?;
481        order.stop_loss = self.exit(market, order.side, entry, Some(order.quantity), false)?;
482        (order.take_profit, order.take_profit_levels) =
483            self.take_profits(market, order.side, entry, order.quantity)?;
484        Ok(order)
485    }
486
487    /// The order the ticket describes, or what is wrong with it.
488    pub fn request(&self, market: &Market<'_>) -> Result<OrderRequest, String> {
489        if market.offline.is_some() {
490            return Err("Waiting for the broker to connect".into());
491        }
492        if !market.rules_ready {
493            return Err("Waiting for the symbol's trading rules".into());
494        }
495        if !self.kind.accepted(&market.capabilities) {
496            return Err("The account does not accept this order type".into());
497        }
498        let snap = |value| to_step(value, market.tick, f64::round);
499        let entry = self
500            .entry(market)
501            .map(snap)
502            .filter(|price| price.is_finite() && *price > 0.0)
503            .ok_or("Enter a positive price")?;
504        let order_type = match self.kind {
505            Kind::Market => OrderType::Market,
506            Kind::Limit => OrderType::Limit(snap(self.price)),
507            Kind::Stop => OrderType::Stop(snap(self.price)),
508            Kind::StopLimit if self.price.is_finite() && snap(self.price) > 0.0 => {
509                OrderType::StopLimit {
510                    stop: snap(self.price),
511                    limit: snap(self.limit),
512                }
513            }
514            Kind::StopLimit => return Err("Enter a positive stop price".into()),
515        };
516        let rules = market.rules;
517        // Units and value size the order before its exits; risk sizing needs the stop loss first.
518        let mut quantity = match self.sizing {
519            Sizing::Units => Some(order_quantity(self.quantity, rules)?),
520            Sizing::Value => Some(order_quantity(
521                self.amount / (entry * rules.point_value),
522                rules,
523            )?),
524            Sizing::Risk => None,
525        };
526        let stop_loss = self.exit(market, self.side, entry, quantity, false)?;
527        let quantity = match quantity.take() {
528            Some(quantity) => quantity,
529            None => {
530                let stop = stop_loss.ok_or("Sizing by risk needs a stop loss")?;
531                let balance = market
532                    .balance
533                    .filter(|balance| balance.is_finite() && *balance > 0.0)
534                    .ok_or("Sizing by risk needs the account's balance")?;
535                let risk = balance * self.amount / 100.0;
536                order_quantity(risk / ((entry - stop).abs() * rules.point_value), rules)?
537            }
538        };
539        let (take_profit, levels) = self.take_profits(market, self.side, entry, quantity)?;
540
541        let mut request = OrderRequest::new(&self.symbol, self.side, quantity, order_type);
542        request.take_profit = take_profit;
543        request.take_profit_levels = levels;
544        request.stop_loss = stop_loss;
545        request.time_in_force = if rules.time_in_force.contains(&self.time_in_force) {
546            self.time_in_force
547        } else {
548            rules.time_in_force[0]
549        };
550        Ok(request)
551    }
552
553    fn apply(&mut self, preset: &OrderPreset) {
554        self.sizing = Sizing::Units;
555        self.quantity = preset.quantity;
556        self.take_profit = preset
557            .take_profit
558            .map_or((false, self.take_profit.1), |bracket| (true, bracket));
559        self.levels.clear();
560        self.stop_loss = preset
561            .stop_loss
562            .map_or((false, self.stop_loss.1), |bracket| (true, bracket));
563    }
564
565    fn preset(&self, name: String, market: &Market<'_>) -> OrderPreset {
566        let quantity = self
567            .request(market)
568            .map_or(self.quantity, |request| request.quantity);
569        OrderPreset {
570            name,
571            quantity,
572            take_profit: self.take_profit.0.then_some(self.take_profit.1),
573            stop_loss: self.stop_loss.0.then_some(self.stop_loss.1),
574        }
575    }
576}
577
578/// The price of `bracket` for an order on `side` entering at `entry`, snapped to the tick, where
579/// `per_point` is the profit or loss of the exit's quantity per price unit. Fails when it is not
580/// on its side of the entry.
581fn exit_price(
582    market: &Market<'_>,
583    side: OrderSide,
584    entry: f64,
585    bracket: Bracket,
586    per_point: f64,
587    profit: bool,
588) -> Result<f64, String> {
589    let price = to_step(
590        bracket.price(side, entry, market.tick, per_point, profit),
591        market.tick,
592        f64::round,
593    );
594    // A buy exits above its entry for a profit and below it for a loss; a sell the other way.
595    let above = (price > entry) == (side == OrderSide::Buy);
596    if !price.is_finite() || price <= 0.0 || price == entry || above != profit {
597        return Err(if profit {
598            "The take profit must lie on the profit side of the entry"
599        } else {
600            "The stop loss must lie on the loss side of the entry"
601        }
602        .into());
603    }
604    Ok(price)
605}
606
607/// The distance from `entry` to each take profit times the quantity it closes, or none
608/// without a take profit: the reward per unit of point value.
609fn reward(request: &OrderRequest, entry: f64) -> Option<f64> {
610    if request.take_profit_levels.is_empty() {
611        let price = request.take_profit?;
612        return Some((price - entry).abs() * request.quantity);
613    }
614    let levels = request.take_profit_levels.iter();
615    Some(
616        levels
617            .map(|level| (level.price - entry).abs() * level.quantity)
618            .sum(),
619    )
620}
621
622/// `quantity` rounded down to a whole multiple of `step`, at the step's decimals.
623fn round_down(quantity: f64, step: f64) -> f64 {
624    // The tolerance keeps 0.3 / 0.1 = 2.9999999999999996 at 3 steps.
625    to_step(quantity, step, |steps| (steps + 1e-9).floor())
626}
627
628/// Rounds a quantity down to the symbol's step and checks its bounds.
629pub(super) fn order_quantity(quantity: f64, rules: &TradingRules) -> Result<f64, String> {
630    let quantity = round_down(quantity, rules.quantity_step);
631    if !quantity.is_finite() || quantity < rules.min_quantity {
632        return Err(format!(
633            "The quantity is below the minimum of {}",
634            rules.min_quantity
635        ));
636    }
637    if rules.max_quantity.is_some_and(|max| quantity > max) {
638        return Err(format!(
639            "The quantity is above the maximum of {}",
640            rules.max_quantity.unwrap_or_default()
641        ));
642    }
643    Ok(quantity)
644}
645
646/// An order as the send button and the confirmation write it: "1 ES @ 4,000.25 LIMIT".
647fn summary(request: &OrderRequest, numbers: &Numbers<'_>, quantity_decimals: usize) -> String {
648    let at = |price: f64, kind: &str| format!(" @ {} {kind}", numbers.price(price));
649    let order_type = match request.order_type {
650        OrderType::Limit(price) => at(price, "LIMIT"),
651        OrderType::Stop(price) => at(price, "STOP"),
652        OrderType::StopLimit { stop, .. } => at(stop, "STOP LIMIT"),
653        _ => " MKT".to_owned(),
654    };
655    format!(
656        "{} {}{order_type}",
657        numbers.quantity(request.quantity, quantity_decimals),
658        request.symbol
659    )
660}
661
662/// The question that confirms an order: "Buy 1 ES @ 4,000.25 LIMIT?", then its exits.
663fn confirmation(request: &OrderRequest, numbers: &Numbers<'_>, quantity_decimals: usize) -> String {
664    let mut text = format!(
665        "{} {}?",
666        side_name(request.side),
667        summary(request, numbers, quantity_decimals)
668    );
669    if let Some(price) = request.take_profit {
670        text.push_str(&format!("\nTake profit {}", numbers.price(price)));
671    }
672    for level in &request.take_profit_levels {
673        text.push_str(&format!(
674            "\nTake profit {} for {}",
675            numbers.price(level.price),
676            numbers.quantity(level.quantity, quantity_decimals)
677        ));
678    }
679    if let Some(price) = request.stop_loss {
680        text.push_str(&format!("\nStop loss {}", numbers.price(price)));
681    }
682    text
683}
684
685/// What the dialog shows for a previewed order: its confirmation, then the broker's estimates
686/// and warnings.
687pub(super) fn preview_message(
688    request: &OrderRequest,
689    estimate: &OrderPreview,
690    rules: &TradingRules,
691    numbers: &Numbers<'_>,
692    currency: &str,
693) -> String {
694    let mut text = confirmation(request, numbers, decimals(rules.quantity_step));
695    for (label, value) in [
696        ("Cost", estimate.cost),
697        ("Commission", estimate.commission),
698        ("Margin", estimate.margin),
699    ] {
700        if let Some(value) = value.filter(|value| value.is_finite()) {
701            text.push_str(&format!(
702                "\n{label}: {} {currency}",
703                grouped(value, 2, false)
704            ));
705        }
706    }
707    for warning in &estimate.warnings {
708        text.push_str(&format!("\n{warning}"));
709    }
710    text
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    #[test]
718    fn opening_a_ticket_replaces_brackets_and_prices() {
719        let mut ticket = OrderTicket::default();
720        let mut request = OrderRequest::new("ES", OrderSide::Buy, 2.0, OrderType::Limit(100.0));
721        request.take_profit = Some(105.0);
722        request.stop_loss = Some(95.0);
723        for brackets in [true, false] {
724            if !brackets {
725                request = OrderRequest::new("NQ", OrderSide::Sell, 1.0, OrderType::Market);
726            }
727            ticket.confirm = Some(request.clone());
728            ticket.cancel = Some(vec![OrderId::new("old")]);
729            ticket.open(&request);
730            assert!(ticket.confirm.is_none());
731            assert!(ticket.cancel.is_none());
732            assert_eq!(ticket.take_profit.0, brackets);
733            assert_eq!(ticket.stop_loss.0, brackets);
734            if brackets {
735                assert_eq!(
736                    ticket.take_profit.1,
737                    Bracket {
738                        mode: BracketMode::Price,
739                        value: 105.0
740                    }
741                );
742                assert_eq!(
743                    ticket.stop_loss.1,
744                    Bracket {
745                        mode: BracketMode::Price,
746                        value: 95.0
747                    }
748                );
749            } else {
750                assert_eq!((ticket.price, ticket.limit), (0.0, 0.0));
751            }
752        }
753    }
754
755    /// A pending entry is sized by its own price like a market entry, the risk control is offered
756    /// for every order type, and an order from the ladder takes the ticket's brackets from its own
757    /// side, entry and quantity and shows them in its confirmation. A regression sizes pending
758    /// orders from the market price, hides the control, or sends ladder orders without exits.
759    #[test]
760    fn pending_entries_are_sized_by_risk_and_ladder_orders_carry_the_brackets() {
761        let rules = TradingRules::new("ES");
762        let mut capabilities = Capabilities::default();
763        (capabilities.limit, capabilities.stop, capabilities.brackets) = (true, true, true);
764        capabilities.take_profit_levels = true;
765        let market = Market {
766            symbol: "ES",
767            formatter: None,
768            pip_value: None,
769            last: Some(100.0),
770            bid: Some(99.75),
771            ask: Some(100.25),
772            tick: 0.25,
773            decimals: 2,
774            currency: "USD",
775            balance: Some(10_000.0),
776            capabilities,
777            rules: &rules,
778            rules_ready: true,
779            offline: None,
780            ladder: None,
781            ladder_unavailable: None,
782            highlights: Vec::new(),
783            orders: &[],
784            positions: &[],
785            trades: &[],
786            session_open: None,
787            last_fill: None,
788            logo: None,
789        };
790        let ticks = |value| {
791            (
792                true,
793                Bracket {
794                    mode: BracketMode::Ticks,
795                    value,
796                },
797            )
798        };
799        // 1% of 10,000 = 100 at a stop 8 ticks = 2 points from the entry: 100 / 2 = 50 units.
800        let mut ticket = OrderTicket {
801            symbol: "ES".into(),
802            kind: Kind::Limit,
803            price: 99.0,
804            sizing: Sizing::Risk,
805            amount: 1.0,
806            take_profit: ticks(16.0),
807            stop_loss: ticks(8.0),
808            ..OrderTicket::default()
809        };
810        let request = ticket.request(&market).unwrap();
811        assert_eq!(
812            (request.quantity, request.take_profit, request.stop_loss),
813            (50.0, Some(103.0), Some(97.0))
814        );
815        // The control is on the Order tab for the pending kind.
816        let ctx = egui::Context::default();
817        let output = ctx.run_ui(egui::RawInput::default(), |ui| {
818            ticket.show(ui, &market, &mut vec![], &mut false);
819        });
820        let risk = output.shapes.iter().any(|shape| {
821            matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == "Risk, % balance")
822        });
823        output.drop_without_applying_deltas();
824        assert!(risk, "limit orders offer the risk control");
825        // A sell stop from the ladder: exits lie the other way from the buy above.
826        let order = OrderRequest::new("ES", OrderSide::Sell, 3.0, OrderType::Stop(98.0));
827        let order = ticket.with_brackets(order, &market).unwrap();
828        assert_eq!(
829            (order.take_profit, order.stop_loss),
830            (Some(94.0), Some(100.0))
831        );
832        assert_eq!(
833            confirmation(&order, &market.numbers(), 0),
834            "Sell 3 ES @ 98.00 STOP?\nTake profit 94.00\nStop loss 100.00"
835        );
836        // A level at 24 ticks = 6 points for half of 3: 1.5 rounds down to 1 and the first takes 2.
837        ticket.levels = vec![(ticks(24.0).1, 50.0)];
838        let order = OrderRequest::new("ES", OrderSide::Sell, 3.0, OrderType::Stop(98.0));
839        let order = ticket.with_brackets(order, &market).unwrap();
840        assert_eq!(order.take_profit, None);
841        assert_eq!(
842            order.take_profit_levels,
843            [
844                TakeProfitLevel::new(94.0, 2.0),
845                TakeProfitLevel::new(92.0, 1.0)
846            ]
847        );
848        assert_eq!(
849            confirmation(&order, &market.numbers(), 0),
850            "Sell 3 ES @ 98.00 STOP?\nTake profit 94.00 for 2\nTake profit 92.00 for 1\nStop loss 100.00"
851        );
852        ticket.stop_loss = ticks(0.0);
853        let order = OrderRequest::new("ES", OrderSide::Sell, 3.0, OrderType::Stop(98.0));
854        assert!(ticket.with_brackets(order, &market).is_err());
855    }
856
857    /// Guards sizing and brackets against the published definitions: value sizing buys
858    /// value / (price * point value) units, risk sizing loses at most the risk at the stop, both
859    /// round down to the quantity step, and ticks, percentages and money place a take profit
860    /// above a buy's entry and below a sell's (a stop loss the other way). A sign flip sends a
861    /// bracket to the wrong side; rounding to nearest risks more than asked. Writing an exit in
862    /// another way keeps its price, or switching how it is entered moves it.
863    #[test]
864    fn tickets_size_and_place_brackets_by_the_rules() {
865        let mut rules = TradingRules::new("ES");
866        rules.quantity_step = 0.5;
867        rules.min_quantity = 0.5;
868        rules.max_quantity = Some(10.0);
869        rules.point_value = 50.0;
870        let mut capabilities = Capabilities::default();
871        (
872            capabilities.market,
873            capabilities.limit,
874            capabilities.brackets,
875        ) = (true, true, true);
876        let mut market = Market {
877            symbol: "ES",
878            formatter: None,
879            pip_value: None,
880            last: Some(4_000.0),
881            bid: None,
882            ask: None,
883            tick: 0.25,
884            decimals: 2,
885            currency: "USD",
886            balance: Some(100_000.0),
887            capabilities,
888            rules: &rules,
889            rules_ready: true,
890            offline: None,
891            ladder: None,
892            ladder_unavailable: None,
893            highlights: Vec::new(),
894            orders: &[],
895            positions: &[],
896            trades: &[],
897            session_open: None,
898            last_fill: None,
899            logo: None,
900        };
901        let ticks = |value| Bracket {
902            mode: BracketMode::Ticks,
903            value,
904        };
905        let percent = |value| Bracket {
906            mode: BracketMode::Percent,
907            value,
908        };
909        let money = |value| Bracket {
910            mode: BracketMode::Money,
911            value,
912        };
913        // (side, sizing, amount or quantity, take profit, stop loss) -> (quantity, tp, sl)
914        let cases = [
915            // 1,000,000 / (4,000 * 50) = 5 units.
916            (
917                OrderSide::Buy,
918                Sizing::Value,
919                1_000_000.0,
920                None,
921                None,
922                Ok((5.0, None, None)),
923            ),
924            // 1,100,000 / 200,000 = 5.5, a whole number of 0.5 steps.
925            (
926                OrderSide::Buy,
927                Sizing::Value,
928                1_100_000.0,
929                None,
930                None,
931                Ok((5.5, None, None)),
932            ),
933            // 1,140,000 / 200,000 = 5.7, rounded down to 5.5.
934            (
935                OrderSide::Sell,
936                Sizing::Value,
937                1_140_000.0,
938                None,
939                None,
940                Ok((5.5, None, None)),
941            ),
942            // 1% of 100,000 = 1,000 at a stop 8 ticks = 2 points below: 1,000 / (2 * 50) = 10.
943            (
944                OrderSide::Buy,
945                Sizing::Risk,
946                1.0,
947                Some(ticks(16.0)),
948                Some(ticks(8.0)),
949                Ok((10.0, Some(4_004.0), Some(3_998.0))),
950            ),
951            // A sell's stop lies above: 1% of 4,000 = 40 points, 1,000 / (40 * 50) = 0.5.
952            (
953                OrderSide::Sell,
954                Sizing::Risk,
955                1.0,
956                Some(percent(2.0)),
957                Some(percent(1.0)),
958                Ok((0.5, Some(3_920.0), Some(4_040.0))),
959            ),
960            // 900 / 2,000 = 0.45, below the minimum of 0.5.
961            (
962                OrderSide::Sell,
963                Sizing::Risk,
964                0.9,
965                None,
966                Some(percent(1.0)),
967                Err(()),
968            ),
969            // Stop 4 ticks = 1 point below: 1,500 / 50 = 30 units, above the maximum of 10.
970            (
971                OrderSide::Buy,
972                Sizing::Risk,
973                1.5,
974                None,
975                Some(ticks(4.0)),
976                Err(()),
977            ),
978            (
979                OrderSide::Buy,
980                Sizing::Units,
981                2.0,
982                None,
983                None,
984                Ok((2.0, None, None)),
985            ),
986            (OrderSide::Buy, Sizing::Risk, 1.0, None, None, Err(())),
987            (
988                OrderSide::Buy,
989                Sizing::Units,
990                1.0,
991                None,
992                Some(ticks(0.0)),
993                Err(()),
994            ),
995            (
996                OrderSide::Sell,
997                Sizing::Units,
998                1.0,
999                Some(ticks(0.0)),
1000                None,
1001                Err(()),
1002            ),
1003            // Percent exits and explicit prices snap before sizing by their risk: 0.025% of
1004            // 100,000 = 25 at 0.5 points: 25 / (0.5 * 50) = 1.
1005            (
1006                OrderSide::Buy,
1007                Sizing::Risk,
1008                0.025,
1009                Some(percent(0.014)),
1010                Some(Bracket {
1011                    mode: BracketMode::Price,
1012                    value: 3999.6,
1013                }),
1014                Ok((1.0, Some(4000.5), Some(3999.5))),
1015            ),
1016            // 2 units of 50 a point: 500 is 5 points of profit, 250 is 2.5 points of loss.
1017            (
1018                OrderSide::Buy,
1019                Sizing::Units,
1020                2.0,
1021                Some(money(500.0)),
1022                Some(money(250.0)),
1023                Ok((2.0, Some(4_005.0), Some(3_997.5))),
1024            ),
1025            // A stop loss in money needs the quantity that risk sizing takes from it.
1026            (
1027                OrderSide::Sell,
1028                Sizing::Risk,
1029                1.0,
1030                None,
1031                Some(money(250.0)),
1032                Err(()),
1033            ),
1034        ];
1035        for (side, sizing, amount, take_profit, stop_loss, expected) in cases {
1036            let ticket = |take_profit: Option<Bracket>, stop_loss: Option<Bracket>| OrderTicket {
1037                symbol: "ES".into(),
1038                side,
1039                sizing,
1040                quantity: amount,
1041                amount,
1042                take_profit: (take_profit.is_some(), take_profit.unwrap_or(ticks(1.0))),
1043                stop_loss: (stop_loss.is_some(), stop_loss.unwrap_or(ticks(1.0))),
1044                ..OrderTicket::default()
1045            };
1046            let actual = ticket(take_profit, stop_loss)
1047                .request(&market)
1048                .map(|r| (r.quantity, r.take_profit, r.stop_loss))
1049                .map_err(|_| ());
1050            assert_eq!(actual, expected, "{side:?} {sizing:?} {amount}");
1051            let Ok((quantity, Some(take_profit), Some(stop_loss))) = expected else {
1052                continue;
1053            };
1054            if sizing == Sizing::Risk {
1055                continue;
1056            }
1057            for mode in BracketMode::ALL {
1058                let at = |price, profit| {
1059                    let per_point = quantity * rules.point_value;
1060                    Bracket::at(mode, price, side, 4_000.0, 0.25, per_point, profit)
1061                };
1062                let actual = ticket(Some(at(take_profit, true)), Some(at(stop_loss, false)))
1063                    .request(&market)
1064                    .map(|r| (r.quantity, r.take_profit, r.stop_loss));
1065                assert_eq!(actual, Ok(expected.unwrap()), "{side:?} {mode:?}");
1066            }
1067        }
1068        // (side, sizing, amount or quantity, take profit, stop loss, further levels with their
1069        // shares, whether the account accepts levels) -> (take profit, levels, reward * 50). A
1070        // regression sizes levels from the typed quantity instead of the risk-sized one, prices
1071        // money from the whole order, or loses a level when the preview reopens the ticket.
1072        let level = TakeProfitLevel::new;
1073        let cases = [
1074            // 2 units split in half: 1 * 1 + 2 * 1 = 3 points of reward.
1075            (
1076                OrderSide::Buy,
1077                Sizing::Units,
1078                2.0,
1079                ticks(4.0),
1080                None,
1081                vec![(ticks(8.0), 50.0)],
1082                true,
1083                Ok((None, vec![level(4_001.0, 1.0), level(4_002.0, 1.0)], 150.0)),
1084            ),
1085            // Half of 1.5 rounds down to 0.5 and the first takes the rest: 1 + 1 = 2 points.
1086            (
1087                OrderSide::Buy,
1088                Sizing::Units,
1089                1.5,
1090                ticks(4.0),
1091                None,
1092                vec![(ticks(8.0), 50.0)],
1093                true,
1094                Ok((None, vec![level(4_001.0, 1.0), level(4_002.0, 0.5)], 100.0)),
1095            ),
1096            // 30% of 1 rounds down to 0, below the minimum of 0.5.
1097            (
1098                OrderSide::Buy,
1099                Sizing::Units,
1100                1.0,
1101                ticks(4.0),
1102                None,
1103                vec![(ticks(8.0), 30.0)],
1104                true,
1105                Err(()),
1106            ),
1107            // Nothing is left for the first level.
1108            (
1109                OrderSide::Buy,
1110                Sizing::Units,
1111                2.0,
1112                ticks(4.0),
1113                None,
1114                vec![(ticks(8.0), 50.0), (ticks(12.0), 50.0)],
1115                true,
1116                Err(()),
1117            ),
1118            (
1119                OrderSide::Buy,
1120                Sizing::Units,
1121                2.0,
1122                ticks(4.0),
1123                None,
1124                vec![(ticks(8.0), f64::NAN)],
1125                true,
1126                Err(()),
1127            ),
1128            (
1129                OrderSide::Buy,
1130                Sizing::Units,
1131                2.0,
1132                ticks(4.0),
1133                None,
1134                vec![(ticks(8.0), 0.0)],
1135                true,
1136                Err(()),
1137            ),
1138            // Each level makes 1.5 * 50 = 75 a point: 150 is 2 points, 300 is 4. Reward 3 + 6.
1139            (
1140                OrderSide::Sell,
1141                Sizing::Units,
1142                3.0,
1143                money(150.0),
1144                None,
1145                vec![(money(300.0), 50.0)],
1146                true,
1147                Ok((None, vec![level(3_998.0, 1.5), level(3_996.0, 1.5)], 450.0)),
1148            ),
1149            // Without the capability the first take profit covers the whole quantity.
1150            (
1151                OrderSide::Buy,
1152                Sizing::Units,
1153                2.0,
1154                ticks(4.0),
1155                None,
1156                vec![(ticks(8.0), 50.0)],
1157                false,
1158                Ok((Some(4_001.0), vec![], 100.0)),
1159            ),
1160            // 1% of 100,000 at 2 points of 50 sizes 10 units: 4 * 7.5 + 6 * 2.5 = 45 points.
1161            (
1162                OrderSide::Buy,
1163                Sizing::Risk,
1164                1.0,
1165                ticks(16.0),
1166                Some(ticks(8.0)),
1167                vec![(ticks(24.0), 25.0)],
1168                true,
1169                Ok((
1170                    None,
1171                    vec![level(4_004.0, 7.5), level(4_006.0, 2.5)],
1172                    2_250.0,
1173                )),
1174            ),
1175        ];
1176        for (side, sizing, amount, take_profit, stop_loss, levels, accepted, expected) in cases {
1177            market.capabilities.take_profit_levels = accepted;
1178            let ticket = OrderTicket {
1179                symbol: "ES".into(),
1180                side,
1181                sizing,
1182                quantity: amount,
1183                amount,
1184                take_profit: (true, take_profit),
1185                levels,
1186                stop_loss: (stop_loss.is_some(), stop_loss.unwrap_or(ticks(1.0))),
1187                ..OrderTicket::default()
1188            };
1189            let request = ticket.request(&market);
1190            let actual = request.as_ref().map_err(|_| ()).map(|request| {
1191                (
1192                    request.take_profit,
1193                    request.take_profit_levels.clone(),
1194                    reward(request, 4_000.0).unwrap() * rules.point_value,
1195                )
1196            });
1197            assert_eq!(actual, expected, "{side:?} {sizing:?} {amount}");
1198            if let Ok(request) = request {
1199                let mut reopened = OrderTicket::default();
1200                reopened.open(&request);
1201                assert_eq!(reopened.request(&market), Ok(request));
1202            }
1203        }
1204        // A quoted market order enters a buy at the ask and a sell at the bid: 4 ticks = 1 point.
1205        let quoted = Market {
1206            bid: Some(3_999.75),
1207            ask: Some(4_000.25),
1208            ..market
1209        };
1210        for (side, take_profit) in [(OrderSide::Buy, 4_001.25), (OrderSide::Sell, 3_998.75)] {
1211            let ticket = OrderTicket {
1212                symbol: "ES".into(),
1213                side,
1214                take_profit: (true, ticks(4.0)),
1215                ..OrderTicket::default()
1216            };
1217            let request = ticket.request(&quoted).unwrap();
1218            assert_eq!(request.take_profit, Some(take_profit), "{side:?}");
1219        }
1220    }
1221}