Skip to main content

egui_charts/drawings/domain/
options.rs

1//! Drawing options and configuration types.
2//!
3//! Contains styling enums ([`LineStyle`](crate::model::LineStyle), [`ArrowStyle`], [`FontWeight`]),
4//! interaction configuration ([`DrawingOptions`], [`HandlePos`]),
5//! per-timeframe visibility ([`TimeframeVisibility`]), Fibonacci level
6//! configuration ([`FibonacciConfig`], [`FibonacciLevel`]), and the settings of
7//! the volume profile, regression trend and position drawings
8//! ([`VolumeProfileConfig`](crate::data::VolumeProfileConfig), [`RegressionTrendConfig`], [`PositionSize`], [`PositionSizing`]).
9
10use crate::model::TimeframeUnit;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashSet};
13use std::ops::RangeInclusive;
14
15/// Identifies a selection handle position on a drawing.
16///
17/// Different tool types expose different handle layouts. Two-point tools use
18/// `Start` and `End`; rectangles use four corner and four edge handles;
19/// multi-point tools use indexed `Point(i)` handles.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum HandlePos {
22    /// First anchor point of the drawing.
23    Start,
24    /// Last anchor point of the drawing.
25    End,
26    /// Whole-drawing move, also used internally for body drags.
27    Middle,
28    /// Top-left corner (used by rectangle and similar axis-aligned shapes).
29    TopLeft,
30    /// Top-right corner.
31    TopRight,
32    /// Bottom-left corner.
33    BottomLeft,
34    /// Bottom-right corner.
35    BottomRight,
36    /// Top edge midpoint.
37    Top,
38    /// Bottom edge midpoint.
39    Bottom,
40    /// Left edge midpoint.
41    Left,
42    /// Right edge midpoint.
43    Right,
44    /// Indexed anchor point for multi-point drawings (e.g., `Point(2)` is the
45    /// third point in an XABCD pattern).
46    Point(usize),
47}
48
49/// Arrow/arrowhead style for line drawings
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, Serialize, Deserialize)]
51pub enum ArrowStyle {
52    /// No arrowhead
53    None,
54    /// Standard filled arrow (default)
55    #[default]
56    Arrow,
57    /// Open/outline arrow
58    OpenArrow,
59    /// Circle marker
60    Circle,
61    /// Square marker
62    Square,
63    /// Diamond marker
64    Diamond,
65}
66
67impl ArrowStyle {
68    /// Check if this style requires any rendering
69    #[inline]
70    pub fn is_visible(&self) -> bool {
71        !matches!(self, ArrowStyle::None)
72    }
73
74    /// Get the marker size multiplier relative to stroke width
75    #[inline]
76    pub fn size_multiplier(&self) -> f32 {
77        match self {
78            ArrowStyle::None => 0.0,
79            ArrowStyle::Arrow | ArrowStyle::OpenArrow => 4.0,
80            ArrowStyle::Circle => 3.0,
81            ArrowStyle::Square => 3.0,
82            ArrowStyle::Diamond => 3.5,
83        }
84    }
85
86    /// All available arrow styles
87    pub fn all() -> &'static [ArrowStyle] {
88        &[
89            ArrowStyle::None,
90            ArrowStyle::Arrow,
91            ArrowStyle::OpenArrow,
92            ArrowStyle::Circle,
93            ArrowStyle::Square,
94            ArrowStyle::Diamond,
95        ]
96    }
97}
98
99pub use crate::config::FontWeight;
100
101/// The intervals a drawing shows on.
102///
103/// While [`show_on_all`](Self::show_on_all) is set, a drawing shows on every interval. Otherwise it
104/// shows on the intervals named in [`visible_timeframes`](Self::visible_timeframes) and on those
105/// whose unit has a range in [`ranges`](Self::ranges) holding their value. An interval's unit and
106/// value are those of its label, as [`Timeframe::as_str`](crate::model::Timeframe::as_str) writes
107/// it: `90min` is 90 minutes, and a 24 hour interval, labelled `1D`, is 1 day.
108#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
109pub struct TimeframeVisibility {
110    /// Show on every interval.
111    pub show_on_all: bool,
112    /// Interval labels the drawing shows on, matched exactly.
113    #[serde(serialize_with = "serialize_sorted")]
114    pub visible_timeframes: HashSet<String>,
115    /// Values of each unit the drawing shows on, both ends included, such as 1 to 4 hours for
116    /// 1h, 2h, 3h and 4h. A range whose start is after its end holds no value.
117    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118    pub ranges: BTreeMap<TimeframeUnit, RangeInclusive<u64>>,
119}
120
121/// Writes the set sorted, so saving the same drawings gives the same file.
122fn serialize_sorted<S: serde::Serializer>(
123    set: &HashSet<String>,
124    serializer: S,
125) -> Result<S::Ok, S::Error> {
126    serializer.collect_seq(set.iter().collect::<std::collections::BTreeSet<_>>())
127}
128
129/// The value and unit of an interval label written by `Timeframe::as_str`, such as `90min`.
130fn interval_parts(label: &str) -> Option<(u64, TimeframeUnit)> {
131    let split = label.find(|c: char| !c.is_ascii_digit())?;
132    let unit = match &label[split..] {
133        "t" => TimeframeUnit::Tick,
134        "v" => TimeframeUnit::Volume,
135        "r" => TimeframeUnit::Range,
136        "delta" => TimeframeUnit::Delta,
137        "ms" => TimeframeUnit::Millisecond,
138        "s" => TimeframeUnit::Second,
139        "min" => TimeframeUnit::Minute,
140        "h" => TimeframeUnit::Hour,
141        "D" => TimeframeUnit::Day,
142        "W" => TimeframeUnit::Week,
143        "M" => TimeframeUnit::Month,
144        _ => return None,
145    };
146    Some((label[..split].parse().ok()?, unit))
147}
148
149impl TimeframeVisibility {
150    /// Create visibility for all timeframes (default)
151    pub fn all() -> Self {
152        Self {
153            show_on_all: true,
154            visible_timeframes: HashSet::new(),
155            ranges: BTreeMap::new(),
156        }
157    }
158
159    /// Create visibility for specific timeframes only
160    pub fn specific(timeframes: &[&str]) -> Self {
161        Self {
162            show_on_all: false,
163            visible_timeframes: timeframes.iter().map(|s| (*s).to_string()).collect(),
164            ranges: BTreeMap::new(),
165        }
166    }
167
168    /// Check if visible on the interval labelled `timeframe`
169    #[inline]
170    pub fn is_visible_on(&self, timeframe: &str) -> bool {
171        self.show_on_all
172            || self.visible_timeframes.contains(timeframe)
173            || (!self.ranges.is_empty()
174                && interval_parts(timeframe).is_some_and(|(value, unit)| {
175                    self.ranges
176                        .get(&unit)
177                        .is_some_and(|range| range.contains(&value))
178                }))
179    }
180
181    /// The values of `unit` the drawing shows on: its range, widened to cover the labels of that
182    /// unit in [`visible_timeframes`](Self::visible_timeframes). `None` when neither names the unit.
183    pub fn range(&self, unit: TimeframeUnit) -> Option<RangeInclusive<u64>> {
184        self.visible_timeframes
185            .iter()
186            .filter_map(|label| interval_parts(label))
187            .filter(|&(_, of)| of == unit)
188            .map(|(value, _)| value..=value)
189            .chain(self.ranges.get(&unit).cloned())
190            .reduce(|a, b| *a.start().min(b.start())..=*a.end().max(b.end()))
191    }
192
193    /// Show the drawing on `range` of `unit`, or on no interval of that unit for `None`, in place
194    /// of that unit's labels in [`visible_timeframes`](Self::visible_timeframes). A range turns
195    /// [`show_on_all`](Self::show_on_all) off.
196    pub fn set_range(&mut self, unit: TimeframeUnit, range: Option<RangeInclusive<u64>>) {
197        self.visible_timeframes
198            .retain(|label| interval_parts(label).is_none_or(|(_, of)| of != unit));
199        if let Some(range) = range {
200            self.show_on_all = false;
201            self.ranges.insert(unit, range);
202        } else {
203            self.ranges.remove(&unit);
204        }
205    }
206
207    /// Add a timeframe to visibility
208    pub fn add_timeframe(&mut self, timeframe: &str) {
209        self.show_on_all = false;
210        self.visible_timeframes.insert(timeframe.to_string());
211    }
212
213    /// Remove a timeframe from visibility
214    pub fn remove_timeframe(&mut self, timeframe: &str) {
215        self.visible_timeframes.remove(timeframe);
216        if self.visible_timeframes.is_empty() && self.ranges.is_empty() {
217            self.show_on_all = true;
218        }
219    }
220}
221
222/// Drawing interaction options
223#[derive(Debug, Clone)]
224pub struct DrawingOptions {
225    /// Enable snap to OHLC prices
226    pub snap_to_price: bool,
227    /// Enable snap to candle open/close times
228    pub snap_to_time: bool,
229    /// Snap distance in pixels
230    pub snap_distance: f32,
231    /// Enable magnet mode (snap to nearby points)
232    pub magnet_mode: bool,
233    /// Magnet distance in pixels
234    pub magnet_distance: f32,
235    /// Show selection handles
236    pub show_handles: bool,
237    /// Handle size in pixels
238    pub handle_size: f32,
239    /// Handle color (RGBA)
240    pub handle_color: [u8; 4],
241    /// Selected handle color (RGBA)
242    pub handle_sel_color: [u8; 4],
243}
244
245impl Default for DrawingOptions {
246    fn default() -> Self {
247        Self {
248            snap_to_price: true,
249            snap_to_time: true,
250            snap_distance: 10.0,
251            magnet_mode: false,
252            magnet_distance: 15.0,
253            show_handles: true,
254            handle_size: 6.0,
255            handle_color: [255, 255, 255, 200],
256            handle_sel_color: [242, 54, 69, 255], // Default red
257        }
258    }
259}
260
261/// Configuration for a single Fibonacci level
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct FibonacciLevel {
264    /// The ratio value (e.g., 0.236, 0.382, 0.618)
265    pub value: f32,
266    /// Legacy custom percentage label. Empty derives the label from `value`.
267    #[serde(default)]
268    pub label: String,
269    /// Level color as RGBA
270    pub color: [u8; 4],
271    /// Whether this level is visible
272    pub visible: bool,
273    /// Legacy price visibility, used when the drawing leaves `show_prices` unset.
274    pub show_price: bool,
275}
276
277impl FibonacciLevel {
278    /// Creates a new Fibonacci level with the given ratio, display label, and color.
279    ///
280    /// The level is visible by default with price display enabled.
281    pub fn new(value: f32, label: &str, color: [u8; 4]) -> Self {
282        Self {
283            value,
284            label: label.to_string(),
285            color,
286            visible: true,
287            show_price: true,
288        }
289    }
290
291    /// Label derived from the current ratio. Saved nonnumeric custom labels remain in percentage mode.
292    pub fn label(&self, as_percent: bool) -> String {
293        let numeric = self
294            .label
295            .strip_suffix('%')
296            .is_some_and(|v| v.parse::<f32>().is_ok());
297        if as_percent && !self.label.is_empty() && !numeric {
298            return self.label.clone();
299        }
300        let text = if as_percent {
301            format!("{:.4}", f64::from(self.value) * 100.0)
302        } else {
303            format!("{:.6}", self.value)
304        };
305        let text = text.trim_end_matches('0').trim_end_matches('.');
306        if as_percent {
307            format!("{text}%")
308        } else {
309            text.to_owned()
310        }
311    }
312}
313
314/// Configuration for all Fibonacci levels in a drawing
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316pub struct FibonacciConfig {
317    /// Individual level configurations
318    pub levels: Vec<FibonacciLevel>,
319    /// Width of every level line, in points.
320    #[serde(default = "fib_level_width")]
321    pub level_width: f32,
322    /// Line pattern shared by every level.
323    #[serde(default)]
324    pub level_style: crate::model::LineStyle,
325    /// Show background fill between levels
326    pub show_background: bool,
327    /// Background opacity (0-255)
328    pub background_opacity: u8,
329    /// Extend lines to the left
330    pub extend_left: bool,
331    /// Extend lines to the right
332    pub extend_right: bool,
333    /// Reverse the direction (flip vertically)
334    pub reverse: bool,
335    /// Show prices in level labels. `None` retains the tool's legacy choice:
336    /// inherited from each level.
337    #[serde(default)]
338    pub show_prices: Option<bool>,
339    /// Show the level value in labels; enabled when unset.
340    #[serde(default)]
341    pub show_levels: Option<bool>,
342    /// Format level values as percentages rather than ratios.
343    #[serde(default = "percent_levels")]
344    pub levels_as_percent: bool,
345    /// Place labels at the left end of level lines instead of the right.
346    #[serde(default)]
347    pub labels_on_left: bool,
348    /// Measure retracement and extension levels in logarithmic price when all anchors are positive.
349    #[serde(default)]
350    pub logarithmic: bool,
351}
352
353fn fib_level_width() -> f32 {
354    2.0
355}
356
357fn percent_levels() -> bool {
358    true
359}
360
361impl Default for FibonacciConfig {
362    /// Default Fibonacci retracement levels
363    fn default() -> Self {
364        Self {
365            levels: vec![
366                FibonacciLevel::new(0.0, "0%", [120, 123, 134, 255]),
367                FibonacciLevel::new(0.236, "23.6%", [242, 54, 69, 255]),
368                FibonacciLevel::new(0.382, "38.2%", [255, 152, 0, 255]),
369                FibonacciLevel::new(0.5, "50%", [76, 175, 80, 255]),
370                FibonacciLevel::new(0.618, "61.8%", [33, 150, 243, 255]),
371                FibonacciLevel::new(0.786, "78.6%", [156, 39, 176, 255]),
372                FibonacciLevel::new(1.0, "100%", [120, 123, 134, 255]),
373            ],
374            level_width: fib_level_width(),
375            level_style: crate::model::LineStyle::Solid,
376            show_background: false,
377            background_opacity: 30,
378            extend_left: false,
379            extend_right: false,
380            reverse: false,
381            logarithmic: false,
382            show_prices: None,
383            show_levels: None,
384            levels_as_percent: false,
385            labels_on_left: true,
386        }
387    }
388}
389
390impl FibonacciConfig {
391    /// Extension levels (beyond 100%)
392    pub fn extension_default() -> Self {
393        Self {
394            levels: vec![
395                FibonacciLevel::new(0.0, "0%", [120, 123, 134, 255]),
396                FibonacciLevel::new(0.618, "61.8%", [33, 150, 243, 255]),
397                FibonacciLevel::new(1.0, "100%", [120, 123, 134, 255]),
398                FibonacciLevel::new(1.272, "127.2%", [242, 54, 69, 255]),
399                FibonacciLevel::new(1.618, "161.8%", [255, 152, 0, 255]),
400                FibonacciLevel::new(2.0, "200%", [76, 175, 80, 255]),
401                FibonacciLevel::new(2.618, "261.8%", [156, 39, 176, 255]),
402            ],
403            level_width: fib_level_width(),
404            level_style: crate::model::LineStyle::Solid,
405            show_background: false,
406            background_opacity: 30,
407            extend_left: false,
408            extend_right: false,
409            reverse: false,
410            logarithmic: false,
411            show_prices: None,
412            show_levels: None,
413            levels_as_percent: false,
414            labels_on_left: true,
415        }
416    }
417
418    /// Initial levels for a tool, each in its own colour, with the background on at 20%.
419    /// Retracements, extensions and channels list further ratios hidden. Existing drawings
420    /// without a stored config keep their original renderer defaults; new tools store this
421    /// table when constructed.
422    pub fn for_tool(tool: super::DrawingToolType) -> Self {
423        use super::DrawingToolType as T;
424        use super::drawing::{BLUE, CYAN, GREEN, GREY, ORANGE, PINK, PURPLE, RED, TEAL};
425        let table = match tool {
426            T::FibonacciTimeZones => [0.0, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0, 34.0, 55.0, 89.0]
427                .map(|value| (value, if value == 0.0 { GREY } else { BLUE }))
428                .to_vec(),
429            T::TrendBasedFibTime => vec![
430                (0.0, GREY),
431                (0.382, RED),
432                (0.5, [129, 199, 132, 255]),
433                (0.618, GREEN),
434                (1.0, TEAL),
435                (1.382, CYAN),
436                (1.618, GREY),
437                (2.0, BLUE),
438                (2.382, PINK),
439                (2.618, PURPLE),
440                (3.0, [103, 58, 183, 255]),
441            ],
442            T::FibonacciSpeedFan => vec![
443                (0.0, GREY),
444                (0.25, ORANGE),
445                (0.382, CYAN),
446                (0.5, GREEN),
447                (0.618, TEAL),
448                (0.75, BLUE),
449                (1.0, GREY),
450            ],
451            T::FibonacciCircles | T::FibonacciSpeedResistanceArcs => vec![
452                (0.236, RED),
453                (0.382, ORANGE),
454                (0.5, TEAL),
455                (0.618, GREEN),
456                (0.786, CYAN),
457                (1.0, GREY),
458                (1.618, BLUE),
459                (2.618, PINK),
460                (3.618, BLUE),
461                (4.236, PINK),
462                (4.618, RED),
463            ],
464            T::FibonacciRetracement
465            | T::FibonacciExtension
466            | T::FibonacciChannel
467            | T::FibonacciWedge => {
468                let mut levels: Vec<(f32, _, _)> = [
469                    (0.0, GREY),
470                    (0.236, RED),
471                    (0.382, ORANGE),
472                    (0.5, GREEN),
473                    (0.618, TEAL),
474                    (0.786, CYAN),
475                    (1.0, GREY),
476                    (1.618, BLUE),
477                    (2.618, RED),
478                    (3.618, PURPLE),
479                    (4.236, PINK),
480                ]
481                .map(|(value, color)| (value, color, true))
482                .to_vec();
483                if tool == T::FibonacciWedge {
484                    levels.retain(|level| level.0 != 0.0);
485                    levels.push((4.618, RED, true));
486                    for level in &mut levels {
487                        level.2 = level.0 <= 1.0;
488                    }
489                } else {
490                    // Further ratios, listed hidden so they can be switched on without typing.
491                    let spare = [
492                        1.272, 1.414, 2.0, 2.272, 2.414, 3.0, 3.272, 3.414, 4.0, 4.272, 4.414,
493                        4.618, 4.764,
494                    ];
495                    let colors = [RED, ORANGE, GREEN, TEAL, CYAN, BLUE, PURPLE, PINK];
496                    levels.extend(
497                        (spare.into_iter().zip(colors.into_iter().cycle()))
498                            .map(|(value, color)| (value, color, false)),
499                    );
500                    levels.sort_by(|a, b| a.0.total_cmp(&b.0));
501                }
502                return Self {
503                    levels: levels
504                        .into_iter()
505                        .map(|(value, color, visible)| FibonacciLevel {
506                            visible,
507                            ..FibonacciLevel::new(value, "", color)
508                        })
509                        .collect(),
510                    show_background: true,
511                    background_opacity: 51,
512                    show_levels: (tool == T::FibonacciWedge).then_some(true),
513                    ..Self::default()
514                };
515            }
516            _ => return Self::default(),
517        };
518        Self {
519            levels: table
520                .into_iter()
521                .map(|(value, color)| {
522                    let mut level = FibonacciLevel::new(value, "", color);
523                    level.visible = tool != T::TrendBasedFibTime || value != 0.5;
524                    level
525                })
526                .collect(),
527            show_background: tool != T::FibonacciTimeZones,
528            background_opacity: 51,
529            levels_as_percent: false,
530            ..Self::default()
531        }
532    }
533
534    /// Label content; a tool without a price passes `None`.
535    pub(crate) fn label_text(&self, level: &FibonacciLevel, price: Option<&str>) -> String {
536        if self.show_prices.is_none()
537            && self.show_levels.is_none()
538            && !level.show_price
539            && level.label.is_empty()
540        {
541            return String::new();
542        }
543        let value = self
544            .show_levels
545            .unwrap_or(true)
546            .then(|| level.label(self.levels_as_percent));
547        let price = price.filter(|_| self.show_prices.unwrap_or(level.show_price));
548        match (value, price) {
549            (Some(value), Some(price)) => format!("{value} ({price})"),
550            (Some(value), None) => value,
551            (None, Some(price)) => price.to_owned(),
552            (None, None) => String::new(),
553        }
554    }
555
556    /// Get visible levels only
557    pub fn visible_levels(&self) -> impl Iterator<Item = &FibonacciLevel> {
558        self.levels.iter().filter(|l| l.visible)
559    }
560
561    /// Position of retracement level `value` between the first anchor `start`
562    /// and the second anchor `end`, in price units.
563    ///
564    /// Level 0 sits on the second anchor and level 1 on the first, so levels
565    /// measure back from the end of the move; `reverse` swaps the two anchors.
566    pub(crate) fn retracement_position(&self, start: f64, end: f64, value: f64) -> f64 {
567        let (from, to) = if self.reverse {
568            (start, end)
569        } else {
570            (end, start)
571        };
572        if self.logarithmic && from > 0.0 && to > 0.0 {
573            (from.ln() + (to.ln() - from.ln()) * value).exp()
574        } else {
575            from + (to - from) * value
576        }
577    }
578
579    /// Price of each visible level, in config order, between the anchor prices `start` and `end`:
580    /// a retracement's as [`Self::retracement_position`] places them, an extension's projected
581    /// from `origin` by `end - start`, or by the log swing when all three prices are positive.
582    pub(crate) fn level_prices(
583        &self,
584        start: f64,
585        end: f64,
586        extension: bool,
587        origin: f64,
588    ) -> impl Iterator<Item = f64> + '_ {
589        self.visible_levels().map(move |level| {
590            let value = f64::from(level.value);
591            if extension {
592                let value = if self.reverse { -value } else { value };
593                if self.logarithmic && start > 0.0 && end > 0.0 && origin > 0.0 {
594                    (origin.ln() + (end.ln() - start.ln()) * value).exp()
595                } else {
596                    origin + (end - start) * value
597                }
598            } else {
599                self.retracement_position(start, end, value)
600            }
601        })
602    }
603}
604
605/// Settings of the regression trend drawing.
606#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
607#[serde(try_from = "RegressionTrendFields")]
608pub struct RegressionTrendConfig {
609    deviations: f64,
610}
611
612/// Stored settings of a regression trend, checked by [`RegressionTrendConfig::new`] on load.
613#[derive(Deserialize)]
614struct RegressionTrendFields {
615    deviations: f64,
616}
617
618impl TryFrom<RegressionTrendFields> for RegressionTrendConfig {
619    type Error = &'static str;
620
621    fn try_from(fields: RegressionTrendFields) -> Result<Self, Self::Error> {
622        Self::new(fields.deviations).ok_or("regression trend settings out of bounds")
623    }
624}
625
626impl RegressionTrendConfig {
627    /// Bands `deviations` standard deviations either side of the fitted line
628    /// (above 0, at most 10); `None` when out of bounds.
629    pub fn new(deviations: f64) -> Option<Self> {
630        (deviations > 0.0 && deviations <= 10.0).then_some(Self { deviations })
631    }
632
633    /// Distance of the bands from the fitted line, in standard deviations.
634    pub fn deviations(&self) -> f64 {
635        self.deviations
636    }
637}
638
639impl Default for RegressionTrendConfig {
640    /// Bands two standard deviations away.
641    fn default() -> Self {
642        Self { deviations: 2.0 }
643    }
644}
645
646/// Instrument economics used by long and short position drawings.
647/// Quantities are lots; one lot contains `lot_size` units, each worth `point_value`
648/// in the account currency per price point. Defaults retain unit sizing without rounding.
649#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
650#[serde(default)]
651pub struct PositionSizing {
652    /// Account-currency value of one price point per underlying unit.
653    pub point_value: f64,
654    /// Underlying units per displayed lot.
655    pub lot_size: f64,
656    /// Permitted lot increment; zero leaves the quantity unrounded.
657    pub quantity_step: f64,
658    /// Notional value divided by required capital; positive and finite.
659    pub leverage: f64,
660}
661
662impl Default for PositionSizing {
663    fn default() -> Self {
664        Self {
665            point_value: 1.0,
666            lot_size: 1.0,
667            quantity_step: 0.0,
668            leverage: 1.0,
669        }
670    }
671}
672
673impl PositionSizing {
674    /// Whether the economics are finite and positive, with a nonnegative quantity step.
675    pub fn is_valid(&self) -> bool {
676        [
677            self.point_value,
678            self.lot_size,
679            self.leverage,
680            self.point_value * self.lot_size,
681        ]
682        .into_iter()
683        .all(|v| v.is_finite() && v > 0.0)
684            && self.quantity_step.is_finite()
685            && self.quantity_step >= 0.0
686    }
687
688    /// Required capital: absolute entry notional divided by leverage, in account currency.
689    /// Returns `None` for invalid settings or a nonpositive or nonfinite quantity.
690    pub fn required_capital(&self, entry: f64, quantity: f64) -> Option<f64> {
691        if !self.is_valid() || !quantity.is_finite() || quantity <= 0.0 {
692            return None;
693        }
694        let capital = entry.abs() * self.point_value * self.lot_size * quantity / self.leverage;
695        capital.is_finite().then_some(capital)
696    }
697}
698
699/// How a long or short position drawing sizes its quantity.
700#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
701pub enum PositionSize {
702    /// A fixed quantity, interpreted as lots by [`Self::quantity_with_sizing`].
703    Quantity(f64),
704    /// The quantity that loses this amount if the stop is hit.
705    RiskAmount(f64),
706    /// The quantity that loses `percent` of `account_size` if the stop is hit.
707    RiskPercent {
708        /// Account size, in the same currency as risk amounts and the configured point value.
709        account_size: f64,
710        /// Share of the account risked, in percent (above 0, at most 100).
711        percent: f64,
712    },
713}
714
715impl PositionSize {
716    /// Size in lots using the instrument's point value and lot size. Risk-based sizes and
717    /// fixed lot quantities round down to `quantity_step`, so rounding cannot increase risk.
718    /// Returns `None` if no positive permitted quantity fits, or inputs are invalid.
719    pub fn quantity_with_sizing(
720        &self,
721        entry: f64,
722        stop: f64,
723        sizing: &PositionSizing,
724    ) -> Option<f64> {
725        if !sizing.is_valid() {
726            return None;
727        }
728        let mut quantity = self.quantity(entry, stop)?;
729        if !matches!(self, Self::Quantity(_)) {
730            quantity /= sizing.point_value * sizing.lot_size;
731        }
732        if sizing.quantity_step > 0.0 {
733            let steps = quantity / sizing.quantity_step;
734            // Decimal steps can put an exact multiple just below its integer quotient.
735            let nearest = steps.round();
736            let steps = if (steps - nearest).abs() <= 4.0 * f64::EPSILON * steps.abs() {
737                nearest
738            } else {
739                steps.floor()
740            };
741            quantity = steps * sizing.quantity_step;
742        }
743        (quantity.is_finite() && quantity > 0.0).then_some(quantity)
744    }
745
746    /// Unit quantity at `entry` with its stop at `stop`, without instrument conversion.
747    /// Use [`Self::quantity_with_sizing`] for lot sizing and quantity-step rounding.
748    ///
749    /// `None` when a size from the risk has its stop at the entry, or a
750    /// setting is out of bounds (not positive and finite, or a percent above 100).
751    pub fn quantity(&self, entry: f64, stop: f64) -> Option<f64> {
752        let positive = |value: f64| value.is_finite() && value > 0.0;
753        let risk = match *self {
754            Self::Quantity(quantity) => return positive(quantity).then_some(quantity),
755            Self::RiskAmount(amount) => amount,
756            Self::RiskPercent {
757                account_size,
758                percent,
759            } if positive(account_size) && positive(percent) && percent <= 100.0 => {
760                account_size * (percent / 100.0)
761            }
762            Self::RiskPercent { .. } => return None,
763        };
764        let quantity = risk / (entry - stop).abs();
765        positive(quantity).then_some(quantity)
766    }
767}
768
769impl Default for PositionSize {
770    /// One unit, so the profit and loss read per unit.
771    fn default() -> Self {
772        Self::Quantity(1.0)
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::data::VolumeProfileConfig;
780    use crate::model::Timeframe;
781
782    /// Reference: quantity = risked amount / |entry - stop| (reference.py);
783    /// none when a size from the risk has its stop at the entry, or a setting
784    /// is out of bounds.
785    #[test]
786    fn position_size_quantity() {
787        let percent = |account_size, percent| PositionSize::RiskPercent {
788            account_size,
789            percent,
790        };
791        for (size, entry, stop, want) in [
792            (PositionSize::Quantity(3.0), 50.0, 48.0, Some(3.0)),
793            (PositionSize::RiskAmount(100.0), 50.0, 48.0, Some(50.0)),
794            (percent(10_000.0, 1.0), 50.0, 52.5, Some(40.0)),
795            (percent(2_000.0, 2.5), 1.1, 1.0975, Some(19999.99999999865)),
796            (PositionSize::RiskAmount(100.0), 50.0, 50.0, None),
797            (PositionSize::RiskAmount(-100.0), 50.0, 48.0, None),
798            (PositionSize::Quantity(f64::NAN), 50.0, 48.0, None),
799            (percent(10_000.0, 150.0), 50.0, 48.0, None),
800            (percent(-10_000.0, -1.0), 50.0, 48.0, None),
801            (percent(1e308, 100.0), 1.0, 0.0, Some(1e308)),
802        ] {
803            let got = size.quantity(entry, stop);
804            assert!(
805                got.zip(want)
806                    .map_or(got == want, |(got, want)| (got - want).abs() < 1e-6),
807                "{size:?}: got {got:?}, want {want:?}"
808            );
809        }
810
811        // Hand calculations in lots, independent of the renderer's label formatting.
812        let sizing = PositionSizing {
813            point_value: 50.0,
814            lot_size: 10.0,
815            quantity_step: 0.01,
816            leverage: 5.0,
817        };
818        for (size, stop, expected) in [
819            (PositionSize::RiskAmount(230.0), 97.5, Some(0.18)),
820            (PositionSize::RiskAmount(230.0), 102.5, Some(0.18)),
821            (
822                PositionSize::RiskPercent {
823                    account_size: 23000.0,
824                    percent: 1.0,
825                },
826                97.5,
827                Some(0.18),
828            ),
829            (PositionSize::Quantity(0.189), 97.5, Some(0.18)),
830            (PositionSize::Quantity(0.29), 97.5, Some(0.29)),
831            (PositionSize::Quantity(0.009), 97.5, None),
832            (PositionSize::RiskAmount(230.0), 100.0, None),
833        ] {
834            let quantity = size.quantity_with_sizing(100.0, stop, &sizing);
835            assert_eq!(quantity, expected, "{size:?}, stop {stop}");
836            let unleveraged = PositionSizing {
837                leverage: 1.0,
838                ..sizing
839            };
840            assert_eq!(
841                quantity,
842                size.quantity_with_sizing(100.0, stop, &unleveraged)
843            );
844        }
845        assert_eq!(sizing.required_capital(100.0, 0.18), Some(1800.0));
846        assert_eq!(
847            PositionSize::Quantity(2.13).quantity_with_sizing(
848                100.0,
849                97.5,
850                &PositionSizing {
851                    quantity_step: 0.25,
852                    ..sizing
853                }
854            ),
855            Some(2.0)
856        );
857        for sizing in [
858            PositionSizing {
859                point_value: 0.0,
860                ..sizing
861            },
862            PositionSizing {
863                lot_size: -1.0,
864                ..sizing
865            },
866            PositionSizing {
867                point_value: 1e-200,
868                lot_size: 1e-200,
869                ..sizing
870            },
871            PositionSizing {
872                quantity_step: -0.01,
873                ..sizing
874            },
875            PositionSizing {
876                leverage: f64::NAN,
877                ..sizing
878            },
879        ] {
880            assert!(
881                PositionSize::default()
882                    .quantity_with_sizing(100.0, 97.5, &sizing)
883                    .is_none()
884            );
885            assert!(sizing.required_capital(100.0, 0.18).is_none());
886        }
887    }
888
889    #[test]
890    fn drawing_settings_reject_out_of_bounds_values() {
891        assert!(VolumeProfileConfig::new(1, 1.0).is_some());
892        assert!(VolumeProfileConfig::new(1000, 0.01).is_some());
893        for (rows, value_area) in [(0, 0.7), (1001, 0.7), (24, 0.0), (24, 1.01), (24, f64::NAN)] {
894            assert!(VolumeProfileConfig::new(rows, value_area).is_none());
895        }
896        for height in [0.0, -1.0, f64::INFINITY, f64::NAN] {
897            assert!(
898                VolumeProfileConfig::default()
899                    .with_row_size(Some(height))
900                    .is_none()
901            );
902        }
903        assert!(RegressionTrendConfig::new(10.0).is_some());
904        for deviations in [0.0, -1.0, 10.5, f64::INFINITY, f64::NAN] {
905            assert!(RegressionTrendConfig::new(deviations).is_none());
906        }
907    }
908
909    /// A drawing shows on intervals whose unit and value, read from the label, fall in a range,
910    /// and on labels saved before ranges, matched exactly until their unit's range is set.
911    #[test]
912    fn test_timeframe_visibility() {
913        let mut vis = TimeframeVisibility::all();
914        assert!(vis.is_visible_on("1h"));
915        assert!(vis.is_visible_on("1D"));
916
917        vis.add_timeframe("1h");
918        assert!(vis.is_visible_on("1h"));
919        assert!(!vis.is_visible_on("1D"));
920
921        let n = |v| std::num::NonZeroU64::new(v).unwrap();
922        let mut vis = TimeframeVisibility::all();
923        vis.set_range(TimeframeUnit::Hour, Some(1..=4));
924        vis.set_range(TimeframeUnit::Minute, Some(1..=59));
925        vis.set_range(TimeframeUnit::Second, Some(1..=600));
926        vis.set_range(TimeframeUnit::Tick, Some(100..=1000));
927        for (timeframe, shown) in [
928            (Timeframe::Hour1, true),                // "1h", the start
929            (Timeframe::Custom(n(2 * 3600)), true),  // "2h", not one of the nine former names
930            (Timeframe::Hour4, true),                // "4h", the end
931            (Timeframe::Custom(n(5 * 3600)), false), // "5h"
932            (Timeframe::Day1, false),                // "1D"
933            (Timeframe::Min30, true),                // "30min"
934            (Timeframe::Custom(n(90 * 60)), false),  // "90min", 90 > 59
935            (Timeframe::Month1, false),              // "1M" is a month, not a minute
936            (Timeframe::Sec30, true),                // "30s"
937            (Timeframe::Ms500, false),               // "500ms" is not 500 seconds
938            (Timeframe::Ticks(n(99)), false),
939            (Timeframe::Ticks(n(100)), true),
940            (Timeframe::Ticks(n(1000)), true),
941            (Timeframe::Ticks(n(1001)), false),
942            (Timeframe::Range(n(100)), false), // "100r", unit not set
943            (Timeframe::Delta(n(100)), false), // "100delta"
944        ] {
945            assert_eq!(vis.is_visible_on(&timeframe.as_str()), shown, "{timeframe}");
946        }
947        vis.show_on_all = true;
948        assert!(vis.is_visible_on("1D"));
949
950        let mut saved = TimeframeVisibility::specific(&["1h", "4h", "1D"]);
951        assert_eq!(saved.range(TimeframeUnit::Hour), Some(1..=4));
952        assert!(!saved.is_visible_on("2h"));
953        saved.set_range(TimeframeUnit::Hour, Some(2..=3));
954        assert_eq!(saved.range(TimeframeUnit::Hour), Some(2..=3));
955        assert!(
956            !saved.is_visible_on("1h") && saved.is_visible_on("2h") && saved.is_visible_on("1D")
957        );
958        saved.remove_timeframe("1D");
959        assert!(!saved.is_visible_on("1D") && saved.is_visible_on("3h"));
960        saved.set_range(TimeframeUnit::Hour, None);
961        assert_eq!(saved.range(TimeframeUnit::Hour), None);
962
963        let legacy = r#"{"show_on_all":false,"visible_timeframes":["4h"]}"#;
964        let loaded: TimeframeVisibility = serde_json::from_str(legacy).unwrap();
965        assert_eq!(loaded, TimeframeVisibility::specific(&["4h"]));
966        assert_eq!(serde_json::to_string(&loaded).unwrap(), legacy);
967        let mut hours = TimeframeVisibility::default();
968        hours.set_range(TimeframeUnit::Hour, Some(1..=4));
969        let json = serde_json::to_string(&hours).unwrap();
970        assert_eq!(
971            json,
972            r#"{"show_on_all":false,"visible_timeframes":[],"ranges":{"Hour":{"start":1,"end":4}}}"#
973        );
974        assert_eq!(
975            serde_json::from_str::<TimeframeVisibility>(&json).unwrap(),
976            hours
977        );
978    }
979
980    /// Anchors 100 then 200: level v sits at 200 - 100 * v, or at 100 + 100 * v when reversed.
981    #[test]
982    fn fibonacci_retracement_position() {
983        for (reverse, value, want) in [
984            (false, 0.0, 200.0),
985            (false, 0.236, 176.4),
986            (false, 0.618, 138.2),
987            (false, 1.0, 100.0),
988            (true, 0.236, 123.6),
989            (true, 0.786, 178.6),
990        ] {
991            let config = FibonacciConfig {
992                reverse,
993                ..Default::default()
994            };
995            let got = config.retracement_position(100.0, 200.0, value);
996            assert!(
997                (got - want).abs() < 1e-9,
998                "reverse {reverse}, level {value}: got {got}, want {want}"
999            );
1000        }
1001        // With 0 at 10 and 1 at 1000, log10(price) = 1 + 2*r.
1002        // Rounded independently: 10^1.472, 10^2, 10^2.236.
1003        for (logarithmic, low, ratio, want) in [
1004            (true, 10.0, 0.236, 29.64831389524342),
1005            (true, 10.0, 0.5, 100.0),
1006            (true, 10.0, 0.618, 172.1868574986006),
1007            (false, 10.0, 0.236, 243.64),
1008            (false, 10.0, 0.5, 505.0),
1009            (false, 10.0, 0.618, 621.82),
1010            (true, 0.0, 0.5, 500.0),
1011            (true, -10.0, 0.5, 495.0),
1012        ] {
1013            for reverse in [false, true] {
1014                let config = FibonacciConfig {
1015                    logarithmic,
1016                    reverse,
1017                    ..Default::default()
1018                };
1019                let (start, end) = if reverse {
1020                    (low, 1000.0)
1021                } else {
1022                    (1000.0, low)
1023                };
1024                let got = config.retracement_position(start, end, ratio);
1025                assert!(
1026                    (got - want).abs() < 1e-9,
1027                    "log {logarithmic}, reverse {reverse}, ratio {ratio}: {got} != {want}"
1028                );
1029            }
1030        }
1031    }
1032}