1mod 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#[derive(Clone, Debug, PartialEq)]
22#[non_exhaustive]
23pub enum TradingLineAction {
24 Cancel(Order),
26 Modify(Order, OrderRequest),
29 Close(Position),
31 Reverse(Position),
33 Exit(Position, OrderRequest),
37}
38
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41pub enum PnlUnit {
42 #[default]
44 Money,
45 Ticks,
47 Percent,
49}
50
51impl PnlUnit {
52 pub const ALL: [Self; 3] = [Self::Money, Self::Ticks, Self::Percent];
54
55 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67pub enum TradingLabelAlignment {
68 Left,
70 Center,
72 #[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
92pub struct TradingLines {
94 pub action: Option<TradingLineAction>,
96 pub tags: Vec<(f64, Color32)>,
100 pub outlined: Vec<usize>,
102 pub submitted: Option<OrderRequest>,
104}
105
106#[derive(Clone)]
108pub struct TradingLineOptions<'a> {
109 pub show_orders: bool,
111 pub show_positions: bool,
113 pub extend_lines: bool,
115 pub label_alignment: TradingLabelAlignment,
117 pub submit: bool,
120 pub modify: bool,
122 pub close: bool,
125 pub reverse: bool,
127 pub take_profit: bool,
129 pub stop_loss: bool,
131 pub order_exits: bool,
136 pub pnl: Option<PnlUnit>,
138 pub bracket_pnl: Option<PnlUnit>,
141 pub last_price: Option<f64>,
143 pub point_value: f64,
145 pub currency: &'a str,
147 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
170pub 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 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 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 let cancel = level == Level::Entry || options.modify;
295 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 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
422fn 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
438fn direction(side: OrderSide) -> f64 {
440 match side {
441 OrderSide::Buy => 1.0,
442 OrderSide::Sell => -1.0,
443 }
444}
445
446fn 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
487fn 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
497fn 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
506fn 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 2 => buttons - 1.0 + HEIGHT * 0.65,
523 _ => buttons + HEIGHT * 0.65,
524 }
525}
526
527struct ExitButtons<'a> {
529 key: egui::Id,
530 exits: &'a [Level],
532 x: f32,
534 y: f32,
536 entry: f64,
538 line_left: f32,
540 preview_right: f32,
542 pnl: &'a dyn Fn(PnlUnit, f64) -> String,
544}
545
546impl ExitButtons<'_> {
547 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 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
679pub(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
689struct Label<'a> {
692 id: (&'a str, &'a str),
693 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 exits: &'a [Level],
702 exit_pnl: &'a dyn Fn(PnlUnit, f64) -> String,
704}
705
706impl Label<'_> {
707 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 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 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
931fn label_fill(theme: &Theme, color: Color32) -> Color32 {
934 theme.chart.bg.blend(color.gamma_multiply(0.2))
935}
936
937fn 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
965fn 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 #[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}