Skip to main content

flowsurface_data/chart/kline/
indicators.rs

1mod bundled;
2mod core;
3pub mod language;
4mod loader;
5pub mod manifest;
6mod registry;
7pub mod scripting;
8
9use super::composition::{
10    AxisBinding, LayerDataKind, MarkKind, PanelScaleMode, PanelValueLabelPolicy,
11    PanelValuePrecision,
12};
13use crate::chart::Basis;
14use crate::config::theme::{ThemeColor, ThemeColorRole, ThemeColorShade};
15use exchange::{TickerInfo, UnixMs};
16use indicator_engine::IndicatorFrame;
17use indicator_engine::PaneTarget;
18pub use indicator_engine::{BaseSeriesSource, IndicatorAuthor};
19use std::fmt::{Display, Formatter};
20use std::ops::Range;
21use std::sync::Arc;
22
23pub use core::input::{IndicatorInputRequirement, IndicatorInputSource, OpenInterestSeries};
24pub use core::output::{IndicatorOutputRef, OutputChannelSpec, OutputValue, OutputValueType};
25pub use core::runtime::IndicatorHost;
26
27pub use loader::{
28    IndicatorCatalogPaths, initialize_indicator_catalog, initialize_indicator_catalog_with_paths,
29    remove_indicator_source, write_indicator_source,
30};
31pub(crate) use registry::install_catalog_with_bundled_diagnostics;
32pub use registry::{
33    CatalogInstallError, IndicatorCatalog, IndicatorConfig, IndicatorConfigError, IndicatorId,
34    IndicatorIdError, IndicatorRegistration, all_indicators, availability, catalog,
35    declared_input_requirements, display_name, indicator_definition, panel_recipe,
36    requires_trade_data, table_columns, unsupported_reason,
37};
38
39pub(crate) fn region_intersects_source_range(
40    region: &indicator_engine::RegionOutput,
41    source_range: &Range<usize>,
42) -> bool {
43    if source_range.start >= source_range.end {
44        return false;
45    }
46    if region.extend_right {
47        return region.left_index < source_range.end;
48    }
49
50    let left = region.left_index.min(region.right_index);
51    let right = region.left_index.max(region.right_index);
52    left < source_range.end && right >= source_range.start
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct IndicatorInputAvailability {
57    pub klines: bool,
58    pub directional_volume: bool,
59    pub trades: bool,
60    pub open_interest: bool,
61}
62
63impl IndicatorInputAvailability {
64    fn from_frame(frame: &IndicatorFrame) -> Self {
65        Self {
66            klines: frame.chart.close.is_some(),
67            directional_volume: frame.chart.directional_volume_available,
68            trades: frame.chart.trade.is_some(),
69            open_interest: frame.open_interest.is_some(),
70        }
71    }
72
73    fn contains(self, source: IndicatorInputSource) -> bool {
74        match source {
75            IndicatorInputSource::Klines => self.klines,
76            IndicatorInputSource::DirectionalVolume => self.directional_volume,
77            IndicatorInputSource::Trades => self.trades,
78            IndicatorInputSource::OpenInterest => self.open_interest,
79        }
80    }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum IndicatorInputCoverageState {
85    Unknown,
86    Available,
87    Missing,
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct IndicatorInputCoverage {
92    by_runtime_index: Arc<Vec<IndicatorInputAvailability>>,
93    prefix_offset: usize,
94}
95
96impl IndicatorInputCoverage {
97    pub fn remap(&mut self, previous_indices: &[Option<usize>]) {
98        let by_runtime_index = previous_indices
99            .iter()
100            .map(|index| {
101                index
102                    .and_then(|index| self.at_index(index))
103                    .unwrap_or_default()
104            })
105            .collect();
106        self.by_runtime_index = Arc::new(by_runtime_index);
107        self.prefix_offset = 0;
108    }
109
110    pub fn remap_prefix(&mut self, prefix: usize) {
111        self.prefix_offset = self.prefix_offset.saturating_add(prefix);
112    }
113
114    fn at_index(&self, runtime_index: usize) -> Option<IndicatorInputAvailability> {
115        runtime_index
116            .checked_sub(self.prefix_offset)
117            .and_then(|index| self.by_runtime_index.get(index))
118            .copied()
119    }
120
121    pub fn from_frames(frames: &[IndicatorFrame]) -> Self {
122        let mut by_runtime_index = vec![
123            IndicatorInputAvailability::default();
124            frames
125                .iter()
126                .map(|frame| frame.index)
127                .max()
128                .map_or(0, |index| index.saturating_add(1))
129        ];
130        for frame in frames {
131            by_runtime_index[frame.index] = IndicatorInputAvailability::from_frame(frame);
132        }
133
134        Self {
135            by_runtime_index: Arc::new(by_runtime_index),
136            prefix_offset: 0,
137        }
138    }
139
140    pub fn update_frames(&mut self, frames: &[IndicatorFrame]) {
141        let Some(max_index) = frames
142            .iter()
143            .filter_map(|frame| frame.index.checked_sub(self.prefix_offset))
144            .max()
145        else {
146            return;
147        };
148
149        let by_runtime_index = Arc::make_mut(&mut self.by_runtime_index);
150        by_runtime_index.resize(
151            by_runtime_index.len().max(max_index.saturating_add(1)),
152            Default::default(),
153        );
154        for frame in frames {
155            let Some(index) = frame.index.checked_sub(self.prefix_offset) else {
156                continue;
157            };
158            by_runtime_index[index] = IndicatorInputAvailability::from_frame(frame);
159        }
160    }
161
162    pub fn state(&self, source: IndicatorInputSource) -> IndicatorInputCoverageState {
163        if self.by_runtime_index.is_empty() {
164            return IndicatorInputCoverageState::Unknown;
165        }
166        if self
167            .by_runtime_index
168            .iter()
169            .copied()
170            .any(|availability| availability.contains(source))
171        {
172            IndicatorInputCoverageState::Available
173        } else {
174            IndicatorInputCoverageState::Missing
175        }
176    }
177
178    pub fn at(&self, source: IndicatorInputSource, runtime_index: usize) -> bool {
179        self.at_index(runtime_index)
180            .is_some_and(|availability| availability.contains(source))
181    }
182}
183
184/// Declarative metadata shared by startup-loaded indicator sources.
185///
186/// The catalog supplies stable IDs and type-erased factories, while the
187/// compiled `.ind` programs describe their feeds and channels here, so chart
188/// code does not need implementation-specific dispatch.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct IndicatorSourceInput {
191    pub id: String,
192    pub label: String,
193    pub default_label: String,
194    pub allow_indicator: bool,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum IndicatorAttachmentMode {
199    Overlay,
200    Panel,
201}
202
203#[derive(Debug, Clone)]
204pub struct IndicatorDefinition {
205    pub display_name: String,
206    pub author: Option<IndicatorAuthor>,
207    pub description: Option<String>,
208    pub source: Option<String>,
209    pub sourceable: bool,
210    pub inputs: Vec<IndicatorInputRequirement>,
211    pub source_inputs: Vec<IndicatorSourceInput>,
212    pub attachment_mode: IndicatorAttachmentMode,
213    pub outputs: Vec<OutputChannelSpec>,
214    pub style_slots: Vec<IndicatorColorSpec>,
215    pub region_style_slots: Vec<String>,
216    pub mark_specs: Vec<IndicatorMarkSpec>,
217    pub segment_specs: Vec<IndicatorSegmentSpec>,
218    pub overlay_channels: Vec<IndicatorOverlayChannelSpec>,
219    pub fill_specs: Vec<IndicatorFillSpec>,
220    pub panel_recipe: IndicatorPanelRecipe,
221}
222
223/// One output channel bound to a layer in an indicator's default view.
224///
225/// The channel ID is resolved against the definition at composition time, so
226/// a view never needs to know about an indicator's implementation type.
227#[derive(Debug, Clone, PartialEq)]
228pub struct IndicatorOutputLayerSpec {
229    pub channel_id: String,
230    pub layer_name: String,
231    pub pane: PaneTarget,
232    pub data_kind: LayerDataKind,
233    pub mark: MarkKind,
234    pub axis: AxisBinding,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum IndicatorDefinitionError {
239    EmptyDisplayName,
240    EmptyInputId,
241    EmptyOutputId,
242    EmptyOutputLabel,
243    InvalidOutputPrecision,
244    InvalidOutputWidth,
245    DuplicateInput,
246    DuplicateOutput,
247    EmptyStyleSlot,
248    DuplicateStyleSlot,
249    UnknownOutputStyleSlot,
250    EmptyMarkSource,
251    UnknownMarkSource,
252    InvalidMarkFallback,
253    EmptySegmentId,
254    EmptySegmentLabel,
255    DuplicateSegment,
256    InvalidSegmentWidth,
257    UnknownSegmentStyleSlot,
258    EmptyLayerName,
259    IncompatibleLayerMark,
260    InvalidOverlayWidth,
261    InvalidFillOpacity,
262    UnknownStyleBinding,
263    EmptyReadoutLabel,
264    EmptyTableColumnLabel,
265    EmptyOutputLayers,
266    UnknownViewOutput,
267    DuplicateViewOutput,
268    BooleanPlotOutput,
269    IncompatibleTableOutput,
270}
271
272impl IndicatorDefinition {
273    pub fn new(
274        display_name: impl Into<String>,
275        inputs: impl Into<Vec<IndicatorInputRequirement>>,
276        outputs: impl Into<Vec<OutputChannelSpec>>,
277        panel_recipe: IndicatorPanelRecipe,
278    ) -> Self {
279        Self {
280            display_name: display_name.into(),
281            author: None,
282            description: None,
283            source: None,
284            sourceable: false,
285            inputs: inputs.into(),
286            source_inputs: Vec::new(),
287            attachment_mode: IndicatorAttachmentMode::Panel,
288            outputs: outputs.into(),
289            style_slots: Vec::new(),
290            region_style_slots: Vec::new(),
291            mark_specs: Vec::new(),
292            segment_specs: Vec::new(),
293            overlay_channels: Vec::new(),
294            fill_specs: Vec::new(),
295            panel_recipe,
296        }
297    }
298
299    pub fn with_style_slots(mut self, style_slots: impl Into<Vec<IndicatorColorSpec>>) -> Self {
300        self.style_slots = style_slots.into();
301        Self { ..self }
302    }
303
304    pub fn with_region_style_slots(mut self, slots: impl Into<Vec<String>>) -> Self {
305        self.region_style_slots = slots.into();
306        Self { ..self }
307    }
308
309    pub fn with_source_inputs(
310        mut self,
311        source_inputs: impl Into<Vec<IndicatorSourceInput>>,
312    ) -> Self {
313        self.source_inputs = source_inputs.into();
314        Self { ..self }
315    }
316
317    pub fn with_sourceable(mut self, sourceable: bool) -> Self {
318        self.sourceable = sourceable;
319        Self { ..self }
320    }
321
322    pub fn with_author(mut self, author: Option<IndicatorAuthor>) -> Self {
323        self.author = author;
324        Self { ..self }
325    }
326
327    pub fn with_description(mut self, description: Option<String>) -> Self {
328        self.description = description;
329        Self { ..self }
330    }
331
332    pub fn with_source(mut self, source: Option<String>) -> Self {
333        self.source = source;
334        Self { ..self }
335    }
336
337    pub fn with_attachment_mode(mut self, attachment_mode: IndicatorAttachmentMode) -> Self {
338        self.attachment_mode = attachment_mode;
339        Self { ..self }
340    }
341
342    pub fn with_display_specs(
343        mut self,
344        mark_specs: impl Into<Vec<IndicatorMarkSpec>>,
345        overlay_channels: impl Into<Vec<IndicatorOverlayChannelSpec>>,
346        fill_specs: impl Into<Vec<IndicatorFillSpec>>,
347    ) -> Self {
348        self.mark_specs = mark_specs.into();
349        self.overlay_channels = overlay_channels.into();
350        self.fill_specs = fill_specs.into();
351        Self { ..self }
352    }
353
354    pub fn with_segment_specs(
355        mut self,
356        segment_specs: impl Into<Vec<IndicatorSegmentSpec>>,
357    ) -> Self {
358        self.segment_specs = segment_specs.into();
359        Self { ..self }
360    }
361
362    pub fn output(&self, id: &str) -> Option<&OutputChannelSpec> {
363        self.outputs.iter().find(|output| output.id == id)
364    }
365
366    fn numeric_plot_output(&self, id: &str) -> Option<&OutputChannelSpec> {
367        let output = self.output(id)?;
368        (output.value_type == OutputValueType::Number
369            && self
370                .output_layers()
371                .iter()
372                .any(|layer| layer.channel_id == id))
373        .then_some(output)
374    }
375
376    pub fn sourceable_numeric_output(&self, id: &str) -> Option<&OutputChannelSpec> {
377        self.sourceable
378            .then(|| self.numeric_plot_output(id))
379            .flatten()
380    }
381
382    pub fn has_numeric_plot_layers(&self) -> bool {
383        self.output_layers()
384            .iter()
385            .any(|layer| self.numeric_plot_output(&layer.channel_id).is_some())
386    }
387
388    pub fn default_view(&self) -> &IndicatorPanelRecipe {
389        &self.panel_recipe
390    }
391
392    pub fn output_layers(&self) -> &[IndicatorOutputLayerSpec] {
393        match &self.panel_recipe {
394            IndicatorPanelRecipe::Plot { layers, .. }
395            | IndicatorPanelRecipe::PrimaryOverlay { layers, .. } => layers,
396            IndicatorPanelRecipe::Table { .. } => &[],
397        }
398    }
399
400    pub fn segment_specs(&self) -> &[IndicatorSegmentSpec] {
401        &self.segment_specs
402    }
403
404    pub fn requires(&self, source: IndicatorInputSource) -> bool {
405        self.inputs
406            .iter()
407            .any(|requirement| requirement.source == source && requirement.is_required())
408    }
409
410    pub fn uses(&self, source: IndicatorInputSource) -> bool {
411        self.inputs
412            .iter()
413            .any(|requirement| requirement.source == source)
414    }
415
416    pub fn validate(&self) -> Result<(), IndicatorDefinitionError> {
417        if self.display_name.trim().is_empty() {
418            return Err(IndicatorDefinitionError::EmptyDisplayName);
419        }
420
421        for (index, input) in self.inputs.iter().enumerate() {
422            if input.id().trim().is_empty() {
423                return Err(IndicatorDefinitionError::EmptyInputId);
424            }
425            if self.inputs[..index]
426                .iter()
427                .any(|previous| previous.source == input.source)
428            {
429                return Err(IndicatorDefinitionError::DuplicateInput);
430            }
431        }
432
433        for (index, output) in self.outputs.iter().enumerate() {
434            if output.id.trim().is_empty() {
435                return Err(IndicatorDefinitionError::EmptyOutputId);
436            }
437            if output.label.trim().is_empty() {
438                return Err(IndicatorDefinitionError::EmptyOutputLabel);
439            }
440            if !output.precision.is_valid() {
441                return Err(IndicatorDefinitionError::InvalidOutputPrecision);
442            }
443            if !output.line_width.is_finite() || output.line_width <= 0.0 {
444                return Err(IndicatorDefinitionError::InvalidOutputWidth);
445            }
446            if self.outputs[..index]
447                .iter()
448                .any(|previous| previous.id == output.id)
449            {
450                return Err(IndicatorDefinitionError::DuplicateOutput);
451            }
452            if let Some(style_slot) = output.style_slot.as_deref() {
453                if style_slot.trim().is_empty() {
454                    return Err(IndicatorDefinitionError::EmptyStyleSlot);
455                }
456                if !self.style_slots.iter().any(|slot| slot.id == style_slot) {
457                    return Err(IndicatorDefinitionError::UnknownOutputStyleSlot);
458                }
459            }
460            if let Some(style_by) = output.style_by.as_deref()
461                && self
462                    .output(style_by)
463                    .is_none_or(|binding| binding.value_type != OutputValueType::Boolean)
464            {
465                return Err(IndicatorDefinitionError::UnknownStyleBinding);
466            }
467        }
468
469        for (index, style_slot) in self.style_slots.iter().enumerate() {
470            if style_slot.id.trim().is_empty() {
471                return Err(IndicatorDefinitionError::EmptyStyleSlot);
472            }
473            if self.style_slots[..index]
474                .iter()
475                .any(|previous| previous.id == style_slot.id)
476            {
477                return Err(IndicatorDefinitionError::DuplicateStyleSlot);
478            }
479        }
480
481        let validate_style_slot = |style_slot: &str| {
482            if style_slot.trim().is_empty() {
483                return Err(IndicatorDefinitionError::EmptyStyleSlot);
484            }
485            if !self.style_slots.iter().any(|slot| slot.id == style_slot) {
486                return Err(IndicatorDefinitionError::UnknownOutputStyleSlot);
487            }
488            Ok(())
489        };
490        for style_slot in &self.region_style_slots {
491            validate_style_slot(style_slot)?;
492        }
493        let validate_source = |channel: &str| {
494            self.output(channel)
495                .map(|_| ())
496                .ok_or(IndicatorDefinitionError::UnknownViewOutput)
497        };
498        let validate_numeric_source = |channel: &str| {
499            validate_source(channel)?;
500            if self
501                .output(channel)
502                .is_some_and(|output| output.value_type == OutputValueType::Number)
503            {
504                Ok(())
505            } else {
506                Err(IndicatorDefinitionError::BooleanPlotOutput)
507            }
508        };
509
510        for mark in &self.mark_specs {
511            if mark.label.trim().is_empty() {
512                return Err(IndicatorDefinitionError::EmptyLayerName);
513            }
514            if mark.source_channel.trim().is_empty() {
515                return Err(IndicatorDefinitionError::EmptyMarkSource);
516            }
517            let Some(output) = self.output(&mark.source_channel) else {
518                return Err(IndicatorDefinitionError::UnknownMarkSource);
519            };
520            if output.value_type != OutputValueType::Number {
521                return Err(IndicatorDefinitionError::UnknownMarkSource);
522            }
523            if mark.color_slot.trim().is_empty() {
524                return Err(IndicatorDefinitionError::EmptyStyleSlot);
525            }
526            validate_style_slot(&mark.color_slot)?;
527            if mark.true_color_slot.is_some() != mark.false_color_slot.is_some() {
528                return Err(IndicatorDefinitionError::UnknownStyleBinding);
529            }
530            if let Some(slot) = mark.true_color_slot.as_deref() {
531                validate_style_slot(slot)?;
532            }
533            if let Some(slot) = mark.false_color_slot.as_deref() {
534                validate_style_slot(slot)?;
535            }
536            if mark.highlight_by.is_some() != mark.highlight_color_slot.is_some() {
537                return Err(IndicatorDefinitionError::UnknownStyleBinding);
538            }
539            if let Some(highlight_by) = mark.highlight_by.as_deref()
540                && (self
541                    .output(highlight_by)
542                    .is_none_or(|binding| binding.value_type != OutputValueType::Boolean)
543                    || !matches!(
544                        mark.mark,
545                        MarkKind::Bar(
546                            super::composition::BarMode::Regular
547                                | super::composition::BarMode::Histogram(_)
548                        )
549                    ))
550            {
551                return Err(IndicatorDefinitionError::UnknownStyleBinding);
552            }
553            if let Some(slot) = mark.highlight_color_slot.as_deref() {
554                validate_style_slot(slot)?;
555            }
556        }
557
558        for mark in &self.mark_specs {
559            let Some(fallback_mark) = mark.fallback_mark else {
560                continue;
561            };
562            if fallback_mark == mark.mark {
563                return Err(IndicatorDefinitionError::InvalidMarkFallback);
564            }
565            let fallback_marks = self
566                .mark_specs
567                .iter()
568                .filter(|candidate| candidate.mark == fallback_mark)
569                .collect::<Vec<_>>();
570            if fallback_marks.len() != 1 || fallback_marks[0].required_input.is_some() {
571                return Err(IndicatorDefinitionError::InvalidMarkFallback);
572            }
573        }
574
575        for (index, segment) in self.segment_specs.iter().enumerate() {
576            if segment.id.trim().is_empty() {
577                return Err(IndicatorDefinitionError::EmptySegmentId);
578            }
579            if segment.label.trim().is_empty() {
580                return Err(IndicatorDefinitionError::EmptySegmentLabel);
581            }
582            if self.segment_specs[..index]
583                .iter()
584                .any(|previous| previous.id == segment.id)
585            {
586                return Err(IndicatorDefinitionError::DuplicateSegment);
587            }
588            if !segment.line_width.is_finite() || segment.line_width <= 0.0 {
589                return Err(IndicatorDefinitionError::InvalidSegmentWidth);
590            }
591            if segment.color_slot.trim().is_empty()
592                || !self
593                    .style_slots
594                    .iter()
595                    .any(|style| style.id == segment.color_slot)
596            {
597                return Err(IndicatorDefinitionError::UnknownSegmentStyleSlot);
598            }
599        }
600
601        for channel in &self.overlay_channels {
602            validate_numeric_source(&channel.source)?;
603            validate_style_slot(&channel.color_slot)?;
604            if !channel.line_width.is_finite() || channel.line_width <= 0.0 {
605                return Err(IndicatorDefinitionError::InvalidOverlayWidth);
606            }
607            if let Some(style_by) = channel.style_by.as_deref()
608                && self
609                    .output(style_by)
610                    .is_none_or(|binding| binding.value_type != OutputValueType::Boolean)
611            {
612                return Err(IndicatorDefinitionError::UnknownStyleBinding);
613            }
614        }
615
616        for fill in &self.fill_specs {
617            validate_numeric_source(&fill.upper)?;
618            validate_numeric_source(&fill.lower)?;
619            validate_style_slot(&fill.color_slot)?;
620            if fill.true_color_slot.is_some() != fill.false_color_slot.is_some() {
621                return Err(IndicatorDefinitionError::UnknownStyleBinding);
622            }
623            if let Some(style_slot) = fill.true_color_slot.as_deref() {
624                validate_style_slot(style_slot)?;
625            }
626            if let Some(style_slot) = fill.false_color_slot.as_deref() {
627                validate_style_slot(style_slot)?;
628            }
629            if let Some(style_by) = fill.style_by.as_deref()
630                && self
631                    .output(style_by)
632                    .is_none_or(|binding| binding.value_type != OutputValueType::Boolean)
633            {
634                return Err(IndicatorDefinitionError::UnknownStyleBinding);
635            }
636            if !fill.opacity.is_finite() || !(0.0..=1.0).contains(&fill.opacity) {
637                return Err(IndicatorDefinitionError::InvalidFillOpacity);
638            }
639        }
640
641        let validate_layers = |layers: &[IndicatorOutputLayerSpec]| {
642            for (index, layer) in layers.iter().enumerate() {
643                if layer.layer_name.trim().is_empty() {
644                    return Err(IndicatorDefinitionError::EmptyLayerName);
645                }
646                let Some(output) = self.output(&layer.channel_id) else {
647                    return Err(IndicatorDefinitionError::UnknownViewOutput);
648                };
649                if output.value_type == OutputValueType::Boolean {
650                    return Err(IndicatorDefinitionError::BooleanPlotOutput);
651                }
652                if super::composition::resolve_mark_for_data_kind(layer.mark, layer.data_kind)
653                    != layer.mark
654                {
655                    return Err(IndicatorDefinitionError::IncompatibleLayerMark);
656                }
657                if layers[..index]
658                    .iter()
659                    .any(|previous| previous.channel_id == layer.channel_id)
660                {
661                    return Err(IndicatorDefinitionError::DuplicateViewOutput);
662                }
663            }
664            Ok(())
665        };
666
667        match &self.panel_recipe {
668            IndicatorPanelRecipe::Plot {
669                layers, readouts, ..
670            } => {
671                if layers.is_empty() {
672                    return Err(IndicatorDefinitionError::EmptyOutputLayers);
673                }
674                validate_layers(layers)?;
675                for readout in readouts {
676                    if readout.label.trim().is_empty() {
677                        return Err(IndicatorDefinitionError::EmptyReadoutLabel);
678                    }
679                    validate_numeric_source(&readout.source)?;
680                    validate_style_slot(&readout.color_slot)?;
681                }
682            }
683            IndicatorPanelRecipe::Table { columns, .. } => {
684                for column in columns {
685                    if column.label.trim().is_empty() {
686                        return Err(IndicatorDefinitionError::EmptyTableColumnLabel);
687                    }
688                    match &column.color {
689                        TableColumnColor::Static { slot } => {
690                            validate_style_slot(slot)?;
691                        }
692                        TableColumnColor::Signed {
693                            positive_slot,
694                            negative_slot,
695                        } => {
696                            validate_style_slot(positive_slot)?;
697                            validate_style_slot(negative_slot)?;
698                        }
699                    }
700                    let output = self
701                        .output(&column.value)
702                        .ok_or(IndicatorDefinitionError::UnknownViewOutput)?;
703                    let expected_type = match column.format {
704                        TableColumnFormat::Boolean => OutputValueType::Boolean,
705                        TableColumnFormat::Quantity
706                        | TableColumnFormat::Integer
707                        | TableColumnFormat::Percent => OutputValueType::Number,
708                    };
709                    if output.value_type != expected_type {
710                        return Err(IndicatorDefinitionError::IncompatibleTableOutput);
711                    }
712                    if matches!(&column.color, TableColumnColor::Signed { .. })
713                        && output.value_type != OutputValueType::Number
714                    {
715                        return Err(IndicatorDefinitionError::IncompatibleTableOutput);
716                    }
717                }
718            }
719            IndicatorPanelRecipe::PrimaryOverlay { layers, .. } => {
720                if layers.is_empty() {
721                    return Err(IndicatorDefinitionError::EmptyOutputLayers);
722                }
723                validate_layers(layers)?;
724            }
725        }
726
727        Ok(())
728    }
729}
730
731#[derive(Debug, Clone, Copy, Default)]
732pub struct IndicatorHistoryRequirement {
733    history_start: Option<UnixMs>,
734    history_bars: u64,
735    warmup_bars: u64,
736}
737
738impl IndicatorHistoryRequirement {
739    pub fn new(history_start: Option<UnixMs>, history_bars: u64, warmup_bars: u64) -> Self {
740        Self {
741            history_start,
742            history_bars,
743            warmup_bars,
744        }
745    }
746
747    pub fn history_start(self) -> Option<UnixMs> {
748        self.history_start
749    }
750
751    pub fn history_bars(self) -> u64 {
752        self.history_bars
753    }
754
755    pub fn warmup_bars(self) -> u64 {
756        self.warmup_bars
757    }
758
759    pub(crate) fn merge(self, other: Self) -> Self {
760        Self {
761            history_start: match (self.history_start, other.history_start) {
762                (Some(left), Some(right)) => Some(left.min(right)),
763                (Some(time), None) | (None, Some(time)) => Some(time),
764                (None, None) => None,
765            },
766            history_bars: self.history_bars.max(other.history_bars),
767            warmup_bars: self.warmup_bars.max(other.warmup_bars),
768        }
769    }
770}
771
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct IndicatorColorSpec {
774    pub id: String,
775    pub label: String,
776    pub default_role: ThemeColorRole,
777    pub default_shade: ThemeColorShade,
778    pub required_input: Option<IndicatorInputSource>,
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct IndicatorMarkSpec {
783    pub label: String,
784    pub mark: MarkKind,
785    pub source_channel: String,
786    pub required_input: Option<IndicatorInputSource>,
787    pub fallback_mark: Option<MarkKind>,
788    /// Fallback style when no conditional binding is available.
789    pub color_slot: String,
790    /// Explicit style selected when the mark's boolean binding is true.
791    pub true_color_slot: Option<String>,
792    /// Explicit style selected when the mark's boolean binding is false.
793    pub false_color_slot: Option<String>,
794    /// Optional boolean output that draws a non-destructive bar outline.
795    pub highlight_by: Option<String>,
796    /// Color slot used by the optional bar outline.
797    pub highlight_color_slot: Option<String>,
798}
799
800#[derive(Debug, Clone, PartialEq)]
801pub struct IndicatorSegmentSpec {
802    pub id: String,
803    pub label: String,
804    pub color_slot: String,
805    pub line_width: f32,
806}
807
808#[derive(Debug, Clone, PartialEq)]
809pub struct IndicatorOverlayChannelSpec {
810    pub label: String,
811    pub source: String,
812    pub line_width: f32,
813    pub color_role: ThemeColorRole,
814    pub color_slot: String,
815    pub style_by: Option<String>,
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub enum IndicatorReadoutScope {
820    Always,
821    Mark(MarkKind),
822}
823
824impl IndicatorReadoutScope {
825    pub fn matches(self, mark: MarkKind) -> bool {
826        match self {
827            Self::Always => true,
828            Self::Mark(expected) => expected == mark,
829        }
830    }
831}
832
833#[derive(Debug, Clone, PartialEq)]
834pub struct IndicatorReadoutSpec {
835    pub label: String,
836    pub source: String,
837    pub color_role: ThemeColorRole,
838    pub color_slot: String,
839    pub scope: IndicatorReadoutScope,
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
843pub enum IndicatorFillGeometry {
844    PanelBand,
845    SeriesBand,
846}
847
848#[derive(Debug, Clone, PartialEq)]
849pub struct IndicatorFillSpec {
850    pub upper: String,
851    pub lower: String,
852    pub upper_offset_bars: i32,
853    pub lower_offset_bars: i32,
854    pub style_by: Option<String>,
855    pub geometry: IndicatorFillGeometry,
856    pub color_slot: String,
857    pub true_color_slot: Option<String>,
858    pub false_color_slot: Option<String>,
859    pub opacity: f32,
860}
861
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub enum TableColumnFormat {
864    Quantity,
865    Integer,
866    Percent,
867    Boolean,
868}
869
870#[derive(Debug, Clone, PartialEq, Eq)]
871pub enum TableColumnColor {
872    Static {
873        slot: String,
874    },
875    Signed {
876        positive_slot: String,
877        negative_slot: String,
878    },
879}
880
881#[derive(Debug, Clone, PartialEq, Eq)]
882pub struct TableColumnSpec {
883    pub label: String,
884    pub value: String,
885    pub format: TableColumnFormat,
886    pub color: TableColumnColor,
887}
888
889impl IndicatorColorSpec {
890    pub fn new(
891        id: impl Into<String>,
892        label: impl Into<String>,
893        default_role: ThemeColorRole,
894        default_shade: ThemeColorShade,
895    ) -> Self {
896        Self {
897            id: id.into(),
898            label: label.into(),
899            default_role,
900            default_shade,
901            required_input: None,
902        }
903    }
904
905    pub fn default_color(&self) -> ThemeColor {
906        ThemeColor::new(self.default_role, self.default_shade)
907    }
908
909    pub fn with_required_input(mut self, required_input: Option<IndicatorInputSource>) -> Self {
910        self.required_input = required_input;
911        self
912    }
913}
914
915#[derive(Debug, Clone, Copy, PartialEq, Eq)]
916pub enum IndicatorUnsupportedReason {
917    NotInstalled,
918    BasisNotSupported,
919    IncompleteHistory,
920    SourceNotSupported,
921    ResolutionNotSupported,
922    InsufficientData { available: usize, required: usize },
923    MissingRequiredInput { source: IndicatorInputSource },
924}
925
926impl IndicatorUnsupportedReason {
927    pub fn indicator_unavailable_tooltip(&self) -> &'static str {
928        match self {
929            IndicatorUnsupportedReason::NotInstalled => "Not included in this build",
930            IndicatorUnsupportedReason::BasisNotSupported => {
931                "Not available for the selected chart basis"
932            }
933            IndicatorUnsupportedReason::SourceNotSupported => {
934                "Not available for the selected market"
935            }
936            IndicatorUnsupportedReason::ResolutionNotSupported => {
937                "Not available at the selected timeframe"
938            }
939            IndicatorUnsupportedReason::IncompleteHistory => "Requires complete historical data",
940            IndicatorUnsupportedReason::InsufficientData { .. } => {
941                "Not enough data to use this indicator"
942            }
943            IndicatorUnsupportedReason::MissingRequiredInput { source } => match source {
944                IndicatorInputSource::Klines => "Requires candle data",
945                IndicatorInputSource::DirectionalVolume => {
946                    "Requires directional volume from candles or trade data"
947                }
948                IndicatorInputSource::Trades => "Enable trade data to use this indicator",
949                IndicatorInputSource::OpenInterest => "Requires open-interest data",
950            },
951        }
952    }
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
956pub enum IndicatorAvailability {
957    Available,
958    PendingProbe,
959    Unsupported(IndicatorUnsupportedReason),
960}
961
962#[derive(Debug, Clone, PartialEq)]
963pub enum IndicatorPanelRecipe {
964    Plot {
965        panel_title: String,
966        layer_name: String,
967        value_precision: PanelValuePrecision,
968        value_label_policy: PanelValueLabelPolicy,
969        preferred_scale: PanelScaleMode,
970        layers: Vec<IndicatorOutputLayerSpec>,
971        readouts: Vec<IndicatorReadoutSpec>,
972    },
973    Table {
974        panel_title: String,
975        columns: Vec<TableColumnSpec>,
976    },
977    PrimaryOverlay {
978        layer_name: String,
979        layers: Vec<IndicatorOutputLayerSpec>,
980    },
981}
982
983#[derive(Debug, Clone, Copy)]
984pub struct AvailabilityContext {
985    pub basis: Basis,
986    pub base_ticker: TickerInfo,
987}
988
989#[derive(Debug, Clone)]
990pub struct IndicatorSettingsContext {
991    pub basis: Basis,
992    pub input_coverage: IndicatorInputCoverage,
993}
994
995/// A value exposed by an indicator's settings schema.
996///
997/// Settings use signed integers and double-precision fractional values at the
998/// chart boundary. Owned values and metadata let declaration modules expose
999/// data whose lifetime is independent of the binary.
1000#[derive(Debug, Clone, PartialEq)]
1001pub enum IndicatorSettingValue {
1002    I64(i64),
1003    F64(f64),
1004    Bool(bool),
1005    Color(u32),
1006    Choice(String),
1007    Text(String),
1008    TextList(Vec<String>),
1009}
1010
1011impl IndicatorSettingValue {
1012    pub fn text(value: impl Into<String>) -> Self {
1013        Self::Text(value.into())
1014    }
1015
1016    pub fn choice(value: impl Into<String>) -> Self {
1017        Self::Choice(value.into())
1018    }
1019
1020    pub fn as_text(&self) -> Option<&str> {
1021        match self {
1022            Self::Choice(value) | Self::Text(value) => Some(value),
1023            Self::I64(_) | Self::F64(_) | Self::Bool(_) | Self::Color(_) | Self::TextList(_) => {
1024                None
1025            }
1026        }
1027    }
1028
1029    pub fn into_text(self) -> Option<String> {
1030        match self {
1031            Self::Choice(value) | Self::Text(value) => Some(value),
1032            Self::I64(_) | Self::F64(_) | Self::Bool(_) | Self::Color(_) | Self::TextList(_) => {
1033                None
1034            }
1035        }
1036    }
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct IndicatorSettingChoice {
1041    pub id: String,
1042    pub label: String,
1043}
1044
1045impl Display for IndicatorSettingChoice {
1046    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1047        formatter.write_str(&self.label)
1048    }
1049}
1050
1051#[derive(Debug, Clone, PartialEq)]
1052pub enum IndicatorSettingControl {
1053    SliderI64 {
1054        min: Option<i64>,
1055        max: Option<i64>,
1056    },
1057    SliderF64 {
1058        min: Option<f64>,
1059        max: Option<f64>,
1060        step: f64,
1061    },
1062    Toggle,
1063    Color,
1064    Text,
1065    Choices(Vec<IndicatorSettingChoice>),
1066}
1067
1068#[derive(Debug, Clone, PartialEq)]
1069pub struct IndicatorSetting {
1070    pub id: String,
1071    pub label: String,
1072    pub tooltip: Option<String>,
1073    pub group: Option<String>,
1074    pub value: IndicatorSettingValue,
1075    pub control: IndicatorSettingControl,
1076}