Skip to main content

egui_charts/chart/rendering/trading_lines/
mod.rs

1//! Order, bracket and position lines from a broker's state. Changes are host intents: rendering
2//! never changes an order or a position.
3
4mod position;
5
6use position::show_position;
7
8use egui::{Color32, CursorIcon, FontId, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
9
10use crate::chart::coords::ChartMapping;
11use crate::chart::rendering::{draw_dashed_polyline, render_price_line};
12use crate::localization as l10n;
13use crate::model::{
14    LineStyle, Order, OrderRequest, OrderSide, OrderType, Position, PositionBracket, PriceLine,
15};
16use crate::scales::{decimals, to_step};
17use crate::theme::{Theme, ThemeExt};
18
19/// What the user asked for on a trading line. Confirm it with the user, then send it to the
20/// broker.
21#[derive(Clone, Debug, PartialEq)]
22#[non_exhaustive]
23pub enum TradingLineAction {
24    /// Cancel the working order.
25    Cancel(Order),
26    /// Replace the working order with the request: the user dragged its price, a take profit or
27    /// its stop loss, removed one of them, or dragged a take profit or stop loss out of its label.
28    Modify(Order, OrderRequest),
29    /// Close the position at the market.
30    Close(Position),
31    /// Turn the position into one of the same size on the other side, at the market.
32    Reverse(Position),
33    /// Place the order the user dragged out of the position's TP or SL button: a limit order
34    /// for a take profit or a stop order for a stop loss, on the other side for the position's
35    /// size.
36    Exit(Position, OrderRequest),
37}
38
39/// What a profit or loss on the trading lines is written in.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41pub enum PnlUnit {
42    /// In the account's currency, such as "+12.50 USD".
43    #[default]
44    Money,
45    /// In ticks of the symbol's price, such as "+25 ticks".
46    Ticks,
47    /// As a percentage of the entry price, such as "+0.42%".
48    Percent,
49}
50
51impl PnlUnit {
52    /// Every unit, in the order a menu lists them.
53    pub const ALL: [Self; 3] = [Self::Money, Self::Ticks, Self::Percent];
54
55    /// The unit's name in menus.
56    pub fn label(self) -> &'static str {
57        match self {
58            Self::Money => "Money",
59            Self::Ticks => "Ticks",
60            Self::Percent => "Percent",
61        }
62    }
63}
64
65/// Horizontal placement of trading line labels inside the price pane.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67pub enum TradingLabelAlignment {
68    /// Place labels beside the left edge.
69    Left,
70    /// Centre labels in the pane.
71    Center,
72    /// Place labels near the right edge.
73    #[default]
74    Right,
75}
76
77impl TradingLabelAlignment {
78    fn left(self, rect: Rect, width: f32, inset: f32) -> f32 {
79        let left = rect.left() + 8.0;
80        let right = rect.right() - inset - width;
81        if right < left {
82            return right;
83        }
84        match self {
85            Self::Left => left,
86            Self::Center => (rect.center().x - width / 2.0).clamp(left, right),
87            Self::Right => right,
88        }
89    }
90}
91
92/// What [`render_trading_lines`] drew, and what the user asked for.
93pub struct TradingLines {
94    /// What the user asked for on a line in this frame.
95    pub action: Option<TradingLineAction>,
96    /// The price and colour of each line drawn, a dragged line at its new price, for tags on the
97    /// price axis: for example as [`PriceLine`]s of width 0 passed to
98    /// [`Chart::set_price_lines`](crate::Chart::set_price_lines) with the chart's other lines.
99    pub tags: Vec<(f64, Color32)>,
100    /// Indices in `tags` that use chart background and a coloured outline.
101    pub outlined: Vec<usize>,
102    /// The projected order whose submit button the user pressed.
103    pub submitted: Option<OrderRequest>,
104}
105
106/// What [`render_trading_lines`] draws and offers.
107#[derive(Clone)]
108pub struct TradingLineOptions<'a> {
109    /// Draw orders and their brackets. Hidden orders still identify existing position exits.
110    pub show_orders: bool,
111    /// Draw positions. Hidden positions still supply their brackets' entry price.
112    pub show_positions: bool,
113    /// Extend lines to the left edge instead of starting at their labels.
114    pub extend_lines: bool,
115    /// Horizontal placement of order and position labels.
116    pub label_alignment: TradingLabelAlignment,
117    /// Treat the orders as projected, not yet placed: their entry lines are dashed and they offer
118    /// a Buy or Sell submit button.
119    pub submit: bool,
120    /// Order and bracket lines can be dragged, and brackets removed.
121    pub modify: bool,
122    /// Position labels offer a close button, and a reverse button with
123    /// [`reverse`](Self::reverse).
124    pub close: bool,
125    /// Hovering a position's label shows its reverse button. Needs [`close`](Self::close).
126    pub reverse: bool,
127    /// Position labels offer a TP button, dragged out to place a take profit as a limit order.
128    pub take_profit: bool,
129    /// Position labels offer an SL button, dragged out to place a stop loss as a stop order.
130    pub stop_loss: bool,
131    /// Labels of orders with an entry price and no position bracket offer a TP button while the
132    /// order has no take profit, and an SL button while it has no stop loss. Dragging one out
133    /// places that exit at the pointer's price and asks for it as a
134    /// [`TradingLineAction::Modify`].
135    pub order_exits: bool,
136    /// A position's label gives its profit or loss in this unit, or none.
137    pub pnl: Option<PnlUnit>,
138    /// A take profit or stop loss gives, in this unit, the profit or loss its order makes if
139    /// the price reaches it, or none.
140    pub bracket_pnl: Option<PnlUnit>,
141    /// The last price, for a position's profit or loss when the broker reports none.
142    pub last_price: Option<f64>,
143    /// The profit or loss of one unit moving by one price unit, in the account's currency.
144    pub point_value: f64,
145    /// The account's currency, such as "USD".
146    pub currency: &'a str,
147    /// The positive tick size used to round dragged prices.
148    pub tick_size: f64,
149}
150
151#[derive(Clone, Copy, PartialEq)]
152enum Level {
153    Entry,
154    TakeProfit,
155    StopLoss,
156}
157
158#[derive(Clone, Copy, PartialEq)]
159enum Button {
160    Cancel,
161    Reverse,
162    Submit,
163}
164
165const HEIGHT: f32 = 20.0;
166const LABEL_INSET: f32 = 84.0;
167const NODE_INSET: f32 = 42.0;
168const PADDING: f32 = 6.0;
169
170/// Draws the working orders with their brackets and the open positions of one symbol on the
171/// chart, and returns what the user asked for. Pass only the orders and positions of the
172/// chart's symbol, and call it after drawing the chart. The price axis is left to the caller; see
173/// [`TradingLines::tags`].
174pub fn render_trading_lines<'a>(
175    ui: &mut Ui,
176    mapping: &ChartMapping,
177    orders: impl IntoIterator<Item = &'a Order>,
178    positions: impl IntoIterator<Item = &'a Position>,
179    options: &TradingLineOptions<'_>,
180) -> TradingLines {
181    let theme = ui.ctx().active_theme();
182    let side_color = |side| crate::theme::trading_side_color(&theme, side);
183    let positions: Vec<_> = positions.into_iter().collect();
184    let orders: Vec<_> = orders.into_iter().collect();
185    let mut action = None;
186    let mut tags = Vec::new();
187    let mut outlined = Vec::new();
188    let mut submitted = None;
189
190    for position in positions.iter().filter(|_| options.show_positions) {
191        let mut position_options = options.clone();
192        for order in &orders {
193            if order.request.symbol == position.symbol
194                && (order.request.side == OrderSide::Buy) != (position.quantity > 0.0)
195            {
196                match order.request.position_bracket {
197                    Some(PositionBracket::TakeProfit) => position_options.take_profit = false,
198                    Some(PositionBracket::StopLoss) => position_options.stop_loss = false,
199                    None => {}
200                }
201            }
202        }
203        let pressed = show_position(ui, mapping, &position_options, position, &mut tags);
204        action = action.or(pressed);
205    }
206
207    for order in orders.into_iter().filter(|_| options.show_orders) {
208        let request = &order.request;
209        let open = request.quantity - order.filled;
210        let kind = match request.order_type {
211            OrderType::Limit(_) => "Limit",
212            OrderType::Stop(_) => "Stop",
213            OrderType::StopLimit { .. } => "Stop limit",
214            _ => "Market",
215        };
216        let parent = request.position_bracket.and_then(|_| {
217            positions.iter().find(|p| {
218                p.symbol == request.symbol && (p.quantity > 0.0) != (request.side == OrderSide::Buy)
219            })
220        });
221        let entry_level = match request.position_bracket {
222            Some(PositionBracket::TakeProfit) => Level::TakeProfit,
223            Some(PositionBracket::StopLoss) => Level::StopLoss,
224            None => Level::Entry,
225        };
226        let initial_entry = entry_price(&request.order_type);
227        // Each level: its kind, its index among the take-profit levels, its price and quantity.
228        let mut levels = vec![(Level::Entry, 0, initial_entry, open)];
229        if request.take_profit_levels.is_empty() {
230            levels.push((Level::TakeProfit, 0, request.take_profit, open));
231        }
232        levels.extend(
233            request
234                .take_profit_levels
235                .iter()
236                .enumerate()
237                .map(|(index, take_profit)| {
238                    (
239                        Level::TakeProfit,
240                        index,
241                        Some(take_profit.price),
242                        take_profit.quantity,
243                    )
244                }),
245        );
246        levels.push((Level::StopLoss, 0, request.stop_loss, open));
247        let mut parent_price = initial_entry;
248        for (level, index, price, quantity) in levels {
249            let Some(price) = price.filter(|price| price.is_finite()) else {
250                continue;
251            };
252            let price = if level != Level::Entry {
253                price
254                    + parent_price
255                        .zip(initial_entry)
256                        .map_or(0.0, |(moved, original)| moved - original)
257            } else {
258                price
259            };
260            let appearance = if level == Level::Entry {
261                entry_level
262            } else {
263                level
264            };
265            let (name, color) = match appearance {
266                Level::Entry => (l10n::text(ui.ctx(), kind), side_color(request.side)),
267                Level::TakeProfit => (l10n::text(ui.ctx(), "TP"), theme.ui.take_profit),
268                Level::StopLoss => (l10n::text(ui.ctx(), "SL"), theme.ui.stop_loss),
269            };
270            // A bracket's result if it is reached, from the entry or, for a market order, the
271            // last price.
272            let entry = parent
273                .map(|p| p.average_price)
274                .or(parent_price)
275                .or(options.last_price);
276            let ctx = ui.ctx().clone();
277            let body = |price: f64| match (appearance, options.bracket_pnl, entry) {
278                (Level::TakeProfit | Level::StopLoss, Some(unit), Some(entry)) => {
279                    let gain = (price - entry)
280                        * parent.map_or(direction(request.side), |p| p.quantity.signum());
281                    let money = gain * quantity * options.point_value;
282                    pnl_text(&ctx, unit, money, gain, entry, options).0
283                }
284                _ => name.clone(),
285            };
286            let line = PriceLine::new(price, color).with_style(
287                if appearance == Level::Entry && options.submit {
288                    LineStyle::Dashed
289                } else {
290                    LineStyle::Solid
291                },
292            );
293            // Removing a bracket changes the order, so it needs the same support as a drag.
294            let cancel = level == Level::Entry || options.modify;
295            // What an exit dragged out of the entry's label makes if the price reaches it.
296            let exit_pnl = |unit, at: f64| {
297                let gain = (at - price) * direction(request.side);
298                let money = gain * open * options.point_value;
299                pnl_text(&ctx, unit, money, gain, price, options).0
300            };
301            let exits = if level == Level::Entry
302                && options.order_exits
303                && request.position_bracket.is_none()
304                && initial_entry.is_some()
305            {
306                offered_exits(
307                    request.take_profit.is_none() && request.take_profit_levels.is_empty(),
308                    request.stop_loss.is_none(),
309                )
310            } else {
311                &[]
312            };
313            let label = Label {
314                id: ("trading_order", &order.id.0),
315                body: &body,
316                body_color: color,
317                quantity,
318                buttons: if cancel { &[Button::Cancel] } else { &[] },
319                draggable: options.modify,
320                submit: (options.submit && level == Level::Entry).then_some(request.side),
321                exits,
322                exit_pnl: &exit_pnl,
323            };
324            let first_tag = tags.len();
325            let (dragged, pressed, released) = ui
326                .push_id((level as u8, index), |ui| {
327                    label.show(ui, mapping, options, &line, &mut tags)
328                })
329                .inner;
330            let drawn = tags.get(first_tag).map_or(price, |tag| tag.0);
331            if appearance == Level::Entry && tags.len() > first_tag {
332                outlined.push(first_tag);
333                parent_price = Some(drawn);
334            }
335            if let Some(parent) = parent {
336                connector(
337                    ui,
338                    mapping,
339                    parent.average_price,
340                    drawn,
341                    side_color(if parent.quantity > 0.0 {
342                        OrderSide::Buy
343                    } else {
344                        OrderSide::Sell
345                    }),
346                );
347            } else if level != Level::Entry
348                && let Some(entry) = entry
349            {
350                connector(ui, mapping, entry, drawn, side_color(request.side));
351            }
352            if tags.len() == first_tag {
353                continue;
354            }
355            if let Some((exit, price)) = released {
356                let mut changed = request.clone();
357                if exit == Level::TakeProfit {
358                    changed.take_profit = Some(price);
359                } else {
360                    changed.stop_loss = Some(price);
361                }
362                action = action.or(Some(TradingLineAction::Modify(order.clone(), changed)));
363                continue;
364            }
365            let price = match (dragged, pressed) {
366                (_, Some(Button::Submit)) => {
367                    submitted = Some(request.clone());
368                    continue;
369                }
370                (Some(price), _) => Some(price),
371                (None, Some(Button::Cancel)) if level == Level::Entry => {
372                    action = action.or(Some(TradingLineAction::Cancel(order.clone())));
373                    continue;
374                }
375                (None, Some(_)) => None,
376                (None, None) => continue,
377            };
378            let mut changed = request.clone();
379            match level {
380                Level::Entry => {
381                    if let Some(price) = price {
382                        let delta = price - entry_price(&request.order_type).unwrap_or(price);
383                        // The other levels keep their distance, written at the tick's decimals.
384                        let scale = 10f64.powi(decimals(options.tick_size) as i32);
385                        let shift = |other: f64| ((other + delta) * scale).round() / scale;
386                        changed.order_type = match request.order_type {
387                            OrderType::Limit(_) => OrderType::Limit(price),
388                            OrderType::Stop(_) => OrderType::Stop(price),
389                            OrderType::StopLimit { limit, .. } => OrderType::StopLimit {
390                                stop: price,
391                                limit: shift(limit),
392                            },
393                            other => other,
394                        };
395                        changed.take_profit = changed.take_profit.map(shift);
396                        for take_profit in &mut changed.take_profit_levels {
397                            take_profit.price = shift(take_profit.price);
398                        }
399                        changed.stop_loss = changed.stop_loss.map(shift);
400                    }
401                }
402                Level::TakeProfit if changed.take_profit_levels.is_empty() => {
403                    changed.take_profit = price;
404                }
405                Level::TakeProfit => match price {
406                    Some(price) => changed.take_profit_levels[index].price = price,
407                    None => remove_take_profit_level(&mut changed, index),
408                },
409                Level::StopLoss => changed.stop_loss = price,
410            }
411            action = action.or(Some(TradingLineAction::Modify(order.clone(), changed)));
412        }
413    }
414    TradingLines {
415        action,
416        tags,
417        outlined,
418        submitted,
419    }
420}
421
422/// Removes a take-profit level, adding its quantity to the level before it, or after it for the
423/// first. The last level left becomes the order's single take profit.
424fn remove_take_profit_level(request: &mut OrderRequest, index: usize) {
425    let levels = &mut request.take_profit_levels;
426    let removed = levels.remove(index);
427    if let Some(neighbour) = levels.get_mut(index.saturating_sub(1)) {
428        let places = decimals(removed.quantity).max(decimals(neighbour.quantity));
429        let scale = 10f64.powi(places as i32);
430        neighbour.quantity = ((neighbour.quantity + removed.quantity) * scale).round() / scale;
431    }
432    if let [only] = levels.as_slice() {
433        request.take_profit = Some(only.price);
434        levels.clear();
435    }
436}
437
438/// 1 for a buy and -1 for a sell: the sign of a price move in the side's favour.
439fn direction(side: OrderSide) -> f64 {
440    match side {
441        OrderSide::Buy => 1.0,
442        OrderSide::Sell => -1.0,
443    }
444}
445
446/// A profit or loss of `money` in the account's currency, or of `gain` in price from `entry`,
447/// written in `unit`, and the value written, whose sign colours it.
448fn pnl_text(
449    ctx: &egui::Context,
450    unit: PnlUnit,
451    money: f64,
452    gain: f64,
453    entry: f64,
454    options: &TradingLineOptions<'_>,
455) -> (String, f64) {
456    let round = |value: f64, decimals: i32| {
457        let scale = 10f64.powi(decimals);
458        (value * scale).round() / scale
459    };
460    let (value, text) = match unit {
461        PnlUnit::Money => {
462            let value = round(money, 2);
463            (value, format!("{:.2} {}", value.abs(), options.currency))
464        }
465        PnlUnit::Ticks => {
466            let value = (gain / options.tick_size).round();
467            (
468                value,
469                l10n::format_text(ctx, "{0} ticks", &[("0", value.abs().to_string())]),
470            )
471        }
472        PnlUnit::Percent => {
473            let value = round(gain / entry * 100.0, 2);
474            (value, format!("{:.2}%", value.abs()))
475        }
476    };
477    let sign = if value > 0.0 {
478        "+"
479    } else if value < 0.0 {
480        "-"
481    } else {
482        ""
483    };
484    (format!("{sign}{}", text.trim_end()), value)
485}
486
487/// The TP and SL buttons a label offers, from left to right.
488fn offered_exits(take_profit: bool, stop_loss: bool) -> &'static [Level] {
489    match (take_profit, stop_loss) {
490        (true, true) => &[Level::TakeProfit, Level::StopLoss],
491        (true, false) => &[Level::TakeProfit],
492        (false, true) => &[Level::StopLoss],
493        (false, false) => &[],
494    }
495}
496
497/// An exit's name and colour.
498fn exit_style(theme: &Theme, exit: Level) -> (&'static str, Color32) {
499    if exit == Level::TakeProfit {
500        ("TP", theme.ui.take_profit)
501    } else {
502        ("SL", theme.ui.stop_loss)
503    }
504}
505
506/// The width of the TP and SL buttons in `exits` with the gap after them, or 0 without buttons.
507fn exit_buttons_width(ui: &Ui, exits: &[Level]) -> f32 {
508    let theme = ui.ctx().active_theme();
509    let font = FontId::proportional(theme.text.ohlc);
510    let buttons = exits
511        .iter()
512        .map(|exit| {
513            let (name, color) = exit_style(&theme, *exit);
514            let name = l10n::text(ui.ctx(), name);
515            let galley = ui.painter().layout_no_wrap(name, font.clone(), color);
516            galley.size().x + 2.0 * PADDING
517        })
518        .sum::<f32>();
519    match exits.len() {
520        0 => 0.0,
521        // The two buttons share an edge.
522        2 => buttons - 1.0 + HEIGHT * 0.65,
523        _ => buttons + HEIGHT * 0.65,
524    }
525}
526
527/// The TP and SL buttons on a label's row.
528struct ExitButtons<'a> {
529    key: egui::Id,
530    /// From left to right.
531    exits: &'a [Level],
532    /// The left edge of the first button.
533    x: f32,
534    /// The middle of the row.
535    y: f32,
536    /// A dragged exit asks for nothing at this price.
537    entry: f64,
538    /// Where a dragged exit's line starts.
539    line_left: f32,
540    /// The right edge of a dragged exit's label.
541    preview_right: f32,
542    /// The text after the name on a dragged exit's label, from its price.
543    pnl: &'a dyn Fn(PnlUnit, f64) -> String,
544}
545
546impl ExitButtons<'_> {
547    /// Draws the buttons and handles their drags: while one is dragged, the exit's line and a
548    /// label at the pointer's tick price. Returns the right edge of the last button, and the exit
549    /// released with its price.
550    fn show(
551        &self,
552        ui: &Ui,
553        mapping: &ChartMapping,
554        options: &TradingLineOptions<'_>,
555        tags: &mut Vec<(f64, Color32)>,
556    ) -> (f32, Option<(Level, f64)>) {
557        let theme = ui.ctx().active_theme();
558        let painter = ui.painter().with_clip_rect(mapping.rect);
559        let font = FontId::proportional(theme.text.ohlc);
560        let layout = |text: String, color| painter.layout_no_wrap(text, font.clone(), color);
561        let width = |galley: &egui::Galley| galley.size().x + 2.0 * PADDING;
562        let y = self.y;
563        let joined = self.exits.len() == 2;
564        let mut x = self.x;
565        let mut released = None;
566        for &exit in self.exits {
567            let (name, exit_color) = exit_style(&theme, exit);
568            let galley = layout(l10n::text(ui.ctx(), name), exit_color);
569            let rect = Rect::from_min_size(pos2(x, y - HEIGHT / 2.0), vec2(width(&galley), HEIGHT));
570            let take_profit = exit == Level::TakeProfit;
571            x = rect.right() - if take_profit && joined { 1.0 } else { 0.0 };
572            let id = self.key.with(name);
573            let response = ui
574                .interact(rect.intersect(mapping.rect), id, Sense::drag())
575                .on_hover_text(l10n::text(
576                    ui.ctx(),
577                    if take_profit {
578                        "Drag to place a take profit"
579                    } else {
580                        "Drag to place a stop loss"
581                    },
582                ));
583            if response.hovered() || response.dragged() {
584                ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
585            }
586            let r = rect.shrink(0.5);
587            let left_radius = if !take_profit && joined { 0.0 } else { 4.0 };
588            let right_radius = if take_profit && joined { 0.0 } else { 4.0 };
589            painter.rect_filled(
590                r,
591                egui::CornerRadius {
592                    nw: left_radius as u8,
593                    sw: left_radius as u8,
594                    ne: right_radius as u8,
595                    se: right_radius as u8,
596                },
597                label_fill(&theme, exit_color),
598            );
599            draw_dashed_polyline(
600                &painter,
601                &[
602                    r.left_top() + vec2(left_radius, 0.0),
603                    r.right_top() - vec2(right_radius, 0.0),
604                    r.right_top() + vec2(-right_radius / 4.0, right_radius / 4.0),
605                    r.right_top() + vec2(0.0, right_radius),
606                    r.right_bottom() - vec2(0.0, right_radius),
607                    r.right_bottom() - vec2(right_radius / 4.0, right_radius / 4.0),
608                    r.right_bottom() - vec2(right_radius, 0.0),
609                    r.left_bottom() + vec2(left_radius, 0.0),
610                    r.left_bottom() + vec2(left_radius / 4.0, -left_radius / 4.0),
611                    r.left_bottom() - vec2(0.0, left_radius),
612                    r.left_top() + vec2(0.0, left_radius),
613                    r.left_top() + vec2(left_radius / 4.0, left_radius / 4.0),
614                    r.left_top() + vec2(left_radius, 0.0),
615                ],
616                Stroke::new(1.0, exit_color),
617                1.0,
618                2.0,
619            );
620            painter.galley(
621                pos2(rect.left() + PADDING, y - galley.size().y / 2.0),
622                galley,
623                exit_color,
624            );
625            let price = response
626                .interact_pointer_pos()
627                .filter(|_| response.dragged() || response.drag_stopped())
628                .map(|pointer| {
629                    let y = pointer.y.clamp(mapping.rect.top(), mapping.rect.bottom());
630                    to_step(mapping.y_to_price(y), options.tick_size, f64::round)
631                })
632                .filter(|price| price.is_finite() && *price != self.entry);
633            let Some(price) = price else {
634                continue;
635            };
636            if response.drag_stopped() {
637                released = Some((exit, price));
638                continue;
639            }
640            // The exit being dragged: its line and a label like a bracket's.
641            let line = PriceLine::new(price, exit_color).with_style(LineStyle::Solid);
642            render_price_line(
643                &painter,
644                mapping.rect,
645                &mapping.price,
646                &line,
647                self.line_left,
648                false,
649                f32::INFINITY,
650            );
651            tags.push((price, exit_color));
652            let text = match options.bracket_pnl {
653                Some(unit) => format!("{} {}", l10n::text(ui.ctx(), name), (self.pnl)(unit, price)),
654                None => l10n::text(ui.ctx(), name),
655            };
656            let text = layout(text, exit_color);
657            let y = mapping.price_to_y(price);
658            let preview = Rect::from_min_size(
659                pos2(self.preview_right - width(&text), y - HEIGHT / 2.0),
660                vec2(width(&text), HEIGHT),
661            );
662            painter.rect(
663                preview,
664                2.0,
665                label_fill(&theme, exit_color),
666                Stroke::new(1.0, exit_color),
667                egui::StrokeKind::Inside,
668            );
669            painter.galley(
670                pos2(preview.left() + PADDING, y - text.size().y / 2.0),
671                text,
672                exit_color,
673            );
674        }
675        (x, released)
676    }
677}
678
679/// The price an order waits for: its limit or stop price, or the stop of a stop-limit order.
680pub(crate) fn entry_price(order_type: &OrderType) -> Option<f64> {
681    match *order_type {
682        OrderType::Limit(price)
683        | OrderType::Stop(price)
684        | OrderType::StopLimit { stop: price, .. } => Some(price),
685        _ => None,
686    }
687}
688
689/// The label at the right end of a trading line: TP and SL buttons, the open quantity, a
690/// description and buttons.
691struct Label<'a> {
692    id: (&'a str, &'a str),
693    /// The description at the line's price.
694    body: &'a dyn Fn(f64) -> String,
695    body_color: Color32,
696    quantity: f64,
697    buttons: &'a [Button],
698    draggable: bool,
699    submit: Option<OrderSide>,
700    /// The TP and SL buttons before the label.
701    exits: &'a [Level],
702    /// The text after the name on a label of an exit dragged out of `exits`, from its price.
703    exit_pnl: &'a dyn Fn(PnlUnit, f64) -> String,
704}
705
706impl Label<'_> {
707    /// Draws the line and its label. Returns the price a finished drag ended at, the button the
708    /// user clicked, and the exit dragged out of the label with its price.
709    fn show(
710        &self,
711        ui: &mut Ui,
712        mapping: &ChartMapping,
713        options: &TradingLineOptions<'_>,
714        line: &PriceLine,
715        tags: &mut Vec<(f64, Color32)>,
716    ) -> (Option<f64>, Option<Button>, Option<(Level, f64)>) {
717        let key = ui.scope_id().with(self.id);
718        if !self.draggable {
719            ui.ctx().data_mut(|data| data.remove::<f64>(key));
720        }
721        let staged = ui.ctx().data(|data| data.get_temp::<f64>(key));
722        let y = mapping.price_to_y(staged.unwrap_or(line.price));
723        if !y.is_finite() || (staged.is_none() && !mapping.is_y_visible(y)) {
724            return (None, None, None);
725        }
726        // A tick can snap beyond the pane; keep its drag target reachable until release.
727        let y = y.clamp(mapping.rect.top(), mapping.rect.bottom());
728        let font = FontId::proportional(ui.ctx().active_theme().text.ohlc);
729        let painter = ui.painter().clone();
730        let layout = |text: String, color| painter.layout_no_wrap(text, font.clone(), color);
731        let body = (self.body)(staged.unwrap_or(line.price));
732        let body = (!body.is_empty()).then(|| layout(body, self.body_color));
733        let quantity = layout(format!("{}", self.quantity), line.color);
734        let width = |galley: &egui::Galley| galley.size().x + 2.0 * PADDING;
735        let total = body.as_deref().map_or(0.0, width)
736            + width(&quantity)
737            + HEIGHT * self.buttons.len() as f32;
738        let exits_width = exit_buttons_width(ui, self.exits);
739        let group = total + exits_width;
740        let inset = LABEL_INSET.min((mapping.rect.width() - group - 8.0).max(8.0));
741        let mut x = options.label_alignment.left(mapping.rect, group, inset) + exits_width;
742        let rect = Rect::from_min_size(pos2(x, y - HEIGHT / 2.0), vec2(total, HEIGHT));
743
744        let mut dragged = None;
745        let mut value = staged.unwrap_or(line.price);
746        let grab = ui.interact(
747            Rect::from_min_max(
748                pos2(
749                    if options.extend_lines {
750                        mapping.rect.left()
751                    } else {
752                        rect.left()
753                    },
754                    rect.top(),
755                ),
756                pos2(rect.max.x - HEIGHT * self.buttons.len() as f32, rect.max.y),
757            )
758            .intersect(mapping.rect),
759            key,
760            if self.draggable {
761                Sense::drag()
762            } else {
763                Sense::hover()
764            },
765        );
766        let node = pos2(mapping.rect.right() - NODE_INSET, y);
767        let node_response = ui.interact(
768            Rect::from_center_size(node, vec2(14.0, HEIGHT)).intersect(mapping.rect),
769            key.with("node"),
770            if self.draggable {
771                Sense::drag()
772            } else {
773                Sense::hover()
774            },
775        );
776        let grab = grab.union(node_response);
777        if self.draggable {
778            if grab.hovered() || grab.dragged() {
779                ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
780            }
781            if grab.dragged()
782                && let Some(pointer) = grab.interact_pointer_pos()
783            {
784                let price =
785                    mapping.y_to_price(pointer.y.clamp(mapping.rect.top(), mapping.rect.bottom()));
786                value = to_step(price, options.tick_size, f64::round);
787                if value.is_finite() {
788                    ui.ctx().data_mut(|data| data.insert_temp(key, value));
789                }
790            }
791            if grab.drag_stopped() {
792                ui.ctx().data_mut(|data| data.remove::<f64>(key));
793                if value.is_finite() && value != line.price {
794                    dragged = Some(value);
795                }
796            }
797        }
798        let line = PriceLine {
799            price: value,
800            ..line.clone()
801        };
802        let clipped = painter.with_clip_rect(mapping.rect);
803        render_price_line(
804            &clipped,
805            mapping.rect,
806            &mapping.price,
807            &line,
808            if options.extend_lines {
809                mapping.rect.left()
810            } else {
811                rect.left()
812            },
813            false,
814            f32::INFINITY,
815        );
816        tags.push((value, line.color));
817        let node = pos2(mapping.rect.right() - NODE_INSET, mapping.price_to_y(value));
818        clipped.circle(
819            node,
820            3.5,
821            ui.active_theme().chart.bg,
822            Stroke::new(1.0, line.color),
823        );
824
825        let y = mapping
826            .price_to_y(value)
827            .clamp(mapping.rect.top(), mapping.rect.bottom());
828        let rect = rect.translate(vec2(0.0, y - rect.center().y));
829        let stroke = Stroke::new(1.0, line.color);
830        clipped.rect(
831            rect,
832            4.0,
833            label_fill(&ui.active_theme(), line.color),
834            stroke,
835            egui::StrokeKind::Inside,
836        );
837        x = rect.left();
838        let w = width(&quantity);
839        clipped.galley(
840            pos2(x + PADDING, y - quantity.size().y / 2.0),
841            quantity,
842            line.color,
843        );
844        x += w;
845        if let Some(body) = body {
846            clipped.vline(x, rect.y_range(), stroke);
847            let w = width(&body);
848            clipped.galley(
849                pos2(x + PADDING, y - body.size().y / 2.0),
850                body,
851                self.body_color,
852            );
853            x += w;
854        }
855
856        let mut pressed = None;
857        for (index, button) in self.buttons.iter().enumerate() {
858            let cell = Rect::from_min_size(pos2(x, rect.top()), vec2(HEIGHT, HEIGHT));
859            x += HEIGHT;
860            clipped.vline(cell.left(), cell.y_range(), stroke);
861            let response = ui
862                .interact(
863                    cell.intersect(mapping.rect),
864                    key.with(index),
865                    Sense::click(),
866                )
867                .on_hover_text(l10n::text(ui.ctx(), "Cancel"));
868            if response.hovered() {
869                clipped.rect_filled(cell.shrink(1.0), 1.0, line.color.gamma_multiply(0.2));
870            }
871            if response.clicked() {
872                pressed = Some(*button);
873            }
874            paint_glyph(&clipped, *button, cell.center(), line.color);
875        }
876        // After the grab area, which runs under them with extended lines: the last widget wins.
877        let group_left = rect.left() - exits_width;
878        let (_, released) = ExitButtons {
879            key,
880            exits: self.exits,
881            x: group_left,
882            y,
883            entry: value,
884            line_left: if options.extend_lines {
885                mapping.rect.left()
886            } else {
887                rect.left()
888            },
889            preview_right: rect.right(),
890            pnl: self.exit_pnl,
891        }
892        .show(ui, mapping, options, tags);
893        if let Some(side) = self.submit {
894            let size = vec2(58.0, HEIGHT);
895            let left = group_left - size.x - HEIGHT * 0.65;
896            let at = if left >= mapping.rect.left() + 8.0 {
897                pos2(left, rect.top())
898            } else {
899                pos2(
900                    group_left.max(mapping.rect.left() + 8.0),
901                    rect.bottom() + 4.0,
902                )
903            };
904            let button = Rect::from_min_size(at, size);
905            if ui
906                .put(
907                    button,
908                    egui::Button::new(
909                        egui::RichText::new(l10n::text(
910                            ui.ctx(),
911                            if side == OrderSide::Buy {
912                                "Buy"
913                            } else {
914                                "Sell"
915                            },
916                        ))
917                        .color(Color32::WHITE),
918                    )
919                    .fill(line.color)
920                    .stroke(Stroke::NONE),
921                )
922                .clicked()
923            {
924                pressed = Some(Button::Submit);
925            }
926        }
927        (dragged, pressed, released)
928    }
929}
930
931/// The body of a label in `color`: the colour's tint over the chart, opaque so that the line and
932/// the bars behind the label do not show through its text.
933fn label_fill(theme: &Theme, color: Color32) -> Color32 {
934    theme.chart.bg.blend(color.gamma_multiply(0.2))
935}
936
937/// A cross for cancel and close, two opposed arrows for reverse.
938fn paint_glyph(painter: &egui::Painter, button: Button, center: Pos2, color: Color32) {
939    let stroke = Stroke::new(1.5, color);
940    let r = 3.5;
941    let segment = |a, b| {
942        painter.line_segment([a, b], stroke);
943        painter.circle_filled(a, 0.75, color);
944        painter.circle_filled(b, 0.75, color);
945    };
946    match button {
947        Button::Submit => unreachable!(),
948        Button::Cancel => {
949            segment(center + vec2(-r, -r), center + vec2(r, r));
950            segment(center + vec2(-r, r), center + vec2(r, -r));
951        }
952        Button::Reverse => {
953            let r = 5.0;
954            for (dx, dir) in [(-2.5, -1.0), (2.5, 1.0)] {
955                let top = center + vec2(dx, dir * -r);
956                let tip = center + vec2(dx, dir * r);
957                segment(top, tip);
958                segment(tip, tip + vec2(-2.0, dir * -2.0));
959                segment(tip, tip + vec2(2.0, dir * -2.0));
960            }
961        }
962    }
963}
964
965/// A parent-coloured connector with hollow bracket nodes and a filled parent node.
966fn connector(ui: &Ui, mapping: &ChartMapping, entry: f64, exit: f64, color: Color32) {
967    let painter = ui.painter().with_clip_rect(mapping.rect);
968    let x = mapping.rect.right() - NODE_INSET;
969    let a = pos2(x, mapping.price_to_y(entry));
970    let b = pos2(x, mapping.price_to_y(exit));
971    painter.line_segment([a, b], Stroke::new(1.0, color));
972    painter.circle(b, 3.5, ui.active_theme().chart.bg, Stroke::new(1.0, color));
973    painter.circle_filled(a, 7.0, color.gamma_multiply(0.2));
974    painter.circle_filled(a, 3.0, color);
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use crate::model::{OrderId, OrderStatus};
981
982    /// Only a projected order's line is dashed, a label's body is an opaque tint, and the submit
983    /// button has no outline.
984    #[test]
985    fn working_lines_are_solid_and_labels_are_opaque_tints() {
986        for theme in [Theme::light(), Theme::dark()] {
987            let accent = theme.ui.accent;
988            let ctx = egui::Context::default();
989            crate::theme::apply_to_egui(&ctx, &theme);
990            let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
991            let order = Order::new(
992                OrderId::new("order"),
993                OrderRequest::new("SYM", OrderSide::Buy, 1.0, OrderType::Limit(95.0)),
994                OrderStatus::Working,
995            );
996            let position = Position::new("SYM", 1.0, 95.0);
997            for (submit, is_position) in [(false, false), (true, false), (false, true)] {
998                let options = TradingLineOptions {
999                    show_orders: true,
1000                    show_positions: true,
1001                    extend_lines: true,
1002                    label_alignment: TradingLabelAlignment::Right,
1003                    submit,
1004                    modify: false,
1005                    close: false,
1006                    reverse: false,
1007                    take_profit: false,
1008                    stop_loss: false,
1009                    order_exits: false,
1010                    pnl: None,
1011                    bracket_pnl: None,
1012                    last_price: None,
1013                    point_value: 1.0,
1014                    currency: "USD",
1015                    tick_size: 0.01,
1016                };
1017                for frame in 0..3 {
1018                    let mut y = 0.0;
1019                    let output = ctx.run_ui(
1020                        egui::RawInput {
1021                            screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0))),
1022                            ..Default::default()
1023                        },
1024                        |ui| {
1025                            chart.show(ui);
1026                            let mapping = chart.get_chart_mapping();
1027                            y = mapping.price_to_y(95.0);
1028                            let (orders, positions) = if is_position {
1029                                (vec![], vec![&position])
1030                            } else {
1031                                (vec![&order], vec![])
1032                            };
1033                            render_trading_lines(ui, &mapping, orders, positions, &options);
1034                        },
1035                    );
1036                    if frame == 2 {
1037                        let segments = output
1038                            .shapes
1039                            .iter()
1040                            .filter(|shape| {
1041                                matches!(&shape.shape, egui::Shape::LineSegment { points, stroke }
1042                                    if stroke.color == accent
1043                                        && (points[0].y - y).abs() < 0.1
1044                                        && (points[1].y - y).abs() < 0.1)
1045                            })
1046                            .count();
1047                        assert_eq!(segments > 1, submit, "{segments} segments");
1048                        let rects: Vec<_> = output
1049                            .shapes
1050                            .iter()
1051                            .filter_map(|shape| match &shape.shape {
1052                                egui::Shape::Rect(rect) => Some(rect),
1053                                _ => None,
1054                            })
1055                            .collect();
1056                        let body = rects
1057                            .iter()
1058                            .find(|rect| rect.stroke.color == accent && rect.stroke.width == 1.0)
1059                            .expect("label body");
1060                        assert!((19.0..=20.0).contains(&body.rect.height()));
1061                        assert_ne!(body.fill, theme.chart.bg);
1062                        assert_eq!(body.fill.a(), 255);
1063                        if submit {
1064                            let button = rects
1065                                .iter()
1066                                .find(|rect| rect.fill == accent)
1067                                .expect("submit button");
1068                            assert_eq!(button.stroke.width, 0.0);
1069                        }
1070                    }
1071                    output.drop_without_applying_deltas();
1072                }
1073            }
1074        }
1075    }
1076}