Skip to main content

flowsurface_data/chart/kline/indicators/
scripting.rs

1use super::language::PackageOrigin;
2use super::region_intersects_source_range;
3use super::registry::{IndicatorConfig, IndicatorId, IndicatorRegistration};
4use super::{
5    AvailabilityContext, IndicatorAttachmentMode, IndicatorAuthor, IndicatorColorSpec,
6    IndicatorConfigError, IndicatorDefinition, IndicatorFillGeometry, IndicatorFillSpec,
7    IndicatorHistoryRequirement, IndicatorInputCoverageState, IndicatorInputRequirement,
8    IndicatorInputSource, IndicatorMarkSpec, IndicatorOutputLayerSpec, IndicatorOverlayChannelSpec,
9    IndicatorPanelRecipe, IndicatorReadoutScope, IndicatorReadoutSpec, IndicatorSegmentSpec,
10    IndicatorSetting, IndicatorSettingChoice, IndicatorSettingControl, IndicatorSettingValue,
11    IndicatorSettingsContext, IndicatorSourceInput, IndicatorUnsupportedReason, OutputChannelSpec,
12    OutputValue, OutputValueType, TableColumnColor, TableColumnFormat, TableColumnSpec,
13};
14use crate::chart::Basis;
15use crate::chart::kline::composition::{
16    AxisBinding, BarMode, HistogramMode, LayerDataKind, MarkKind, PanelScaleMode,
17    PanelValueLabelMode, PanelValueLabelPolicy, PanelValuePrecision,
18};
19use crate::config::theme::{ThemeColorRole, ThemeColorShade};
20use exchange::UnixMs;
21use indicator_engine::{
22    Color, CompiledIndicator, FrameOutput, HorizontalLevelOutput,
23    IndicatorConfig as EngineIndicatorConfig, IndicatorFrame, IndicatorInstance, InputValue,
24    MarkerOutput, OhlcOutput, PlanningError, PlotDescriptor, PlotValueType, RangeOutput,
25    RegionOutput, RegionUpdate, RuntimeError, RuntimeLimits, SegmentOutput, Value, ValueType,
26};
27use rustc_hash::FxHashMap;
28use std::collections::BTreeMap;
29use std::mem::size_of;
30use std::ops::Range;
31use std::str::FromStr;
32use std::sync::Arc;
33use url::Url;
34
35const MAX_RETAINED_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
36
37struct PresentationParts {
38    panel_recipe: IndicatorPanelRecipe,
39    mark_specs: Vec<IndicatorMarkSpec>,
40    segment_specs: Vec<IndicatorSegmentSpec>,
41    overlay_channels: Vec<IndicatorOverlayChannelSpec>,
42    fill_specs: Vec<IndicatorFillSpec>,
43    region_style_slots: Vec<String>,
44}
45
46#[derive(Debug, Clone, Default)]
47struct IndicatorOutputStore {
48    budget: RetainedOutputBudget,
49    series: FxHashMap<String, FxHashMap<usize, SeriesOutput>>,
50    markers: BTreeMap<String, BTreeMap<usize, MarkerOutput>>,
51    segments: BTreeMap<String, BTreeMap<usize, SegmentOutput>>,
52    levels: BTreeMap<String, HorizontalLevelOutput>,
53    ohlc: BTreeMap<String, BTreeMap<usize, OhlcOutput>>,
54    ranges: BTreeMap<String, BTreeMap<usize, RangeOutput>>,
55    regions: BTreeMap<String, BTreeMap<i64, RegionOutput>>,
56    active_region_bytes: usize,
57    persistent_regions: PersistentRegionStore,
58    preview_persistent_regions: Option<PersistentRegionStore>,
59}
60
61#[derive(Debug, Clone, Default)]
62struct RetainedOutputBudget {
63    bytes: usize,
64}
65
66impl RetainedOutputBudget {
67    fn reserve(&mut self, bytes: usize) -> Result<(), RuntimeError> {
68        let total = self
69            .bytes
70            .checked_add(bytes)
71            .ok_or(retained_output_error())?;
72        if total > MAX_RETAINED_OUTPUT_BYTES {
73            return Err(retained_output_error());
74        }
75        self.bytes = total;
76        Ok(())
77    }
78
79    fn release(&mut self, bytes: usize) -> Result<(), RuntimeError> {
80        self.bytes = self
81            .bytes
82            .checked_sub(bytes)
83            .ok_or(retained_output_error())?;
84        Ok(())
85    }
86}
87
88fn retained_output_error() -> RuntimeError {
89    RuntimeError::ResourceLimitExceeded("retained output bytes")
90}
91
92// Charge record storage and string contents; allocator slack is outside this budget.
93fn retained_record_bytes<T>(strings: &[&str]) -> Result<usize, RuntimeError> {
94    strings.iter().try_fold(size_of::<T>(), |bytes, text| {
95        bytes.checked_add(text.len()).ok_or(retained_output_error())
96    })
97}
98
99fn marker_retained_bytes(marker: &MarkerOutput) -> Result<usize, RuntimeError> {
100    retained_record_bytes::<(usize, MarkerOutput)>(&[
101        &marker.id,
102        marker.text.as_deref().unwrap_or_default(),
103    ])
104}
105
106fn segment_retained_bytes(segment: &SegmentOutput) -> Result<usize, RuntimeError> {
107    retained_record_bytes::<(usize, SegmentOutput)>(&[
108        &segment.id,
109        segment.text.as_deref().unwrap_or_default(),
110    ])
111}
112
113fn region_retained_bytes(region: &RegionOutput) -> Result<usize, RuntimeError> {
114    retained_record_bytes::<(i64, RegionOutput)>(&[
115        &region.id,
116        region.border_color_slot.as_deref().unwrap_or_default(),
117        region.fill_color_slot.as_deref().unwrap_or_default(),
118    ])
119}
120
121#[derive(Debug, Clone)]
122struct SeriesOutput {
123    value: Value,
124    color: Option<Color>,
125}
126
127#[derive(Debug, Clone)]
128struct PersistentRegion {
129    region: RegionOutput,
130    sequence: usize,
131}
132
133#[derive(Debug, Clone)]
134struct PersistentRegionStore {
135    retained_bytes: usize,
136    values: BTreeMap<(String, i64), PersistentRegion>,
137    order: BTreeMap<usize, (String, i64)>,
138    next_sequence: usize,
139    limit: usize,
140}
141
142impl Default for PersistentRegionStore {
143    fn default() -> Self {
144        Self {
145            retained_bytes: 0,
146            values: BTreeMap::new(),
147            order: BTreeMap::new(),
148            next_sequence: 0,
149            limit: RuntimeLimits::untrusted().max_output_items,
150        }
151    }
152}
153
154impl PersistentRegionStore {
155    fn region_bytes(region: &RegionOutput) -> Result<usize, RuntimeError> {
156        retained_record_bytes::<(((String, i64), PersistentRegion), (usize, (String, i64)))>(&[
157            &region.id,
158            &region.id,
159            &region.id,
160            region.border_color_slot.as_deref().unwrap_or_default(),
161            region.fill_color_slot.as_deref().unwrap_or_default(),
162        ])
163    }
164
165    fn upsert(
166        &mut self,
167        region: &RegionOutput,
168        budget: &mut RetainedOutputBudget,
169    ) -> Result<(), RuntimeError> {
170        let key = (region.id.clone(), region.key);
171        if let Some(existing) = self.values.get_mut(&key) {
172            let previous_bytes = Self::region_bytes(&existing.region)?;
173            budget.release(previous_bytes)?;
174            self.retained_bytes = self
175                .retained_bytes
176                .checked_sub(previous_bytes)
177                .ok_or(retained_output_error())?;
178            let bytes = Self::region_bytes(region)?;
179            budget.reserve(bytes)?;
180            self.retained_bytes = self
181                .retained_bytes
182                .checked_add(bytes)
183                .ok_or(retained_output_error())?;
184            existing.region = region.clone();
185            return Ok(());
186        }
187
188        if self.values.len() >= self.limit
189            && let Some((_, oldest_key)) = self.order.first_key_value()
190        {
191            let oldest_key = oldest_key.clone();
192            self.remove(&oldest_key.0, oldest_key.1, budget)?;
193        }
194
195        let bytes = Self::region_bytes(region)?;
196        budget.reserve(bytes)?;
197
198        self.retained_bytes = self
199            .retained_bytes
200            .checked_add(bytes)
201            .ok_or(retained_output_error())?;
202        let sequence = self.next_sequence;
203        self.next_sequence = self
204            .next_sequence
205            .checked_add(1)
206            .ok_or(retained_output_error())?;
207        self.values.insert(
208            key.clone(),
209            PersistentRegion {
210                region: region.clone(),
211                sequence,
212            },
213        );
214        self.order.insert(sequence, key);
215        Ok(())
216    }
217
218    fn remove(
219        &mut self,
220        id: &str,
221        key: i64,
222        budget: &mut RetainedOutputBudget,
223    ) -> Result<(), RuntimeError> {
224        let key = (id.to_owned(), key);
225        if let Some(region) = self.values.remove(&key) {
226            self.order.remove(&region.sequence);
227            let bytes = Self::region_bytes(&region.region)?;
228            budget.release(bytes)?;
229            self.retained_bytes = self
230                .retained_bytes
231                .checked_sub(bytes)
232                .ok_or(retained_output_error())?;
233        }
234        Ok(())
235    }
236
237    fn apply_updates(
238        &mut self,
239        updates: &[RegionUpdate],
240        budget: &mut RetainedOutputBudget,
241    ) -> Result<(), RuntimeError> {
242        for update in updates {
243            match update {
244                RegionUpdate::Upsert(region) => self.upsert(region, budget)?,
245                RegionUpdate::Remove { id, key } => self.remove(id, *key, budget)?,
246            }
247        }
248        Ok(())
249    }
250}
251
252impl IndicatorOutputStore {
253    fn series_value(&self, plot: &str, index: usize) -> Option<&Value> {
254        Some(&self.series.get(plot)?.get(&index)?.value)
255    }
256
257    fn series_color(&self, plot: &str, index: usize) -> Option<Color> {
258        self.series.get(plot)?.get(&index)?.color
259    }
260
261    fn marker_values_at(&self, index: usize) -> Vec<MarkerOutput> {
262        self.markers
263            .values()
264            .filter_map(|values| values.get(&index))
265            .cloned()
266            .collect()
267    }
268
269    fn segment_values(&self) -> impl Iterator<Item = &SegmentOutput> {
270        self.segments.values().flat_map(|values| values.values())
271    }
272
273    fn level_values(&self) -> Vec<HorizontalLevelOutput> {
274        self.levels.values().cloned().collect()
275    }
276
277    fn ohlc_value(&self, plot: &str, index: usize) -> Option<OhlcOutput> {
278        self.ohlc.get(plot)?.get(&index).cloned()
279    }
280
281    fn range_value(&self, plot: &str, index: usize) -> Option<RangeOutput> {
282        self.ranges.get(plot)?.get(&index).cloned()
283    }
284
285    fn region_values_in_range(&self, source_range: Option<&Range<usize>>) -> Vec<RegionOutput> {
286        let include = |region: &RegionOutput| {
287            source_range.is_none_or(|range| region_intersects_source_range(region, range))
288        };
289
290        let mut regions = self
291            .regions
292            .iter()
293            .flat_map(|(id, values)| values.iter().map(move |(key, region)| (id, key, region)))
294            .filter(|(_, _, region)| include(region))
295            .map(|(id, key, region)| ((id.clone(), *key), region.clone()))
296            .collect::<BTreeMap<_, _>>();
297        let persistent_regions = self
298            .preview_persistent_regions
299            .as_ref()
300            .unwrap_or(&self.persistent_regions);
301
302        for ((id, key), value) in &persistent_regions.values {
303            if !include(&value.region) {
304                continue;
305            }
306            regions.insert((id.clone(), *key), value.region.clone());
307        }
308        regions.into_values().collect()
309    }
310
311    #[cfg(test)]
312    fn region_values(&self) -> Vec<RegionOutput> {
313        self.region_values_in_range(None)
314    }
315
316    fn apply_output(&mut self, output: FrameOutput, preview: bool) -> Result<(), RuntimeError> {
317        if let Some(previous) = self.preview_persistent_regions.take() {
318            self.budget.release(previous.retained_bytes)?;
319        }
320        self.budget.release(self.active_region_bytes)?;
321        self.active_region_bytes = 0;
322        self.regions.clear();
323
324        for values in self.series.values_mut() {
325            if values.remove(&output.index).is_some() {
326                self.budget.release(size_of::<(usize, SeriesOutput)>())?;
327            }
328        }
329        for values in self.markers.values_mut() {
330            if let Some(previous) = values.remove(&output.index) {
331                self.budget.release(marker_retained_bytes(&previous)?)?;
332            }
333        }
334        for values in self.segments.values_mut() {
335            if let Some(previous) = values.remove(&output.index) {
336                self.budget.release(segment_retained_bytes(&previous)?)?;
337            }
338        }
339        for values in self.ohlc.values_mut() {
340            if let Some(previous) = values.remove(&output.index) {
341                self.budget
342                    .release(retained_record_bytes::<(usize, OhlcOutput)>(&[
343                        &previous.id
344                    ])?)?;
345            }
346        }
347        for values in self.ranges.values_mut() {
348            if let Some(previous) = values.remove(&output.index) {
349                self.budget
350                    .release(retained_record_bytes::<(usize, RangeOutput)>(&[
351                        &previous.id
352                    ])?)?;
353            }
354        }
355
356        for plot in output.plots.iter() {
357            if self
358                .series
359                .get_mut(&plot.id)
360                .and_then(|values| values.remove(&output.index))
361                .is_some()
362            {
363                self.budget.release(size_of::<(usize, SeriesOutput)>())?;
364            }
365
366            if !self.series.contains_key(&plot.id) {
367                self.budget.reserve(retained_record_bytes::<(
368                    String,
369                    FxHashMap<usize, SeriesOutput>,
370                )>(&[&plot.id])?)?;
371            }
372
373            self.budget.reserve(size_of::<(usize, SeriesOutput)>())?;
374            self.series.entry(plot.id.clone()).or_default().insert(
375                output.index,
376                SeriesOutput {
377                    value: plot.value.clone(),
378                    color: plot.color,
379                },
380            );
381        }
382
383        for marker in output.markers.iter() {
384            if let Some(previous) = self
385                .markers
386                .get_mut(&marker.id)
387                .and_then(|values| values.remove(&output.index))
388            {
389                self.budget.release(marker_retained_bytes(&previous)?)?;
390            }
391            if !self.markers.contains_key(&marker.id) {
392                self.budget.reserve(retained_record_bytes::<(
393                    String,
394                    BTreeMap<usize, MarkerOutput>,
395                )>(&[&marker.id])?)?;
396            }
397
398            self.budget.reserve(marker_retained_bytes(marker)?)?;
399            self.markers
400                .entry(marker.id.clone())
401                .or_default()
402                .insert(output.index, marker.clone());
403        }
404
405        for segment in output.segments.iter() {
406            if let Some(previous) = self
407                .segments
408                .get_mut(&segment.id)
409                .and_then(|values| values.remove(&output.index))
410            {
411                self.budget.release(segment_retained_bytes(&previous)?)?;
412            }
413
414            if !self.segments.contains_key(&segment.id) {
415                self.budget.reserve(retained_record_bytes::<(
416                    String,
417                    BTreeMap<usize, SegmentOutput>,
418                )>(&[&segment.id])?)?;
419            }
420
421            self.budget.reserve(segment_retained_bytes(segment)?)?;
422            self.segments
423                .entry(segment.id.clone())
424                .or_default()
425                .insert(output.index, segment.clone());
426        }
427
428        for level in output.levels.iter() {
429            if let Some(previous) = self.levels.remove(&level.id) {
430                self.budget
431                    .release(retained_record_bytes::<(String, HorizontalLevelOutput)>(
432                        &[&previous.id, &previous.id],
433                    )?)?;
434            }
435
436            self.budget
437                .reserve(retained_record_bytes::<(String, HorizontalLevelOutput)>(
438                    &[&level.id, &level.id],
439                )?)?;
440            self.levels.insert(level.id.clone(), level.clone());
441        }
442
443        for ohlc in output.ohlc.iter() {
444            if let Some(previous) = self
445                .ohlc
446                .get_mut(&ohlc.id)
447                .and_then(|values| values.remove(&output.index))
448            {
449                self.budget
450                    .release(retained_record_bytes::<(usize, OhlcOutput)>(&[
451                        &previous.id
452                    ])?)?;
453            }
454
455            if !self.ohlc.contains_key(&ohlc.id) {
456                self.budget.reserve(retained_record_bytes::<(
457                    String,
458                    BTreeMap<usize, OhlcOutput>,
459                )>(&[&ohlc.id])?)?;
460            }
461
462            self.budget
463                .reserve(retained_record_bytes::<(usize, OhlcOutput)>(&[&ohlc.id])?)?;
464            self.ohlc
465                .entry(ohlc.id.clone())
466                .or_default()
467                .insert(output.index, ohlc.clone());
468        }
469
470        for range in output.ranges.iter() {
471            if let Some(previous) = self
472                .ranges
473                .get_mut(&range.id)
474                .and_then(|values| values.remove(&output.index))
475            {
476                self.budget
477                    .release(retained_record_bytes::<(usize, RangeOutput)>(&[
478                        &previous.id
479                    ])?)?;
480            }
481
482            if !self.ranges.contains_key(&range.id) {
483                self.budget.reserve(retained_record_bytes::<(
484                    String,
485                    BTreeMap<usize, RangeOutput>,
486                )>(&[&range.id])?)?;
487            }
488
489            self.budget
490                .reserve(retained_record_bytes::<(usize, RangeOutput)>(&[&range.id])?)?;
491            self.ranges
492                .entry(range.id.clone())
493                .or_default()
494                .insert(output.index, range.clone());
495        }
496
497        let previous_bytes = self.budget.bytes;
498        for region in output.regions.iter() {
499            if let Some(previous) = self
500                .regions
501                .get_mut(&region.id)
502                .and_then(|values| values.remove(&region.key))
503            {
504                self.budget.release(region_retained_bytes(&previous)?)?;
505            }
506            if !self.regions.contains_key(&region.id) {
507                self.budget.reserve(retained_record_bytes::<(
508                    String,
509                    BTreeMap<i64, RegionOutput>,
510                )>(&[&region.id])?)?;
511            }
512            self.budget.reserve(region_retained_bytes(region)?)?;
513            self.regions
514                .entry(region.id.clone())
515                .or_default()
516                .insert(region.key, region.clone());
517        }
518
519        self.active_region_bytes = self
520            .budget
521            .bytes
522            .checked_sub(previous_bytes)
523            .ok_or(retained_output_error())?;
524        if preview {
525            if !output.region_updates.is_empty() {
526                self.budget
527                    .reserve(self.persistent_regions.retained_bytes)?;
528                let mut persistent_regions = self.persistent_regions.clone();
529                persistent_regions.apply_updates(&output.region_updates, &mut self.budget)?;
530                self.preview_persistent_regions = Some(persistent_regions);
531            }
532        } else {
533            self.persistent_regions
534                .apply_updates(&output.region_updates, &mut self.budget)?;
535        }
536        Ok(())
537    }
538}
539
540fn validated_http_url(
541    value: &str,
542    indicator: impl std::fmt::Display,
543    field: &str,
544) -> Result<String, String> {
545    let url = Url::parse(value)
546        .map_err(|_| format!("{indicator}: indicator {field} must be a valid URL"))?;
547
548    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
549        return Err(format!(
550            "{indicator}: indicator {field} must be an absolute http(s) URL"
551        ));
552    }
553    Ok(value.to_owned())
554}
555
556fn definition_from_compiled(compiled: &CompiledIndicator) -> Result<IndicatorDefinition, String> {
557    let requirements = compiled.requirements;
558    let mut inputs = Vec::new();
559    if requirements.ohlc {
560        inputs.push(IndicatorInputRequirement::required(
561            IndicatorInputSource::Klines,
562        ));
563    }
564    if requirements.needs_directional_volume() {
565        let requirement = if compiled.descriptor.optional_trades {
566            IndicatorInputRequirement::optional(IndicatorInputSource::DirectionalVolume)
567        } else {
568            IndicatorInputRequirement::required(IndicatorInputSource::DirectionalVolume)
569        };
570        inputs.push(requirement);
571    }
572    if requirements.trades {
573        inputs.push(IndicatorInputRequirement::required(
574            IndicatorInputSource::Trades,
575        ));
576    }
577    if requirements.open_interest {
578        inputs.push(IndicatorInputRequirement::required(
579            IndicatorInputSource::OpenInterest,
580        ));
581    }
582
583    let style_slots = descriptor_styles(compiled)?;
584    let outputs = descriptor_outputs(compiled);
585    let author = compiled
586        .descriptor
587        .author
588        .as_ref()
589        .map(|author| -> Result<IndicatorAuthor, String> {
590            let url = validated_http_url(&author.url, &compiled.descriptor.id, "author_url")?;
591            Ok(IndicatorAuthor {
592                name: author.name.clone(),
593                url,
594            })
595        })
596        .transpose()?;
597    let source = compiled
598        .descriptor
599        .source
600        .as_ref()
601        .map(|source| validated_http_url(source, &compiled.descriptor.id, "source"))
602        .transpose()?;
603    let PresentationParts {
604        panel_recipe,
605        mark_specs,
606        segment_specs,
607        overlay_channels,
608        fill_specs,
609        region_style_slots,
610    } = descriptor_view(compiled, &style_slots)?;
611
612    let definition = IndicatorDefinition::new(
613        compiled.descriptor.title.clone(),
614        inputs,
615        outputs,
616        panel_recipe,
617    )
618    .with_author(author)
619    .with_description(compiled.descriptor.description.clone())
620    .with_source(source)
621    .with_sourceable(compiled.descriptor.sourceable)
622    .with_source_inputs(
623        compiled
624            .descriptor
625            .series_inputs
626            .iter()
627            .map(|input| IndicatorSourceInput {
628                id: input.id.clone(),
629                label: input.label.clone(),
630                default_label: input
631                    .default_field
632                    .source_input_label()
633                    .unwrap_or("Declared default")
634                    .to_owned(),
635                allow_indicator: input.allow_indicator,
636            })
637            .collect::<Vec<_>>(),
638    )
639    .with_attachment_mode(
640        compiled
641            .descriptor
642            .presentation
643            .attachment
644            .map(IndicatorAttachmentMode::from)
645            .unwrap_or(IndicatorAttachmentMode::Panel),
646    )
647    .with_style_slots(style_slots)
648    .with_region_style_slots(region_style_slots)
649    .with_display_specs(mark_specs, overlay_channels, fill_specs)
650    .with_segment_specs(segment_specs);
651
652    definition.validate().map_err(|error| {
653        format!(
654            "{}: invalid indicator definition: {error:?}",
655            compiled.descriptor.id
656        )
657    })?;
658    Ok(definition)
659}
660
661fn descriptor_styles(compiled: &CompiledIndicator) -> Result<Vec<IndicatorColorSpec>, String> {
662    compiled
663        .descriptor
664        .presentation
665        .styles
666        .iter()
667        .map(|style| {
668            Ok(IndicatorColorSpec::new(
669                style.id.clone(),
670                style.label.clone(),
671                ThemeColorRole::from_str(&style.role).map_err(|error| error.to_string())?,
672                ThemeColorShade::from_str(&style.shade).map_err(|error| error.to_string())?,
673            )
674            .with_required_input(style.required_input.map(IndicatorInputSource::from)))
675        })
676        .collect::<Result<Vec<_>, String>>()
677}
678
679impl From<&PlotDescriptor> for OutputValueType {
680    fn from(plot: &PlotDescriptor) -> Self {
681        match plot.value_type {
682            PlotValueType::Number => Self::Number,
683            PlotValueType::Boolean => Self::Boolean,
684            PlotValueType::Ohlc => Self::Ohlc,
685            PlotValueType::Range => Self::Range,
686        }
687    }
688}
689
690impl From<&PlotDescriptor> for OutputChannelSpec {
691    fn from(plot: &PlotDescriptor) -> Self {
692        Self {
693            id: plot.id.clone(),
694            label: plot.label.clone(),
695            value_type: plot.into(),
696            precision: plot
697                .output_precision
698                .unwrap_or(indicator_engine::OutputPrecision::Instrument),
699            line_width: plot.width,
700            readout: plot.readout,
701            style_slot: plot.color_slot.clone(),
702            offset_bars: plot.offset_bars,
703            style_by: plot.style_by.clone(),
704            independent_scale: matches!(plot.scale, indicator_engine::PlotScale::Independent),
705        }
706    }
707}
708
709fn descriptor_outputs(compiled: &CompiledIndicator) -> Vec<OutputChannelSpec> {
710    compiled
711        .descriptor
712        .plots
713        .iter()
714        .map(OutputChannelSpec::from)
715        .collect()
716}
717
718fn descriptor_view(
719    compiled: &CompiledIndicator,
720    style_slots: &[IndicatorColorSpec],
721) -> Result<PresentationParts, String> {
722    let presentation = &compiled.descriptor.presentation;
723    let layers = presentation
724        .layers
725        .iter()
726        .map(|layer| {
727            let plot = compiled
728                .descriptor
729                .plots
730                .iter()
731                .find(|plot| plot.id == layer.channel)
732                .ok_or_else(|| {
733                    format!(
734                        "{}: presentation layer references unknown output '{}'",
735                        compiled.descriptor.id, layer.channel
736                    )
737                })?;
738            if matches!(plot.value_type, PlotValueType::Boolean) {
739                return Err(format!(
740                    "{}: boolean output '{}' cannot be rendered as a layer",
741                    compiled.descriptor.id, layer.channel
742                ));
743            }
744            let data_kind = layer.data_kind.ok_or_else(|| {
745                format!(
746                    "{}: presentation layer '{}' is missing its data kind",
747                    compiled.descriptor.id, layer.channel
748                )
749            })?;
750            let mark = layer.mark.ok_or_else(|| {
751                format!(
752                    "{}: presentation layer '{}' is missing its mark",
753                    compiled.descriptor.id, layer.channel
754                )
755            })?;
756            let axis = layer.axis.ok_or_else(|| {
757                format!(
758                    "{}: presentation layer '{}' is missing its axis",
759                    compiled.descriptor.id, layer.channel
760                )
761            })?;
762            Ok(IndicatorOutputLayerSpec {
763                channel_id: layer.channel.clone(),
764                layer_name: layer.name.clone(),
765                pane: layer.pane,
766                data_kind: data_kind.into(),
767                mark: mark.into(),
768                axis: axis.into(),
769            })
770        })
771        .collect::<Result<Vec<_>, String>>()?;
772    let mut marks = presentation
773        .marks
774        .iter()
775        .map(|mark| IndicatorMarkSpec {
776            label: mark.label.clone(),
777            mark: mark.kind.into(),
778            source_channel: mark.source_channel.clone(),
779            required_input: mark.required_input.map(IndicatorInputSource::from),
780            fallback_mark: mark.fallback_mark.map(MarkKind::from),
781            color_slot: mark.color_slot.clone(),
782            true_color_slot: mark.true_color_slot.clone(),
783            false_color_slot: mark.false_color_slot.clone(),
784            highlight_by: mark.highlight_by.clone(),
785            highlight_color_slot: mark.highlight_color_slot.clone(),
786        })
787        .collect::<Vec<_>>();
788    // A plot can opt into explicit true/false style slots without declaring a
789    // separate mark. Materialize a mark carrying those named bindings so the
790    // boolean state is honored consistently for panel and primary-overlay
791    // lines.
792    for plot in &compiled.descriptor.plots {
793        let (Some(true_slot), Some(false_slot)) = (
794            plot.true_color_slot.as_deref(),
795            plot.false_color_slot.as_deref(),
796        ) else {
797            continue;
798        };
799        if plot.style_by.is_none() || marks.iter().any(|mark| mark.source_channel == plot.id) {
800            continue;
801        }
802        let color_slot = plot.color_slot.as_deref().unwrap_or(true_slot).to_owned();
803        let mark = presentation
804            .layers
805            .iter()
806            .find(|layer| layer.channel == plot.id)
807            .and_then(|layer| layer.mark)
808            .or(plot.presentation_mark)
809            .unwrap_or(indicator_engine::PresentationMarkKind::Line);
810        marks.push(IndicatorMarkSpec {
811            label: plot.label.clone(),
812            mark: mark.into(),
813            source_channel: plot.id.clone(),
814            required_input: None,
815            fallback_mark: None,
816            color_slot,
817            true_color_slot: Some(true_slot.to_owned()),
818            false_color_slot: Some(false_slot.to_owned()),
819            highlight_by: None,
820            highlight_color_slot: None,
821        });
822    }
823    let mut segments = presentation
824        .segments
825        .iter()
826        .map(|segment| IndicatorSegmentSpec {
827            id: segment.id.clone(),
828            label: segment.label.clone(),
829            color_slot: segment.color_slot.clone(),
830            line_width: segment.line_width,
831        })
832        .collect::<Vec<_>>();
833    let overlays = compiled
834        .descriptor
835        .plots
836        .iter()
837        .filter(|plot| plot.overlay && plot.value_type == PlotValueType::Number)
838        .filter_map(|plot| {
839            let color_slot = plot.color_slot.clone()?;
840            Some(IndicatorOverlayChannelSpec {
841                label: plot.label.clone(),
842                source: plot.id.clone(),
843                line_width: plot.width,
844                color_role: style_role_for_plot(style_slots, &color_slot),
845                color_slot,
846                style_by: plot.style_by.clone(),
847            })
848        })
849        .collect::<Vec<_>>();
850    let fills = presentation
851        .fills
852        .iter()
853        .map(|fill| IndicatorFillSpec {
854            upper: fill.upper.clone(),
855            lower: fill.lower.clone(),
856            upper_offset_bars: fill.upper_offset_bars,
857            lower_offset_bars: fill.lower_offset_bars,
858            style_by: fill.style_by.clone(),
859            geometry: match fill.geometry {
860                indicator_engine::PresentationGeometry::PanelBand => {
861                    IndicatorFillGeometry::PanelBand
862                }
863                indicator_engine::PresentationGeometry::SeriesBand => {
864                    IndicatorFillGeometry::SeriesBand
865                }
866            },
867            color_slot: fill.color_slot.clone(),
868            true_color_slot: fill.true_color_slot.clone(),
869            false_color_slot: fill.false_color_slot.clone(),
870            opacity: fill.opacity,
871        })
872        .collect::<Vec<_>>();
873    let readouts = presentation
874        .readouts
875        .iter()
876        .map(|readout| IndicatorReadoutSpec {
877            label: readout.label.clone(),
878            source: readout.channel.clone(),
879            color_role: style_role_for_plot(style_slots, &readout.color_slot),
880            color_slot: readout.color_slot.clone(),
881            scope: readout
882                .mark
883                .map(|mark| IndicatorReadoutScope::Mark(mark.into()))
884                .unwrap_or(IndicatorReadoutScope::Always),
885        })
886        .collect::<Vec<_>>();
887    let has_numeric_output = compiled
888        .descriptor
889        .plots
890        .iter()
891        .any(|plot| !matches!(plot.value_type, PlotValueType::Boolean));
892    let view_kind = if matches!(
893        presentation.view,
894        indicator_engine::PresentationViewKind::Plot
895    ) && !has_numeric_output
896        && presentation.columns.is_empty()
897    {
898        indicator_engine::PresentationViewKind::Table
899    } else {
900        presentation.view
901    };
902    let panel_title = presentation
903        .panel_title
904        .clone()
905        .unwrap_or_else(|| compiled.descriptor.title.clone());
906    let layer_name = presentation
907        .layer_name
908        .clone()
909        .unwrap_or_else(|| compiled.descriptor.title.clone());
910    let panel_recipe = match view_kind {
911        indicator_engine::PresentationViewKind::Table => IndicatorPanelRecipe::Table {
912            panel_title,
913            columns: presentation
914                .columns
915                .iter()
916                .map(|column| TableColumnSpec {
917                    label: column.label.clone(),
918                    value: column.channel.clone(),
919                    format: parse_table_format(&column.format),
920                    color: match &column.color {
921                        indicator_engine::PresentationColumnColor::Static { slot } => {
922                            TableColumnColor::Static { slot: slot.clone() }
923                        }
924                        indicator_engine::PresentationColumnColor::Signed {
925                            positive_slot,
926                            negative_slot,
927                        } => TableColumnColor::Signed {
928                            positive_slot: positive_slot.clone(),
929                            negative_slot: negative_slot.clone(),
930                        },
931                    },
932                })
933                .collect(),
934        },
935        indicator_engine::PresentationViewKind::PrimaryOverlay => {
936            if layers.is_empty() {
937                return Err(format!(
938                    "{}: presentation has no numeric output layers",
939                    compiled.descriptor.id
940                ));
941            }
942            IndicatorPanelRecipe::PrimaryOverlay { layer_name, layers }
943        }
944        indicator_engine::PresentationViewKind::Plot => {
945            let value_precision = presentation
946                .value_precision
947                .map(PanelValuePrecision::from)
948                .ok_or_else(|| {
949                    format!(
950                        "{}: numeric plot presentation requires format(precision = ...)",
951                        compiled.descriptor.id
952                    )
953                })?;
954            IndicatorPanelRecipe::Plot {
955                panel_title,
956                layer_name,
957                value_precision,
958                value_label_policy: PanelValueLabelPolicy {
959                    axis_mode: presentation
960                        .axis_label_mode
961                        .map(PanelValueLabelMode::from)
962                        .unwrap_or(PanelValueLabelMode::Compact),
963                    header_mode: presentation
964                        .header_label_mode
965                        .map(PanelValueLabelMode::from)
966                        .unwrap_or(PanelValueLabelMode::Commas),
967                    max_decimals: presentation.max_decimals,
968                },
969                preferred_scale: presentation
970                    .preferred_scale
971                    .map(PanelScaleMode::from)
972                    .unwrap_or(PanelScaleMode::FitVisible),
973                layers,
974                readouts,
975            }
976        }
977    };
978    if matches!(view_kind, indicator_engine::PresentationViewKind::Table) {
979        marks.clear();
980        segments.clear();
981    }
982    Ok(PresentationParts {
983        panel_recipe,
984        mark_specs: marks,
985        segment_specs: segments,
986        overlay_channels: overlays,
987        fill_specs: fills,
988        region_style_slots: presentation.region_style_slots.clone(),
989    })
990}
991
992fn style_role_for_plot(style_slots: &[IndicatorColorSpec], slot: &str) -> ThemeColorRole {
993    style_slots
994        .iter()
995        .find(|style| style.id == slot)
996        .map(|style| style.default_role)
997        .unwrap_or(ThemeColorRole::Neutral)
998}
999
1000impl From<indicator_engine::PresentationDataKind> for LayerDataKind {
1001    fn from(kind: indicator_engine::PresentationDataKind) -> Self {
1002        match kind {
1003            indicator_engine::PresentationDataKind::Scalar => Self::Scalar,
1004            indicator_engine::PresentationDataKind::Histogram => Self::Histogram,
1005            indicator_engine::PresentationDataKind::Ohlc => Self::Ohlc,
1006            indicator_engine::PresentationDataKind::Range => Self::Range,
1007        }
1008    }
1009}
1010
1011impl From<indicator_engine::PresentationAttachmentMode> for IndicatorAttachmentMode {
1012    fn from(mode: indicator_engine::PresentationAttachmentMode) -> Self {
1013        match mode {
1014            indicator_engine::PresentationAttachmentMode::Overlay => Self::Overlay,
1015            indicator_engine::PresentationAttachmentMode::Panel => Self::Panel,
1016        }
1017    }
1018}
1019
1020impl From<indicator_engine::PresentationMarkKind> for MarkKind {
1021    fn from(mark: indicator_engine::PresentationMarkKind) -> Self {
1022        match mark {
1023            indicator_engine::PresentationMarkKind::Candle => Self::Candle,
1024            indicator_engine::PresentationMarkKind::RegularBar => Self::Bar(BarMode::Regular),
1025            indicator_engine::PresentationMarkKind::Histogram => {
1026                Self::Bar(BarMode::Histogram(HistogramMode::Plain))
1027            }
1028            indicator_engine::PresentationMarkKind::HistogramSignedOverlay => {
1029                Self::Bar(BarMode::Histogram(HistogramMode::SignedOverlay))
1030            }
1031            indicator_engine::PresentationMarkKind::Line => Self::Line,
1032            indicator_engine::PresentationMarkKind::Step => Self::StepLine,
1033            indicator_engine::PresentationMarkKind::Area => Self::Area,
1034            indicator_engine::PresentationMarkKind::Point => Self::Point,
1035            indicator_engine::PresentationMarkKind::Cross => Self::Cross,
1036            indicator_engine::PresentationMarkKind::Segment => Self::Segment,
1037            indicator_engine::PresentationMarkKind::HorizontalLevel => Self::HorizontalLevel,
1038        }
1039    }
1040}
1041
1042impl From<indicator_engine::PresentationInputSource> for IndicatorInputSource {
1043    fn from(source: indicator_engine::PresentationInputSource) -> Self {
1044        match source {
1045            indicator_engine::PresentationInputSource::Klines => Self::Klines,
1046            indicator_engine::PresentationInputSource::DirectionalVolume => Self::DirectionalVolume,
1047            indicator_engine::PresentationInputSource::Trades => Self::Trades,
1048            indicator_engine::PresentationInputSource::OpenInterest => Self::OpenInterest,
1049        }
1050    }
1051}
1052
1053impl From<indicator_engine::PresentationAxis> for AxisBinding {
1054    fn from(axis: indicator_engine::PresentationAxis) -> Self {
1055        match axis {
1056            indicator_engine::PresentationAxis::Primary => Self::Primary,
1057            indicator_engine::PresentationAxis::Secondary => Self::Secondary,
1058        }
1059    }
1060}
1061
1062impl From<indicator_engine::PresentationPrecision> for PanelValuePrecision {
1063    fn from(precision: indicator_engine::PresentationPrecision) -> Self {
1064        match precision {
1065            indicator_engine::PresentationPrecision::BaseTickerMinTick => Self::BaseTickerMinTick,
1066            indicator_engine::PresentationPrecision::BaseTickerMinQty => Self::BaseTickerMinQty,
1067            indicator_engine::PresentationPrecision::FixedStep(step) => Self::FixedStep(step),
1068        }
1069    }
1070}
1071
1072impl From<indicator_engine::PresentationLabelMode> for PanelValueLabelMode {
1073    fn from(mode: indicator_engine::PresentationLabelMode) -> Self {
1074        match mode {
1075            indicator_engine::PresentationLabelMode::Compact => Self::Compact,
1076            indicator_engine::PresentationLabelMode::Commas => Self::Commas,
1077            indicator_engine::PresentationLabelMode::Abbreviated => Self::Abbreviated,
1078        }
1079    }
1080}
1081
1082impl From<indicator_engine::PresentationScale> for PanelScaleMode {
1083    fn from(scale: indicator_engine::PresentationScale) -> Self {
1084        match scale {
1085            indicator_engine::PresentationScale::Absolute => Self::Absolute,
1086            indicator_engine::PresentationScale::FitVisible => Self::FitVisible,
1087            indicator_engine::PresentationScale::FitVisibleIncludeZero => {
1088                Self::FitVisibleIncludeZero
1089            }
1090            indicator_engine::PresentationScale::Logarithmic => Self::Logarithmic,
1091            indicator_engine::PresentationScale::PercentFromBase => Self::PercentFromBase,
1092        }
1093    }
1094}
1095
1096fn parse_table_format(value: &str) -> TableColumnFormat {
1097    match value.to_ascii_lowercase().as_str() {
1098        "integer" => TableColumnFormat::Integer,
1099        "percent" => TableColumnFormat::Percent,
1100        "boolean" => TableColumnFormat::Boolean,
1101        _ => TableColumnFormat::Quantity,
1102    }
1103}
1104
1105fn input_value_to_json(value: &InputValue) -> serde_json::Value {
1106    match value {
1107        InputValue::Int(value) => (*value).into(),
1108        InputValue::Float(value) => (*value).into(),
1109        InputValue::Bool(value) => (*value).into(),
1110        InputValue::String(value) => value.clone().into(),
1111        InputValue::Color(value) => (*value).into(),
1112        InputValue::Time(value) => (*value).into(),
1113    }
1114}
1115
1116fn integer_bound(value: Option<f64>, round: fn(f64) -> f64) -> Option<i64> {
1117    let value = value.filter(|value| value.is_finite()).map(round)?;
1118    Some(if value <= i64::MIN as f64 {
1119        i64::MIN
1120    } else if value >= i64::MAX as f64 {
1121        i64::MAX
1122    } else {
1123        value as i64
1124    })
1125}
1126
1127fn setting_from_input(
1128    input: &indicator_engine::InputDescriptor,
1129    config: &serde_json::Value,
1130) -> IndicatorSetting {
1131    let configured = config
1132        .as_object()
1133        .and_then(|values| values.get(&input.id))
1134        .and_then(|value| input_value_from_json(&input.value_type, value))
1135        .and_then(|value| input.accepts(&value).then_some(value))
1136        .unwrap_or_else(|| input.default.clone());
1137    let (value, control) = match configured {
1138        InputValue::Int(value) => (
1139            IndicatorSettingValue::I64(value),
1140            IndicatorSettingControl::SliderI64 {
1141                min: integer_bound(input.min, f64::ceil),
1142                max: integer_bound(input.max, f64::floor),
1143            },
1144        ),
1145        InputValue::Float(value) => (
1146            IndicatorSettingValue::F64(value),
1147            IndicatorSettingControl::SliderF64 {
1148                min: input.min,
1149                max: input.max,
1150                step: input.step.unwrap_or(0.01).max(f64::EPSILON),
1151            },
1152        ),
1153        InputValue::Bool(value) => (
1154            IndicatorSettingValue::Bool(value),
1155            IndicatorSettingControl::Toggle,
1156        ),
1157        InputValue::String(value) => {
1158            if input.options.is_empty() {
1159                (
1160                    IndicatorSettingValue::Text(value),
1161                    IndicatorSettingControl::Text,
1162                )
1163            } else {
1164                let choices = input
1165                    .options
1166                    .iter()
1167                    .map(|option| IndicatorSettingChoice {
1168                        id: option.id.clone(),
1169                        label: option.label.clone(),
1170                    })
1171                    .collect();
1172                (
1173                    IndicatorSettingValue::Choice(value),
1174                    IndicatorSettingControl::Choices(choices),
1175                )
1176            }
1177        }
1178        InputValue::Color(value) => (
1179            IndicatorSettingValue::Color(value),
1180            IndicatorSettingControl::Color,
1181        ),
1182        InputValue::Time(value) => (
1183            IndicatorSettingValue::I64(value),
1184            IndicatorSettingControl::SliderI64 {
1185                min: integer_bound(input.min, f64::ceil),
1186                max: integer_bound(input.max, f64::floor),
1187            },
1188        ),
1189    };
1190    IndicatorSetting {
1191        id: input.id.clone(),
1192        label: input.label.clone(),
1193        tooltip: input.tooltip.clone(),
1194        group: input.group.clone(),
1195        value,
1196        control,
1197    }
1198}
1199
1200fn setting_value_to_json(
1201    input: &indicator_engine::InputDescriptor,
1202    value: IndicatorSettingValue,
1203) -> Option<serde_json::Value> {
1204    match (input.value_type, value) {
1205        (indicator_engine::ValueType::Int, IndicatorSettingValue::I64(value)) => {
1206            Some(serde_json::Value::from(value))
1207        }
1208        (indicator_engine::ValueType::Float, IndicatorSettingValue::F64(value)) => {
1209            Some(serde_json::Value::from(value))
1210        }
1211        (indicator_engine::ValueType::Bool, IndicatorSettingValue::Bool(value)) => {
1212            Some(serde_json::Value::from(value))
1213        }
1214        (indicator_engine::ValueType::String, IndicatorSettingValue::Text(value))
1215        | (indicator_engine::ValueType::String, IndicatorSettingValue::Choice(value)) => {
1216            Some(serde_json::Value::from(value))
1217        }
1218        (indicator_engine::ValueType::Time, IndicatorSettingValue::I64(value)) => {
1219            Some(serde_json::Value::from(value))
1220        }
1221        (indicator_engine::ValueType::Color, IndicatorSettingValue::Color(value)) => {
1222            Some(serde_json::Value::from(value))
1223        }
1224        _ => None,
1225    }
1226}
1227
1228/// Register a source-compiled indicator. The `.ind` source owns
1229/// both the executable program metadata and the host-neutral presentation
1230/// scene used by the chart adapter.
1231pub fn registration(
1232    compiled: CompiledIndicator,
1233    origin: PackageOrigin,
1234) -> Result<IndicatorRegistration, Vec<String>> {
1235    let descriptor = compiled.descriptor.clone();
1236    let origin_label = match origin {
1237        PackageOrigin::Bundled => "bundled",
1238        PackageOrigin::Installed => "installed",
1239    };
1240    let id = IndicatorId::parse(descriptor.id.as_str()).map_err(|error| {
1241        vec![format!(
1242            "{origin_label} indicator source has invalid ID: {error}"
1243        )]
1244    })?;
1245    let definition = definition_from_compiled(&compiled).map_err(|error| vec![error])?;
1246    let registration =
1247        IndicatorRegistration::from_parts(id, origin, definition, Arc::new(compiled));
1248    registration
1249        .validate_config(&registration.default_config())
1250        .map_err(|error| {
1251            vec![format!(
1252                "{origin_label} indicator source '{}': {error}",
1253                registration.id.id()
1254            )]
1255        })?;
1256    Ok(registration)
1257}
1258
1259impl IndicatorRegistration {
1260    pub fn default_config(&self) -> IndicatorConfig {
1261        let values = self
1262            .compiled
1263            .descriptor
1264            .inputs
1265            .iter()
1266            .map(|input| (input.id.clone(), input_value_to_json(&input.default)))
1267            .collect();
1268        IndicatorConfig::from_value(serde_json::Value::Object(values))
1269    }
1270
1271    pub fn normalize_config(&self, config: IndicatorConfig) -> IndicatorConfig {
1272        let mut values = config.value().as_object().cloned().unwrap_or_default();
1273        for input in &self.compiled.descriptor.inputs {
1274            values
1275                .entry(input.id.clone())
1276                .or_insert_with(|| input_value_to_json(&input.default));
1277        }
1278        IndicatorConfig::from_value(serde_json::Value::Object(values))
1279    }
1280
1281    pub fn settings(
1282        &self,
1283        config: IndicatorConfig,
1284        context: IndicatorSettingsContext,
1285    ) -> Result<Vec<IndicatorSetting>, IndicatorConfigError> {
1286        let values = config.value();
1287        let inputs = &self.compiled.descriptor.inputs;
1288        Ok(inputs
1289            .iter()
1290            .filter(|input| input_is_visible(input, inputs, values, &context.input_coverage))
1291            .map(|input| setting_from_input(input, values))
1292            .collect())
1293    }
1294
1295    pub fn with_setting(
1296        &self,
1297        config: IndicatorConfig,
1298        id: &str,
1299        value: IndicatorSettingValue,
1300    ) -> Option<IndicatorConfig> {
1301        let mut object = config.value().as_object().cloned().unwrap_or_default();
1302        let input = self
1303            .compiled
1304            .descriptor
1305            .inputs
1306            .iter()
1307            .find(|input| input.id == id)?;
1308        let value = setting_value_to_json(input, value)?;
1309        object.insert(id.to_owned(), value);
1310        Some(IndicatorConfig::from_value(serde_json::Value::Object(
1311            object,
1312        )))
1313    }
1314
1315    pub fn history_requirement(
1316        &self,
1317        config: IndicatorConfig,
1318        basis: Basis,
1319        reference_time: exchange::UnixMs,
1320    ) -> IndicatorHistoryRequirement {
1321        self.history_requirement_with_warmup(config, basis, reference_time, None)
1322    }
1323
1324    pub(crate) fn history_requirement_with_warmup(
1325        &self,
1326        config: IndicatorConfig,
1327        basis: Basis,
1328        reference_time: exchange::UnixMs,
1329        warmup_bars: Option<u64>,
1330    ) -> IndicatorHistoryRequirement {
1331        let Ok(next_config) = config_to_runtime(&config, &self.compiled.descriptor) else {
1332            return IndicatorHistoryRequirement::default();
1333        };
1334        let warmup = warmup_bars
1335            .unwrap_or_else(|| self.compiled.warmup_bars(&next_config).unwrap_or(0) as u64);
1336        let start = self
1337            .compiled
1338            .anchor_start(&next_config, reference_time.as_u64() as i64);
1339        let bars = match (start, basis) {
1340            (Some(start), Basis::Time(timeframe)) => {
1341                let elapsed = (reference_time.as_u64() as i64 - start).max(0) as u64;
1342                let dt = timeframe.to_milliseconds().max(1);
1343                warmup.max(elapsed.div_ceil(dt).saturating_add(1))
1344            }
1345            _ => warmup,
1346        };
1347        IndicatorHistoryRequirement::new(start.map(|start| UnixMs::new(start as u64)), bars, warmup)
1348    }
1349
1350    pub fn warmup_bars(&self, config: &IndicatorConfig) -> Option<u64> {
1351        let next_config = config_to_runtime(config, &self.compiled.descriptor).ok()?;
1352        self.compiled
1353            .warmup_bars(&next_config)
1354            .map(|warmup| warmup as u64)
1355    }
1356
1357    pub fn validate_config(&self, config: &IndicatorConfig) -> Result<(), IndicatorConfigError> {
1358        let next_config = config_to_runtime(config, &self.compiled.descriptor)?;
1359        self.compiled
1360            .validate_history_analysis(&next_config)
1361            .map_err(|error| IndicatorConfigError::Deserialize(error.to_string()))
1362    }
1363
1364    pub fn unsupported_reason(
1365        &self,
1366        context: AvailabilityContext,
1367    ) -> Option<IndicatorUnsupportedReason> {
1368        let basis = match context.basis {
1369            Basis::Time(_) => indicator_engine::TimeOrTick::Time,
1370            Basis::Tick(_) => indicator_engine::TimeOrTick::Tick,
1371        };
1372        if self
1373            .compiled
1374            .descriptor
1375            .bases
1376            .as_ref()
1377            .is_some_and(|bases| !bases.contains(&basis))
1378        {
1379            return Some(IndicatorUnsupportedReason::BasisNotSupported);
1380        }
1381        if basis == indicator_engine::TimeOrTick::Tick && self.compiled.requires_anchored_history()
1382        {
1383            return Some(IndicatorUnsupportedReason::IncompleteHistory);
1384        }
1385        if self.compiled.requirements.open_interest {
1386            let Basis::Time(timeframe) = context.basis else {
1387                return Some(IndicatorUnsupportedReason::BasisNotSupported);
1388            };
1389            if !crate::chart::kline::fetch_plan::supports_open_interest_exchange(
1390                context.base_ticker.exchange(),
1391            ) {
1392                return Some(IndicatorUnsupportedReason::SourceNotSupported);
1393            }
1394            if !crate::chart::kline::fetch_plan::supports_open_interest_timeframe(timeframe) {
1395                return Some(IndicatorUnsupportedReason::ResolutionNotSupported);
1396            }
1397        }
1398        None
1399    }
1400
1401    pub(crate) fn create_runtime(
1402        &self,
1403        config: IndicatorConfig,
1404    ) -> Result<ScriptIndicatorRuntime, IndicatorConfigError> {
1405        ScriptIndicatorRuntime::new(self.compiled.clone(), config)
1406    }
1407}
1408
1409enum ScriptRuntimeState {
1410    Active(Box<IndicatorInstance>),
1411    Quarantined(String),
1412}
1413
1414pub(crate) struct ScriptIndicatorRuntime {
1415    compiled: Arc<CompiledIndicator>,
1416    state: ScriptRuntimeState,
1417    outputs: IndicatorOutputStore,
1418}
1419
1420impl ScriptIndicatorRuntime {
1421    fn new(
1422        compiled: Arc<CompiledIndicator>,
1423        config: IndicatorConfig,
1424    ) -> Result<Self, IndicatorConfigError> {
1425        let instance = Self::instantiate(&compiled, &config)?;
1426        Ok(Self {
1427            compiled,
1428            state: ScriptRuntimeState::Active(Box::new(instance)),
1429            outputs: IndicatorOutputStore::default(),
1430        })
1431    }
1432
1433    fn quarantine(&mut self, phase: &str, error: impl std::fmt::Display) {
1434        if matches!(self.state, ScriptRuntimeState::Active(_)) {
1435            let diagnostic = format!("{phase}: {error}");
1436            log::error!(
1437                "indicator {} quarantined: {diagnostic}",
1438                self.compiled.descriptor.id
1439            );
1440            self.state = ScriptRuntimeState::Quarantined(diagnostic);
1441        }
1442        self.outputs = IndicatorOutputStore::default();
1443    }
1444
1445    fn instantiate(
1446        compiled: &Arc<CompiledIndicator>,
1447        config: &IndicatorConfig,
1448    ) -> Result<IndicatorInstance, IndicatorConfigError> {
1449        let next_config = config_to_runtime(config, &compiled.descriptor)?;
1450        IndicatorInstance::with_limits(
1451            Arc::clone(compiled),
1452            next_config,
1453            RuntimeLimits::untrusted(),
1454        )
1455        .map_err(|error| {
1456            let message = match error {
1457                PlanningError::HistoryAnalysisWorkLimitExceeded
1458                | PlanningError::HistoryAnalysisDepthLimitExceeded => error.to_string(),
1459                _ => format!("indicator could not be planned: {error}"),
1460            };
1461            IndicatorConfigError::Deserialize(message)
1462        })
1463    }
1464}
1465
1466impl ScriptIndicatorRuntime {
1467    pub(super) fn committed_index(&self) -> Option<usize> {
1468        match &self.state {
1469            ScriptRuntimeState::Active(instance) => instance.committed_index(),
1470            ScriptRuntimeState::Quarantined(_) => None,
1471        }
1472    }
1473
1474    pub(super) fn evaluate_closed(&mut self, frame: &IndicatorFrame) {
1475        let ScriptRuntimeState::Active(instance) = &mut self.state else {
1476            return;
1477        };
1478        let result = instance
1479            .evaluate_closed(frame.clone())
1480            .and_then(|output| self.outputs.apply_output(output, false));
1481        if let Err(error) = result {
1482            self.quarantine(&format!("closed bar {} failed", frame.index), error);
1483        }
1484    }
1485
1486    pub(super) fn preview_open(&mut self, frame: &IndicatorFrame) {
1487        let ScriptRuntimeState::Active(instance) = &mut self.state else {
1488            return;
1489        };
1490        let result = instance
1491            .preview_open(frame.clone())
1492            .and_then(|output| self.outputs.apply_output(output, true));
1493        if let Err(error) = result {
1494            self.quarantine(&format!("preview bar {} failed", frame.index), error);
1495        }
1496    }
1497
1498    pub(super) fn output_value(&self, channel_id: &str, index: usize) -> Option<OutputValue> {
1499        self.outputs
1500            .series_value(channel_id, index)
1501            .and_then(|value| match value {
1502                Value::Float(value) => finite_number(*value).map(OutputValue::Number),
1503                Value::Int(value) => finite_number(*value as f64).map(OutputValue::Number),
1504                Value::Bool(value) => Some(OutputValue::Boolean(*value)),
1505                _ => None,
1506            })
1507    }
1508
1509    pub(super) fn output_color(
1510        &self,
1511        channel_id: &str,
1512        index: usize,
1513    ) -> Option<indicator_engine::Color> {
1514        self.outputs.series_color(channel_id, index)
1515    }
1516
1517    pub(super) fn marker_outputs_at(&self, index: usize) -> Vec<MarkerOutput> {
1518        self.outputs.marker_values_at(index)
1519    }
1520
1521    pub(super) fn segment_outputs(&self) -> impl Iterator<Item = &SegmentOutput> {
1522        self.outputs.segment_values()
1523    }
1524
1525    pub(super) fn level_outputs(&self) -> Vec<HorizontalLevelOutput> {
1526        self.outputs.level_values()
1527    }
1528
1529    pub(super) fn ohlc_output(&self, channel_id: &str, index: usize) -> Option<OhlcOutput> {
1530        self.outputs.ohlc_value(channel_id, index)
1531    }
1532
1533    pub(super) fn range_output(&self, channel_id: &str, index: usize) -> Option<RangeOutput> {
1534        self.outputs.range_value(channel_id, index)
1535    }
1536
1537    pub(super) fn region_outputs_in_range(
1538        &self,
1539        source_range: Option<Range<usize>>,
1540    ) -> Vec<RegionOutput> {
1541        self.outputs.region_values_in_range(source_range.as_ref())
1542    }
1543
1544    pub(super) fn diagnostic(&self) -> Option<&str> {
1545        match &self.state {
1546            ScriptRuntimeState::Active(_) => None,
1547            ScriptRuntimeState::Quarantined(diagnostic) => Some(diagnostic),
1548        }
1549    }
1550}
1551
1552fn finite_number(value: f64) -> Option<f64> {
1553    value.is_finite().then_some(value)
1554}
1555
1556fn config_to_runtime(
1557    config: &IndicatorConfig,
1558    descriptor: &indicator_engine::IndicatorDescriptor,
1559) -> Result<EngineIndicatorConfig, IndicatorConfigError> {
1560    let object = config.value().as_object();
1561    let mut values = BTreeMap::new();
1562    for input in &descriptor.inputs {
1563        let value = match object.and_then(|object| object.get(&input.id)) {
1564            Some(value) => validated_input_value(input, value)?,
1565            None => input.default.clone(),
1566        };
1567        values.insert(input.id.clone(), value);
1568    }
1569    Ok(EngineIndicatorConfig { values })
1570}
1571
1572fn input_value_from_json(value_type: &ValueType, value: &serde_json::Value) -> Option<InputValue> {
1573    match value_type {
1574        ValueType::Int => value.as_i64().map(InputValue::Int),
1575        ValueType::Float => value
1576            .as_f64()
1577            .filter(|value| value.is_finite())
1578            .map(InputValue::Float),
1579        ValueType::Bool => value.as_bool().map(InputValue::Bool),
1580        ValueType::String => value
1581            .as_str()
1582            .map(|value| InputValue::String(value.to_owned())),
1583        ValueType::Color => value
1584            .as_i64()
1585            .and_then(|value| u32::try_from(value).ok())
1586            .map(InputValue::Color),
1587        ValueType::Time => value.as_i64().map(InputValue::Time),
1588        _ => None,
1589    }
1590}
1591
1592fn input_is_visible(
1593    input: &indicator_engine::InputDescriptor,
1594    inputs: &[indicator_engine::InputDescriptor],
1595    config: &serde_json::Value,
1596    input_coverage: &super::IndicatorInputCoverage,
1597) -> bool {
1598    if input
1599        .required_input
1600        .map(IndicatorInputSource::from)
1601        .is_some_and(|source| {
1602            matches!(
1603                input_coverage.state(source),
1604                IndicatorInputCoverageState::Missing
1605            )
1606        })
1607    {
1608        return false;
1609    }
1610    let Some(dependency) = input.visible_when.as_deref() else {
1611        return true;
1612    };
1613    let Some(controller) = inputs.iter().find(|candidate| candidate.id == dependency) else {
1614        return true;
1615    };
1616    config
1617        .as_object()
1618        .and_then(|values| values.get(dependency))
1619        .and_then(serde_json::Value::as_bool)
1620        .unwrap_or(matches!(&controller.default, InputValue::Bool(true)))
1621}
1622
1623fn validated_input_value(
1624    input: &indicator_engine::InputDescriptor,
1625    value: &serde_json::Value,
1626) -> Result<InputValue, IndicatorConfigError> {
1627    let parsed = input_value_from_json(&input.value_type, value).ok_or_else(|| {
1628        IndicatorConfigError::Deserialize(format!("invalid value for input '{}'", input.id))
1629    })?;
1630    if !input.accepts(&parsed) {
1631        return Err(IndicatorConfigError::Deserialize(format!(
1632            "invalid value for input '{}'",
1633            input.id
1634        )));
1635    }
1636    Ok(parsed)
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    use super::*;
1642    use crate::chart::kline::IndicatorInputCoverage;
1643
1644    fn settings_context() -> IndicatorSettingsContext {
1645        IndicatorSettingsContext {
1646            basis: Basis::Time(exchange::Timeframe::M1),
1647            input_coverage: IndicatorInputCoverage::default(),
1648        }
1649    }
1650
1651    fn input_coverage(directional_volume: bool) -> IndicatorInputCoverage {
1652        IndicatorInputCoverage::from_frames(&[IndicatorFrame {
1653            index: 0,
1654            time: 0,
1655            basis: indicator_engine::TimeOrTick::Time,
1656            instrument: indicator_engine::InstrumentContext { min_tick: None },
1657            chart: indicator_engine::ChartFrame {
1658                open: Some(1.0),
1659                high: Some(1.0),
1660                low: Some(1.0),
1661                close: Some(1.0),
1662                volume: Some(1.0),
1663                directional_volume_available: directional_volume,
1664                buy_qty: 1.0,
1665                sell_qty: 0.0,
1666                trade: None,
1667            },
1668            open_interest: None,
1669            series_inputs: Vec::new().into(),
1670        }])
1671    }
1672
1673    #[test]
1674    fn basis_restrictions_and_anchored_history_availability() {
1675        let ticker = exchange::TickerInfo::new(
1676            exchange::Ticker::new("BTCUSDT", exchange::adapter::Exchange::BinanceLinear),
1677            0.1,
1678            0.001,
1679            None,
1680        );
1681        for (source, origin, tick_reason) in [
1682            (
1683                r#"language(1)
1684indicator("Trade Count", id = "catalog.trade-count", sourceable = true, max_bars_back = 1, bases = ["time"])
1685display(layer_name = "Trades")
1686format(precision = "fixed_step:1", axis_labels = "abbreviated", header_labels = "commas", scale = "fit_visible_include_zero", max_decimals = 0)
1687value = chart.trade.count
1688plot(value, id = "value", title = "Trade count", precision = "decimals:0", style = "histogram", color_slot = "main", layer_title = "Trades", overlay = false)
1689
1690style("main", title = "Line")
1691style("bars", title = "Bars", role = "secondary")
1692mark(title = "Line", source = "value", kind = "line", color_slot = "main")
1693mark(title = "Bars", source = "value", kind = "histogram", color_slot = "bars")
1694"#,
1695                PackageOrigin::Installed,
1696                Some(IndicatorUnsupportedReason::BasisNotSupported),
1697            ),
1698            (
1699                include_str!("../../../../indicators/sources/anchored-vwap.ind"),
1700                PackageOrigin::Bundled,
1701                Some(IndicatorUnsupportedReason::IncompleteHistory),
1702            ),
1703            (
1704                "language(1)\nindicator(\"Restricted anchor\", id = \"catalog.restricted-anchor\", bases = [\"time\"])\nformat(precision = \"base_ticker_min_tick\")\nplot(ta.avwap(chart.close, chart.volume, \"Day\"))",
1705                PackageOrigin::Installed,
1706                Some(IndicatorUnsupportedReason::BasisNotSupported),
1707            ),
1708            (
1709                "language(1)\nindicator(\"Plain\", id = \"catalog.plain\")\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close)",
1710                PackageOrigin::Installed,
1711                None,
1712            ),
1713        ] {
1714            let compiled = indicator_engine::compile(source, "availability.ind")
1715                .expect("source should compile");
1716            let registration = registration(compiled, origin).expect("source should register");
1717            for (basis, expected) in [
1718                (Basis::Time(exchange::Timeframe::M1), None),
1719                (Basis::Tick(crate::aggr::TickCount(100)), tick_reason),
1720                (Basis::Time(exchange::Timeframe::M1), None),
1721            ] {
1722                assert_eq!(
1723                    registration.unsupported_reason(AvailabilityContext {
1724                        basis,
1725                        base_ticker: ticker
1726                    }),
1727                    expected
1728                );
1729            }
1730        }
1731    }
1732
1733    #[test]
1734    fn indicator_metadata_is_exposed_to_host_definition() {
1735        let compiled = indicator_engine::compile(
1736            r#"
1737                language(1)
1738                indicator("Metadata", id = "catalog.metadata",
1739                          author = "Ada", author_url = "https://example.com/ada",
1740                          description = "A sample indicator",
1741                          source = "https://example.com/source")
1742                format(precision = "base_ticker_min_tick")
1743                plot(chart.close, id = "value")
1744            "#,
1745            "metadata.ind",
1746        )
1747        .expect("metadata source should compile");
1748        let registration = registration(compiled, PackageOrigin::Installed)
1749            .expect("metadata source should register");
1750
1751        assert_eq!(
1752            registration.definition.author,
1753            Some(IndicatorAuthor {
1754                name: "Ada".to_owned(),
1755                url: "https://example.com/ada".to_owned(),
1756            })
1757        );
1758        assert_eq!(
1759            registration.definition.description.as_deref(),
1760            Some("A sample indicator")
1761        );
1762        assert_eq!(
1763            registration.definition.source.as_deref(),
1764            Some("https://example.com/source")
1765        );
1766    }
1767
1768    #[test]
1769    fn visible_inputs_are_filtered_without_dropping_persisted_values() {
1770        let compiled = indicator_engine::compile(
1771            r#"
1772                language(1)
1773                indicator("Visibility", id = "catalog.visibility")
1774                format(precision = "base_ticker_min_tick")
1775                show = input.bool(false, "Show", group = "divergence")
1776                length = input.int(5, "Length", min = 1, max = 100,
1777                                   group = "divergence", visible_when = "show",
1778                                   tooltip = "Explains the setting")
1779                mode = input.bool(true, "Mode", group = "divergence",
1780                                  visible_when = "show")
1781                plot(chart.close, id = "value")
1782            "#,
1783            "visibility.ind",
1784        )
1785        .expect("visibility source should compile");
1786        let registration = registration(compiled, PackageOrigin::Installed)
1787            .expect("visibility source should register");
1788
1789        let hidden = registration
1790            .settings(registration.default_config(), settings_context())
1791            .expect("default settings should be available");
1792        assert_eq!(
1793            hidden
1794                .iter()
1795                .map(|setting| setting.id.as_str())
1796                .collect::<Vec<_>>(),
1797            vec!["show"]
1798        );
1799        assert_eq!(hidden[0].tooltip, None);
1800
1801        let enabled = registration
1802            .with_setting(
1803                registration.default_config(),
1804                "show",
1805                IndicatorSettingValue::Bool(true),
1806            )
1807            .expect("show setting should be accepted");
1808        let customized = registration
1809            .with_setting(enabled, "length", IndicatorSettingValue::I64(42))
1810            .expect("hidden setting value should be accepted");
1811        let shown = registration
1812            .settings(customized.clone(), settings_context())
1813            .expect("enabled settings should be available");
1814        assert_eq!(shown.len(), 3);
1815        assert_eq!(
1816            shown
1817                .iter()
1818                .find(|setting| setting.id == "length")
1819                .and_then(|setting| setting.tooltip.as_deref()),
1820            Some("Explains the setting")
1821        );
1822        assert!(
1823            shown
1824                .iter()
1825                .all(|setting| setting.group.as_deref() == Some("divergence"))
1826        );
1827        assert_eq!(
1828            shown
1829                .iter()
1830                .find(|setting| setting.id == "length")
1831                .map(|setting| &setting.value),
1832            Some(&IndicatorSettingValue::I64(42))
1833        );
1834
1835        let disabled = registration
1836            .with_setting(customized, "show", IndicatorSettingValue::Bool(false))
1837            .expect("show setting should be accepted");
1838        let hidden_again = registration
1839            .settings(disabled.clone(), settings_context())
1840            .expect("disabled settings should be available");
1841        assert_eq!(hidden_again.len(), 1);
1842        let reenabled = registration
1843            .with_setting(disabled, "show", IndicatorSettingValue::Bool(true))
1844            .expect("show setting should be accepted");
1845        let restored = registration
1846            .settings(reenabled, settings_context())
1847            .expect("re-enabled settings should be available");
1848        assert_eq!(
1849            restored
1850                .iter()
1851                .find(|setting| setting.id == "length")
1852                .map(|setting| &setting.value),
1853            Some(&IndicatorSettingValue::I64(42))
1854        );
1855    }
1856
1857    #[test]
1858    fn host_input_dependencies_hide_settings_and_styles_without_coverage() {
1859        let compiled = indicator_engine::compile(
1860            r#"
1861                language(1)
1862                indicator("Dependencies", id = "catalog.dependencies")
1863                format(precision = "base_ticker_min_tick")
1864                highlight = input.bool(false, "Highlight", requires = "directional_volume")
1865                plot(chart.close, id = "value", color_slot = "main")
1866                style("main", title = "Main")
1867                style("warning", title = "Warning", role = "warning",
1868                      requires = "directional_volume")
1869            "#,
1870            "dependencies.ind",
1871        )
1872        .expect("dependency source should compile");
1873        let registration = registration(compiled, PackageOrigin::Installed)
1874            .expect("dependency source should register");
1875
1876        assert_eq!(
1877            registration
1878                .definition
1879                .style_slots
1880                .iter()
1881                .find(|style| style.id == "warning")
1882                .and_then(|style| style.required_input),
1883            Some(IndicatorInputSource::DirectionalVolume)
1884        );
1885
1886        let unavailable = registration
1887            .settings(
1888                registration.default_config(),
1889                IndicatorSettingsContext {
1890                    basis: Basis::Time(exchange::Timeframe::M1),
1891                    input_coverage: input_coverage(false),
1892                },
1893            )
1894            .expect("unavailable settings should resolve");
1895        assert!(unavailable.is_empty());
1896
1897        let available = registration
1898            .settings(
1899                registration.default_config(),
1900                IndicatorSettingsContext {
1901                    basis: Basis::Time(exchange::Timeframe::M1),
1902                    input_coverage: input_coverage(true),
1903                },
1904            )
1905            .expect("available settings should resolve");
1906        assert_eq!(available.len(), 1);
1907        assert_eq!(available[0].id, "highlight");
1908    }
1909
1910    fn region(key: i64, upper: f64) -> RegionOutput {
1911        RegionOutput {
1912            id: "zones".to_owned(),
1913            key,
1914            left_index: 1,
1915            right_index: 3,
1916            upper,
1917            lower: upper - 1.0,
1918            border_color: None,
1919            fill_color: None,
1920            border_color_slot: None,
1921            fill_color_slot: None,
1922            extend_right: true,
1923            width: 1.0,
1924        }
1925    }
1926
1927    #[test]
1928    fn region_store_replaces_the_active_snapshot_and_duplicate_keys() {
1929        let mut store = IndicatorOutputStore::default();
1930        let first = FrameOutput {
1931            regions: vec![region(1, 10.0), region(2, 20.0)].into(),
1932            ..Default::default()
1933        };
1934        store.apply_output(first, false).unwrap();
1935        assert_eq!(store.region_values().len(), 2);
1936
1937        let second = FrameOutput {
1938            regions: vec![region(1, 30.0), region(1, 40.0)].into(),
1939            ..Default::default()
1940        };
1941        store.apply_output(second, false).unwrap();
1942        let regions = store.region_values();
1943        assert_eq!(regions.len(), 1);
1944        assert_eq!(regions[0].upper, 40.0);
1945    }
1946
1947    #[test]
1948    fn persistent_region_store_commits_and_previews_transactionally() {
1949        let mut store = IndicatorOutputStore::default();
1950        let closed = FrameOutput {
1951            region_updates: vec![RegionUpdate::Upsert(region(1, 10.0))].into(),
1952            ..Default::default()
1953        };
1954        store.apply_output(closed, false).unwrap();
1955        assert_eq!(store.region_values()[0].upper, 10.0);
1956
1957        let preview = FrameOutput {
1958            region_updates: vec![
1959                RegionUpdate::Upsert(region(1, 20.0)),
1960                RegionUpdate::Upsert(region(2, 30.0)),
1961            ]
1962            .into(),
1963            ..Default::default()
1964        };
1965        store.apply_output(preview, true).unwrap();
1966        let preview_regions = store.region_values();
1967        assert_eq!(preview_regions.len(), 2);
1968        assert_eq!(
1969            preview_regions
1970                .iter()
1971                .find(|region| region.key == 1)
1972                .map(|region| region.upper),
1973            Some(20.0)
1974        );
1975
1976        store.apply_output(FrameOutput::default(), true).unwrap();
1977        let reverted = store.region_values();
1978        assert_eq!(reverted.len(), 1);
1979        assert_eq!(reverted[0].upper, 10.0);
1980
1981        let preview_removed = FrameOutput {
1982            region_updates: vec![RegionUpdate::Remove {
1983                id: "zones".to_owned(),
1984                key: 1,
1985            }]
1986            .into(),
1987            ..Default::default()
1988        };
1989        store.apply_output(preview_removed, true).unwrap();
1990        assert!(store.region_values().is_empty());
1991        store.apply_output(FrameOutput::default(), true).unwrap();
1992        assert_eq!(store.region_values()[0].upper, 10.0);
1993
1994        let removed = FrameOutput {
1995            region_updates: vec![RegionUpdate::Remove {
1996                id: "zones".to_owned(),
1997                key: 1,
1998            }]
1999            .into(),
2000            ..Default::default()
2001        };
2002        store.apply_output(removed, false).unwrap();
2003        assert!(store.region_values().is_empty());
2004    }
2005
2006    #[test]
2007    fn persistent_region_store_evicts_oldest_without_reordering_updates() {
2008        let mut store = IndicatorOutputStore::default();
2009        let limit = RuntimeLimits::untrusted().max_output_items;
2010        let updates = (0..limit as i64)
2011            .map(|key| RegionUpdate::Upsert(region(key, key as f64)))
2012            .collect::<Vec<_>>();
2013        store
2014            .apply_output(
2015                FrameOutput {
2016                    region_updates: updates.into(),
2017                    ..Default::default()
2018                },
2019                false,
2020            )
2021            .unwrap();
2022
2023        store
2024            .apply_output(
2025                FrameOutput {
2026                    region_updates: vec![
2027                        RegionUpdate::Upsert(region(1, 100.0)),
2028                        RegionUpdate::Upsert(region(limit as i64, 200.0)),
2029                    ]
2030                    .into(),
2031                    ..Default::default()
2032                },
2033                false,
2034            )
2035            .unwrap();
2036
2037        let regions = store.region_values();
2038        assert_eq!(regions.len(), limit);
2039        assert!(!regions.iter().any(|region| region.key == 0));
2040        assert!(
2041            regions
2042                .iter()
2043                .any(|region| region.key == 1 && region.upper == 100.0)
2044        );
2045        assert!(regions.iter().any(|region| region.key == limit as i64));
2046    }
2047
2048    #[test]
2049    fn preview_region_retention_matches_commit_without_mutating_base() {
2050        let mut store = IndicatorOutputStore::default();
2051        let limit = RuntimeLimits::untrusted().max_output_items;
2052        let committed_updates = (0..limit as i64)
2053            .map(|key| RegionUpdate::Upsert(region(key, key as f64)))
2054            .collect::<Vec<_>>();
2055        store
2056            .apply_output(
2057                FrameOutput {
2058                    region_updates: committed_updates.into(),
2059                    ..Default::default()
2060                },
2061                false,
2062            )
2063            .unwrap();
2064
2065        let preview_update = RegionUpdate::Upsert(region(limit as i64, 100.0));
2066        store
2067            .apply_output(
2068                FrameOutput {
2069                    region_updates: vec![preview_update.clone()].into(),
2070                    ..Default::default()
2071                },
2072                true,
2073            )
2074            .unwrap();
2075        let preview_regions = store.region_values();
2076        assert_eq!(preview_regions.len(), limit);
2077        assert!(!preview_regions.iter().any(|region| region.key == 0));
2078        assert!(
2079            preview_regions
2080                .iter()
2081                .any(|region| region.key == limit as i64)
2082        );
2083
2084        store.apply_output(FrameOutput::default(), true).unwrap();
2085        let reverted = store.region_values();
2086        assert_eq!(reverted.len(), limit);
2087        assert!(reverted.iter().any(|region| region.key == 0));
2088        assert!(!reverted.iter().any(|region| region.key == limit as i64));
2089
2090        store
2091            .apply_output(
2092                FrameOutput {
2093                    region_updates: vec![preview_update].into(),
2094                    ..Default::default()
2095                },
2096                false,
2097            )
2098            .unwrap();
2099        let committed = store.region_values();
2100        assert_eq!(committed.len(), limit);
2101        assert!(!committed.iter().any(|region| region.key == 0));
2102        assert!(committed.iter().any(|region| region.key == limit as i64));
2103    }
2104}