Skip to main content

flowsurface_indicator_engine/
lib.rs

1mod diagnostics;
2mod frame;
3mod hir;
4mod intrinsic;
5mod parser;
6mod runtime;
7mod scene;
8mod types;
9mod warmup;
10
11pub use diagnostics::{Diagnostic, Severity, Span};
12pub use frame::{
13    BaseSeriesSource, ChartFrame, FrameField, FrameOutput, HorizontalLevelOutput, IndicatorFrame,
14    InstrumentContext, MarkerOutput, OhlcOutput, PlotOutput, RangeOutput, RegionOutput,
15    RegionUpdate, SegmentOutput, SeriesInputValue, TimeOrTick, TradeAggregate,
16};
17pub use hir::{
18    BarStateField, BinaryOperator, CallSiteId, ConstantId, ExprId, ExpressionKind, FunctionId,
19    HirFunction, HirProgram, IntrinsicId, LocalSlot, PlotId, SeriesSlot, SourceInputSlot,
20    StateSlot, StatementBlock, StatementBlockId, StatementId, StatementKind, TypedExpression,
21    TypedStatement, UnaryOperator,
22};
23use intrinsic::IntrinsicRegistry;
24pub use runtime::{IndicatorInstance, PlanningError, RuntimeError, RuntimeLimits};
25pub use scene::{
26    Color, IndicatorPresentation, OutputPrecision, PaneTarget, PlotDescriptor, PlotScale,
27    PlotStyle, PlotValueType, PresentationAttachmentMode, PresentationAxis, PresentationColumn,
28    PresentationColumnColor, PresentationDataKind, PresentationFill, PresentationGeometry,
29    PresentationInputSource, PresentationLabelMode, PresentationLayer, PresentationMark,
30    PresentationMarkKind, PresentationPrecision, PresentationReadout, PresentationScale,
31    PresentationSegment, PresentationStyle, PresentationViewKind, ReadoutBehavior,
32};
33pub use types::{
34    ArrayValue, IndicatorAuthor, IndicatorConfig, IndicatorDescriptor, IndicatorId,
35    InputDescriptor, InputOption, InputType, InputValue, Qualifier, SeriesInputDescriptor, Type,
36    Value, ValueType,
37};
38
39/// Discover sorted `.ind` files recursively, retaining diagnostics for unreadable entries.
40/// Symlink entries are skipped; failure to read the root directory is fatal.
41pub fn source_files_in_directory(
42    path: impl AsRef<std::path::Path>,
43) -> Result<(Vec<std::path::PathBuf>, Vec<String>), String> {
44    let root = path.as_ref();
45    let mut directories = vec![root.to_owned()];
46    let mut files = Vec::new();
47    let mut diagnostics = Vec::new();
48    while let Some(directory) = directories.pop() {
49        let entries = match std::fs::read_dir(&directory) {
50            Ok(entries) => entries,
51            Err(error) => {
52                let diagnostic =
53                    format!("{}: could not read directory: {error}", directory.display());
54                if directory == root {
55                    return Err(diagnostic);
56                }
57                diagnostics.push(diagnostic);
58                continue;
59            }
60        };
61        for entry in entries {
62            let entry = match entry {
63                Ok(entry) => entry,
64                Err(error) => {
65                    diagnostics.push(format!(
66                        "{}: could not read directory entry: {error}",
67                        directory.display()
68                    ));
69                    continue;
70                }
71            };
72            let path = entry.path();
73            let file_type = match entry.file_type() {
74                Ok(file_type) => file_type,
75                Err(error) => {
76                    diagnostics.push(format!(
77                        "{}: could not inspect entry: {error}",
78                        path.display()
79                    ));
80                    continue;
81                }
82            };
83            if file_type.is_dir() {
84                directories.push(path);
85            } else if file_type.is_file()
86                && path.extension().and_then(|extension| extension.to_str()) == Some("ind")
87            {
88                files.push(path);
89            }
90        }
91    }
92    files.sort();
93    diagnostics.sort();
94    Ok((files, diagnostics))
95}
96
97pub type Program = HirProgram;
98
99/// Bounds applied by the public source compiler.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct CompileLimits {
102    pub max_source_bytes: usize,
103    pub max_tokens: usize,
104    pub max_expressions: usize,
105    pub max_statements: usize,
106    pub max_functions: usize,
107    pub max_output_channels: usize,
108}
109
110impl Default for CompileLimits {
111    fn default() -> Self {
112        Self {
113            max_source_bytes: 1 << 20,
114            max_tokens: 100_000,
115            max_expressions: 100_000,
116            max_statements: 50_000,
117            max_functions: 256,
118            max_output_channels: 256,
119        }
120    }
121}
122
123/// Host data activated by a compiled indicator.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub struct DataRequirements {
126    pub ohlc: bool,
127    pub directional_volume: bool,
128    pub trades: bool,
129    pub open_interest: bool,
130}
131
132impl DataRequirements {
133    pub const fn needs_directional_volume(self) -> bool {
134        self.directional_volume
135    }
136
137    pub const fn needs_open_interest(self) -> bool {
138        self.open_interest
139    }
140}
141
142use std::path::Path;
143use std::sync::Arc;
144
145/// A compiled source program and the metadata discovered from that source.
146#[derive(Debug, Clone)]
147pub struct CompiledIndicator {
148    pub descriptor: IndicatorDescriptor,
149    pub requirements: DataRequirements,
150    pub program: Program,
151    pub(crate) registry: intrinsic::IntrinsicRegistry,
152}
153
154/// Parse and type-check one source-owned indicator file.
155pub fn compile(source: &str, path: impl AsRef<Path>) -> Result<CompiledIndicator, Vec<Diagnostic>> {
156    compile_with_limits(source, path, CompileLimits::default())
157}
158
159/// Parse and type-check source with explicit resource bounds.
160pub fn compile_with_limits(
161    source: &str,
162    path: impl AsRef<Path>,
163    limits: CompileLimits,
164) -> Result<CompiledIndicator, Vec<Diagnostic>> {
165    let path = path.as_ref();
166    if source.len() > limits.max_source_bytes {
167        return Err(vec![Diagnostic::error(
168            path,
169            Span::new(0, source.len().min(limits.max_source_bytes)),
170            format!(
171                "source exceeds compile limit of {} bytes",
172                limits.max_source_bytes
173            ),
174        )]);
175    }
176    compile_with_registry(source, path, IntrinsicRegistry::standard(), limits)
177}
178
179/// Read and compile one indicator source file.
180pub fn compile_file(path: impl AsRef<Path>) -> Result<CompiledIndicator, Vec<Diagnostic>> {
181    compile_file_with_limits(path, CompileLimits::default())
182}
183
184/// Read and compile one indicator source file with explicit resource bounds.
185pub fn compile_file_with_limits(
186    path: impl AsRef<Path>,
187    limits: CompileLimits,
188) -> Result<CompiledIndicator, Vec<Diagnostic>> {
189    let path = path.as_ref();
190    match std::fs::metadata(path) {
191        Ok(metadata) if metadata.len() > limits.max_source_bytes as u64 => {
192            return Err(vec![Diagnostic::error(
193                path,
194                Span::new(0, 0),
195                format!(
196                    "source exceeds compile limit of {} bytes",
197                    limits.max_source_bytes
198                ),
199            )]);
200        }
201        Err(error) => {
202            return Err(vec![Diagnostic::error(
203                path,
204                Span::new(0, 0),
205                format!("could not read source: {error}"),
206            )]);
207        }
208        Ok(_) => {}
209    }
210    let source = match std::fs::read_to_string(path) {
211        Ok(source) => source,
212        Err(error) => {
213            return Err(vec![Diagnostic::error(
214                path,
215                Span::new(0, 0),
216                format!("could not read source: {error}"),
217            )]);
218        }
219    };
220    compile_with_limits(&source, path, limits)
221}
222
223pub(crate) fn compile_with_registry(
224    source: &str,
225    path: impl AsRef<Path>,
226    registry: intrinsic::IntrinsicRegistry,
227    limits: CompileLimits,
228) -> Result<CompiledIndicator, Vec<Diagnostic>> {
229    parser::compile_with_registry(source, path.as_ref(), registry, limits)
230}
231
232impl CompiledIndicator {
233    /// Static bars of history the plotted values need before they are
234    /// meaningful, resolved from constants, inputs, and windowed intrinsics.
235    /// Returns `None` when history analysis exceeds its limits.
236    pub fn warmup_bars(&self, config: &IndicatorConfig) -> Option<usize> {
237        self.warmup_bars_with_limits(config, RuntimeLimits::default())
238    }
239
240    /// Resolve warmup using the same rolling-window policy as the runtime.
241    pub fn warmup_bars_with_limits(
242        &self,
243        config: &IndicatorConfig,
244        limits: RuntimeLimits,
245    ) -> Option<usize> {
246        warmup::warmup_bars(
247            &self.program,
248            &self.registry,
249            &self.descriptor,
250            config,
251            limits.max_window_length,
252        )
253        .ok()
254    }
255
256    /// Earliest UTC boundary an anchored intrinsic needs data from, resolved
257    /// from the configuration. Returns `None` when the indicator has no
258    /// anchored calculations such as `ta.avwap`, or when analysis exceeds its limits.
259    pub fn anchor_start(&self, config: &IndicatorConfig, reference_time: i64) -> Option<i64> {
260        warmup::anchor_start(
261            &self.program,
262            &self.registry,
263            &self.descriptor,
264            config,
265            reference_time,
266        )
267        .ok()
268        .flatten()
269    }
270
271    /// Reject configurations whose history analysis exceeds its work or depth limits.
272    pub fn validate_history_analysis(&self, config: &IndicatorConfig) -> Result<(), PlanningError> {
273        warmup::warmup_bars(
274            &self.program,
275            &self.registry,
276            &self.descriptor,
277            config,
278            RuntimeLimits::default().max_window_length,
279        )?;
280        warmup::anchor_start(&self.program, &self.registry, &self.descriptor, config, 0)?;
281        Ok(())
282    }
283
284    /// Whether the compiled program contains calculations requiring complete anchor history.
285    pub fn requires_anchored_history(&self) -> bool {
286        warmup::requires_anchored_history(&self.program, &self.registry)
287    }
288
289    /// Create an instance that evaluates only the frames supplied by the chart.
290    pub fn instantiate(&self, config: IndicatorConfig) -> Result<IndicatorInstance, PlanningError> {
291        self.instantiate_with_limits(config, RuntimeLimits::default())
292    }
293
294    pub fn instantiate_with_limits(
295        &self,
296        config: IndicatorConfig,
297        limits: RuntimeLimits,
298    ) -> Result<IndicatorInstance, PlanningError> {
299        IndicatorInstance::with_limits(Arc::new(self.clone()), config, limits)
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use std::fmt::Write as _;
307
308    fn frame(index: usize, time: i64, close: f64) -> IndicatorFrame {
309        IndicatorFrame {
310            index,
311            time,
312            basis: TimeOrTick::Time,
313            instrument: InstrumentContext {
314                min_tick: Some(0.1),
315            },
316            chart: ChartFrame {
317                open: Some(close),
318                high: Some(close),
319                low: Some(close),
320                close: Some(close),
321                volume: Some(1.0),
322                directional_volume_available: false,
323                buy_qty: 0.0,
324                sell_qty: 0.0,
325                trade: None,
326            },
327            open_interest: None,
328            series_inputs: Vec::new().into(),
329        }
330    }
331
332    fn market_frame(
333        index: usize,
334        high: Option<f64>,
335        low: Option<f64>,
336        close: Option<f64>,
337        volume: Option<f64>,
338    ) -> IndicatorFrame {
339        let close_value = close.unwrap_or(0.0);
340        let mut frame = frame(index, index as i64, close_value);
341        frame.chart.open = close;
342        frame.chart.high = high;
343        frame.chart.low = low;
344        frame.chart.close = close;
345        frame.chart.volume = volume;
346        frame
347    }
348
349    fn plot_value<'a>(output: &'a FrameOutput, id: &str) -> &'a Value {
350        output
351            .plots
352            .iter()
353            .find(|plot| plot.id == id)
354            .map(|plot| &plot.value)
355            .expect("plot output should be present")
356    }
357
358    #[test]
359    fn indicator_basis_restrictions() {
360        for (declaration, expected) in [
361            ("", None),
362            (", bases = [\"time\"]", Some(vec![TimeOrTick::Time])),
363            (", bases = [\"tick\"]", Some(vec![TimeOrTick::Tick])),
364            (
365                ", bases = [\"time\", \"tick\"]",
366                Some(vec![TimeOrTick::Time, TimeOrTick::Tick]),
367            ),
368            (
369                ", bases = [\"tick\", \"tick\"]",
370                Some(vec![TimeOrTick::Tick]),
371            ),
372        ] {
373            let source = format!(
374                "language(1)\nindicator(\"Bases\", id = \"test.bases\"{declaration})\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close)"
375            );
376            let compiled = compile(&source, "bases.ind").expect("valid basis restriction");
377            assert_eq!(compiled.descriptor.bases, expected);
378        }
379        for declaration in [
380            "[]",
381            "[\"range\"]",
382            "[1]",
383            "[\"time\", false]",
384            "\"time\"",
385            "chart.close",
386        ] {
387            let source = format!(
388                "language(1)\nindicator(\"Bases\", id = \"test.bases\", bases = {declaration})\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close)"
389            );
390            let error = compile(&source, "bases.ind").expect_err("invalid basis restriction");
391            assert!(
392                error
393                    .iter()
394                    .any(|diagnostic| diagnostic.message.contains("bases"))
395            );
396        }
397    }
398
399    #[test]
400    fn anchored_history_is_inferred_through_functions() {
401        for (body, expected) in [
402            ("plot(chart.close)", false),
403            ("plot(ta.avwap(chart.close, chart.volume, \"Day\"))", true),
404            (
405                "fn anchored(price) { return ta.avwap(price, chart.volume, \"Week\") }\nplot(anchored(chart.close))",
406                true,
407            ),
408        ] {
409            let source = format!(
410                "language(1)\nindicator(\"History\", id = \"test.history\")\nformat(precision = \"base_ticker_min_tick\")\n{body}"
411            );
412            let compiled = compile(&source, "history.ind").expect("history source should compile");
413            assert_eq!(compiled.requires_anchored_history(), expected);
414        }
415    }
416
417    #[test]
418    fn record_origin_alias_chain_is_bounded() {
419        const ALIAS_COUNT: usize = 24_000;
420        let mut source = String::from(
421            "language(1)\nindicator(\"Long aliases\", id = \"test.long-aliases\")\nformat(precision = \"base_ticker_min_tick\")\nalias0 = chart\n",
422        );
423        for index in 1..ALIAS_COUNT {
424            writeln!(&mut source, "alias{index} = alias{}", index - 1)
425                .expect("writing an in-memory source should succeed");
426        }
427        writeln!(
428            &mut source,
429            "plot(alias{}.close, id = \"value\")",
430            ALIAS_COUNT - 1
431        )
432        .expect("writing an in-memory source should succeed");
433
434        let result = std::thread::Builder::new()
435            .name("record-origin-regression".to_owned())
436            .stack_size(2 * 1024 * 1024)
437            .spawn(move || compile(&source, "long-aliases.ind"))
438            .expect("regression thread should start")
439            .join()
440            .expect("record-origin traversal should not overflow the stack");
441        assert!(
442            result.is_ok(),
443            "long immutable alias chains should compile: {result:?}"
444        );
445    }
446
447    #[test]
448    fn record_field_classification_preserves_builtin_and_dynamic_records() {
449        let chart_alias = compile(
450            r#"
451                language(1)
452                indicator("Chart alias", id = "test.chart-alias")
453                format(precision = "base_ticker_min_tick")
454                chart_alias = chart
455                plot(chart_alias.close, id = "value")
456            "#,
457            "chart-alias.ind",
458        )
459        .expect("an aliased chart should retain strict fields");
460        assert!(chart_alias.requirements.ohlc);
461
462        let trade_alias = compile(
463            r#"
464                language(1)
465                indicator("Trade aliases", id = "test.trade-aliases")
466                format(precision = "base_ticker_min_qty")
467                chart_alias = chart
468                chart_trade = chart.trade
469                aliased_trade = chart_alias.trade
470                plot(chart_trade.count, id = "direct")
471                plot(aliased_trade.count, id = "aliased")
472            "#,
473            "trade-aliases.ind",
474        )
475        .expect("chart.trade aliases should retain strict trade fields");
476        assert!(trade_alias.requirements.trades);
477
478        for source in [
479            r#"
480                language(1)
481                indicator("Invalid chart field", id = "test.invalid-chart-field")
482                format(precision = "base_ticker_min_tick")
483                chart_alias = chart
484                plot(chart_alias.missing, id = "value")
485            "#,
486            r#"
487                language(1)
488                indicator("Invalid trade field", id = "test.invalid-trade-field")
489                format(precision = "base_ticker_min_qty")
490                trade_alias = chart.trade
491                plot(trade_alias.close, id = "value")
492            "#,
493        ] {
494            let diagnostics = compile(source, "invalid-record-field.ind")
495                .expect_err("unknown built-in fields should remain strict");
496            assert!(
497                diagnostics
498                    .iter()
499                    .any(|diagnostic| { diagnostic.message.contains("unknown record field") })
500            );
501        }
502
503        let nested = compile(
504            r#"
505                language(1)
506                indicator("Nested user record", id = "test.nested-user-record")
507                format(precision = "base_ticker_min_tick")
508                value = { trade: { close: 7 } }
509                plot(value.trade.close, id = "value")
510            "#,
511            "nested-user-record.ind",
512        )
513        .expect("user records named trade should stay dynamic");
514        let mut nested_instance = nested
515            .instantiate(IndicatorConfig::default())
516            .expect("nested user record should instantiate");
517        let nested_output = nested_instance
518            .evaluate_closed(frame(0, 1, 10.0))
519            .expect("nested user record should evaluate");
520        assert_eq!(plot_value(&nested_output, "value"), &Value::Int(7));
521
522        let mutable = compile(
523            r#"
524                language(1)
525                indicator("Mutable record", id = "test.mutable-record")
526                format(precision = "base_ticker_min_tick")
527                value = chart
528                value := { close: 9 }
529                plot(value.close, id = "value")
530            "#,
531            "mutable-record.ind",
532        )
533        .expect("mutable records should use dynamic field access");
534        let mut mutable_instance = mutable
535            .instantiate(IndicatorConfig::default())
536            .expect("mutable record should instantiate");
537        let mutable_output = mutable_instance
538            .evaluate_closed(frame(0, 1, 10.0))
539            .expect("mutable record should evaluate");
540        assert_eq!(plot_value(&mutable_output, "value"), &Value::Int(9));
541    }
542
543    #[test]
544    fn rsi_uses_wilder_seed_and_exact_zero_edges() {
545        let source = r#"
546            language(1)
547            indicator("RSI", id = "test.rsi")
548            format(precision = "base_ticker_min_tick")
549            period = input.int(3, "Period", min = 1, max = 100)
550            plot(ta.rsi(chart.close, period), id = "value")
551        "#;
552        let compiled = compile(source, "rsi.ind").expect("RSI source should compile");
553        assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(4));
554
555        let evaluate = |values: &[Option<f64>]| {
556            let mut instance = compiled
557                .clone()
558                .instantiate(IndicatorConfig::default())
559                .expect("RSI inputs should bind");
560            values
561                .iter()
562                .enumerate()
563                .map(|(index, close)| {
564                    plot_value(
565                        &instance
566                            .evaluate_closed(market_frame(index, *close, *close, *close, Some(1.0)))
567                            .expect("RSI frame should evaluate"),
568                        "value",
569                    )
570                    .clone()
571                })
572                .collect::<Vec<_>>()
573        };
574
575        let alternating = evaluate(&[Some(1.0), Some(2.0), Some(1.0), Some(2.0), Some(1.0)]);
576        assert_eq!(&alternating[..3], &[Value::Na, Value::Na, Value::Na]);
577        assert!(
578            matches!(alternating[3], Value::Float(value) if (value - 66.6666666667).abs() < 1e-9)
579        );
580        assert!(
581            matches!(alternating[4], Value::Float(value) if (value - 44.4444444444).abs() < 1e-9)
582        );
583
584        for (values, expected) in [
585            (&[Some(1.0), Some(2.0), Some(3.0), Some(4.0)][..], 100.0),
586            (&[Some(4.0), Some(3.0), Some(2.0), Some(1.0)][..], 0.0),
587            (&[Some(1.0), Some(1.0), Some(1.0), Some(1.0)][..], 50.0),
588            (
589                &[
590                    Some(1.0),
591                    Some(1.0 + 1e-12),
592                    Some(1.0 + 2e-12),
593                    Some(1.0 + 3e-12),
594                ][..],
595                100.0,
596            ),
597        ] {
598            let output = evaluate(values);
599            assert!(matches!(output[3], Value::Float(value) if (value - expected).abs() < 1e-9));
600        }
601    }
602
603    #[test]
604    fn rsi_gaps_retain_state_and_previews_do_not_commit() {
605        let source = r#"
606            language(1)
607            indicator("RSI gaps", id = "test.rsi-gaps")
608            format(precision = "base_ticker_min_tick")
609            period = input.int(3, "Period", min = 1, max = 100)
610            plot(ta.rsi(chart.close, period), id = "value")
611        "#;
612        let compiled = compile(source, "rsi-gaps.ind").expect("RSI source should compile");
613
614        let mut instance = compiled
615            .clone()
616            .instantiate(IndicatorConfig::default())
617            .expect("RSI inputs should bind");
618        for (index, close) in [Some(1.0), Some(2.0)].into_iter().enumerate() {
619            instance
620                .evaluate_closed(market_frame(index, close, close, close, Some(1.0)))
621                .expect("seed frame should evaluate");
622        }
623        let preview = instance
624            .preview_open(market_frame(2, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
625            .expect("RSI preview should evaluate");
626        assert_eq!(plot_value(&preview, "value"), &Value::Na);
627        let second_preview = instance
628            .preview_open(market_frame(2, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
629            .expect("repeated RSI preview should evaluate");
630        assert_eq!(plot_value(&second_preview, "value"), &Value::Na);
631        let committed = instance
632            .evaluate_closed(market_frame(2, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
633            .expect("RSI commit should evaluate");
634        assert_eq!(plot_value(&committed, "value"), &Value::Na);
635        let committed = instance
636            .evaluate_closed(market_frame(3, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
637            .expect("RSI seeded output should evaluate");
638        assert!(
639            matches!(plot_value(&committed, "value"), Value::Float(value) if (*value - 66.6666666667).abs() < 1e-9)
640        );
641
642        let mut gap_instance = compiled
643            .instantiate(IndicatorConfig::default())
644            .expect("RSI gap inputs should bind");
645        let values = [Some(1.0), Some(2.0), None, Some(1.0), Some(2.0), Some(1.0)];
646        let outputs = values
647            .iter()
648            .enumerate()
649            .map(|(index, close)| {
650                plot_value(
651                    &gap_instance
652                        .evaluate_closed(market_frame(index, *close, *close, *close, Some(1.0)))
653                        .expect("RSI gap frame should evaluate"),
654                    "value",
655                )
656                .clone()
657            })
658            .collect::<Vec<_>>();
659        assert_eq!(outputs[2], Value::Na);
660        assert!(matches!(outputs[5], Value::Float(value) if (value - 66.6666666667).abs() < 1e-9));
661
662        let mut seeded_gap_instance = compiled
663            .instantiate(IndicatorConfig::default())
664            .expect("seeded RSI gap inputs should bind");
665        for (index, close) in [Some(1.0), Some(2.0), Some(1.0), Some(2.0), None, Some(3.0)]
666            .into_iter()
667            .enumerate()
668        {
669            let output = seeded_gap_instance
670                .evaluate_closed(market_frame(index, close, close, close, Some(1.0)))
671                .expect("seeded RSI gap frame should evaluate");
672            if index == 4 || index == 5 {
673                assert_eq!(plot_value(&output, "value"), &Value::Na);
674            }
675        }
676        let output = seeded_gap_instance
677            .evaluate_closed(market_frame(6, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
678            .expect("post-gap RSI frame should evaluate");
679        assert!(
680            matches!(plot_value(&output, "value"), Value::Float(value) if (value - 44.4444444444).abs() < 1e-9)
681        );
682    }
683
684    #[test]
685    fn pivots_confirm_after_the_right_leg_and_accept_ties() {
686        let source = r#"
687            language(1)
688            indicator("Pivots", id = "test.pivots")
689            format(precision = "base_ticker_min_tick")
690            left = input.int(1, "Left", min = 1, max = 10)
691            right = input.int(1, "Right", min = 1, max = 10)
692            plot(ta.pivothigh(chart.high, left, right), id = "high")
693            plot(ta.pivotlow(chart.low, left, right), id = "low")
694        "#;
695        let compiled = compile(source, "pivots.ind").expect("pivots should compile");
696        assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(3));
697        let mut instance = compiled
698            .instantiate(IndicatorConfig::default())
699            .expect("inputs should bind");
700
701        let values = [(1.0, 3.0), (3.0, 3.0), (2.0, 2.0)];
702        let first = instance
703            .evaluate_closed(market_frame(0, Some(1.0), Some(3.0), Some(1.0), Some(1.0)))
704            .expect("first frame should evaluate");
705        assert_eq!(plot_value(&first, "high"), &Value::Na);
706        assert_eq!(plot_value(&first, "low"), &Value::Na);
707        let second = instance
708            .evaluate_closed(market_frame(1, Some(3.0), Some(3.0), Some(3.0), Some(1.0)))
709            .expect("second frame should evaluate");
710        assert_eq!(plot_value(&second, "high"), &Value::Na);
711        let third = instance
712            .evaluate_closed(market_frame(2, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
713            .expect("third frame should evaluate");
714        assert_eq!(plot_value(&third, "high"), &Value::Float(values[1].0));
715        assert_eq!(plot_value(&third, "low"), &Value::Na);
716
717        let mut preview_instance = compiled
718            .instantiate(IndicatorConfig::default())
719            .expect("preview inputs should bind");
720        preview_instance
721            .evaluate_closed(market_frame(0, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
722            .expect("first preview frame should evaluate");
723        preview_instance
724            .evaluate_closed(market_frame(1, Some(3.0), Some(3.0), Some(3.0), Some(1.0)))
725            .expect("second preview frame should evaluate");
726        let preview = preview_instance
727            .preview_open(market_frame(2, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
728            .expect("pivot preview should evaluate");
729        assert_eq!(plot_value(&preview, "high"), &Value::Float(3.0));
730        let closed_after_preview = preview_instance
731            .evaluate_closed(market_frame(2, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
732            .expect("closed frame after preview should evaluate");
733        assert_eq!(
734            plot_value(&closed_after_preview, "high"),
735            &Value::Float(3.0)
736        );
737    }
738
739    #[test]
740    fn pivots_return_na_for_missing_samples_and_dynamic_legs() {
741        let source = r#"
742            language(1)
743            indicator("Dynamic pivots", id = "test.dynamic-pivots")
744            format(precision = "base_ticker_min_tick")
745            left = input.int(1, "Left", min = 1, max = 10)
746            right = input.int(2, "Right", min = 1, max = 10)
747            plot(ta.pivothigh(chart.high, left, right), id = "high")
748        "#;
749        let compiled = compile(source, "dynamic-pivots.ind").expect("pivots should compile");
750        assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(4));
751        let mut instance = compiled
752            .instantiate(IndicatorConfig::default().with("right", InputValue::Int(2)))
753            .expect("inputs should bind");
754        for (index, high) in [Some(1.0), None, Some(2.0), Some(1.0)]
755            .into_iter()
756            .enumerate()
757        {
758            let output = instance
759                .evaluate_closed(market_frame(index, high, high, high, Some(1.0)))
760                .expect("frame should evaluate");
761            assert_eq!(plot_value(&output, "high"), &Value::Na);
762        }
763
764        let mut instance = compiled
765            .instantiate(IndicatorConfig::default().with("right", InputValue::Int(1)))
766            .expect("dynamic input should bind");
767        for (index, high) in [Some(1.0), Some(4.0), Some(2.0)].into_iter().enumerate() {
768            let output = instance
769                .evaluate_closed(market_frame(index, high, high, high, Some(1.0)))
770                .expect("frame should evaluate");
771            if index == 2 {
772                assert_eq!(plot_value(&output, "high"), &Value::Float(4.0));
773            }
774        }
775    }
776
777    #[test]
778    fn pivots_reject_non_positive_legs_and_bounded_windows() {
779        let invalid = r#"
780            language(1)
781            indicator("Invalid pivot", id = "test.invalid-pivot")
782            format(precision = "base_ticker_min_tick")
783            plot(ta.pivothigh(chart.high, 0, 1), id = "high")
784        "#;
785        let invalid =
786            compile(invalid, "invalid-pivot.ind").expect("leg values are checked at runtime");
787        let result = invalid
788            .instantiate(IndicatorConfig::default())
789            .expect("invalid leg source should still plan")
790            .evaluate_closed(frame(0, 0, 1.0));
791        assert!(result.is_err());
792
793        let bounded = r#"
794            language(1)
795            indicator("Bounded pivot", id = "test.bounded-pivot")
796            format(precision = "base_ticker_min_tick")
797            left = input.int(2, "Left", min = 1, max = 10)
798            right = input.int(2, "Right", min = 1, max = 10)
799            plot(ta.pivotlow(chart.low, left, right), id = "low")
800        "#;
801        let compiled = compile(bounded, "bounded-pivot.ind").expect("pivot should compile");
802        let limits = RuntimeLimits {
803            max_window_length: 3,
804            ..Default::default()
805        };
806        let result = compiled
807            .instantiate_with_limits(IndicatorConfig::default(), limits)
808            .expect("bounded source should plan")
809            .evaluate_closed(frame(0, 0, 1.0));
810        assert!(result.is_err());
811    }
812
813    #[test]
814    fn regions_emit_loop_snapshots_last_write_wins_and_preview_isolated() {
815        let source = r#"
816            language(1)
817            indicator("Regions", id = "test.regions")
818            display(view = "primary_overlay")
819            plot(na, id = "anchor", display = "none", pane = "main", layer = true)
820            state threshold = chart.close
821            if chart.close > threshold {
822                for index = 0 to 1 {
823                    region(key = 7, upper = 10 + index, lower = 5,
824                           left_index = 0, right_index = bar_index,
825                           extend_right = true, id = "zones")
826                }
827            }
828            threshold := chart.close
829        "#;
830        let compiled = compile(source, "regions.ind").expect("regions should compile");
831        let mut instance = compiled
832            .instantiate(IndicatorConfig::default())
833            .expect("inputs should bind");
834        let first = instance
835            .evaluate_closed(frame(0, 0, 10.0))
836            .expect("first frame should evaluate");
837        assert!(first.regions.is_empty());
838
839        let preview = instance
840            .preview_open(frame(1, 1, 20.0))
841            .expect("preview should evaluate");
842        assert_eq!(preview.regions.len(), 1);
843        assert_eq!(preview.regions[0].key, 7);
844        assert_eq!(preview.regions[0].upper, 11.0);
845
846        let closed = instance
847            .evaluate_closed(frame(1, 1, 15.0))
848            .expect("closed frame should evaluate");
849        assert_eq!(closed.regions.len(), 1);
850        assert_eq!(closed.regions[0].upper, 11.0);
851
852        let removed = instance
853            .evaluate_closed(frame(2, 2, 5.0))
854            .expect("region removal should evaluate");
855        assert!(removed.regions.is_empty());
856    }
857
858    #[test]
859    fn persistent_regions_emit_updates_and_last_write_wins() {
860        let source = r#"
861            language(1)
862            indicator("Persistent regions", id = "test.persistent-regions")
863            display(view = "primary_overlay")
864            plot(na, id = "anchor", display = "none", pane = "main", layer = true)
865            if chart.close > 0 {
866                region(key = 7, upper = 10, lower = 5,
867                       left_index = 0, right_index = bar_index,
868                       extend_right = true, persistent = true, id = "zones")
869                region(key = 7, upper = 12, lower = 5,
870                       left_index = 0, right_index = bar_index,
871                       extend_right = false, persistent = true, id = "zones")
872                region_remove(id = "zones", key = 8)
873            }
874        "#;
875        let compiled = compile(source, "persistent-regions.ind")
876            .expect("persistent region source should compile");
877        let mut instance = compiled
878            .instantiate(IndicatorConfig::default())
879            .expect("inputs should bind");
880        let output = instance
881            .evaluate_closed(frame(0, 0, 1.0))
882            .expect("persistent region should evaluate");
883        assert_eq!(output.regions.len(), 0);
884        assert_eq!(output.region_updates.len(), 2);
885        assert!(matches!(
886            &output.region_updates[0],
887            RegionUpdate::Upsert(region)
888                if region.key == 7 && region.upper == 12.0 && !region.extend_right
889        ));
890        assert!(matches!(
891            &output.region_updates[1],
892            RegionUpdate::Remove { id, key } if id == "zones" && *key == 8
893        ));
894
895        let omitted = instance
896            .evaluate_closed(frame(1, 1, -1.0))
897            .expect("omitted persistent regions should evaluate");
898        assert!(omitted.region_updates.is_empty());
899    }
900
901    #[test]
902    fn arrays_support_tuple_storage_push_get_and_size() {
903        let source = r#"
904            language(1)
905            indicator("Array zones", id = "test.array-zones")
906            format(precision = "base_ticker_min_tick")
907            active = array.new()
908            active := array.push(active, (7, 10.0, 5.0, 3, 2))
909            active := array.push(active, (8, 20.5, 15.0, 4, 1))
910            (key, upper, lower, left, count) = array.get(active, 1)
911            plot(array.size(active), id = "size")
912            plot(upper, id = "upper")
913            plot(left, id = "left")
914            plot(count, id = "count")
915        "#;
916        let compiled = compile(source, "array-zones.ind").expect("array source should compile");
917        let mut instance = compiled
918            .instantiate(IndicatorConfig::default())
919            .expect("inputs should bind");
920        let output = instance
921            .evaluate_closed(frame(0, 0, 1.0))
922            .expect("array source should evaluate");
923        assert_eq!(plot_value(&output, "size"), &Value::Int(2));
924        assert_eq!(plot_value(&output, "upper"), &Value::Float(20.5));
925        assert_eq!(plot_value(&output, "left"), &Value::Int(4));
926        assert_eq!(plot_value(&output, "count"), &Value::Int(1));
927    }
928
929    #[test]
930    fn array_push_evaluates_side_effecting_arguments_once_when_aliased() {
931        let source = r#"
932            language(1)
933            indicator("Array alias", id = "test.array-alias")
934            format(precision = "base_ticker_min_tick")
935            active = array.new()
936            alias = active
937            active := array.push(active, (7, ta.cum(1), 0.0, 0, 0))
938            (_, upper, _, _, _) = array.get(active, 0)
939            plot(upper, id = "upper")
940        "#;
941        let compiled = compile(source, "array-alias.ind").expect("aliased array should compile");
942        let mut instance = compiled
943            .instantiate(IndicatorConfig::default())
944            .expect("inputs should bind");
945        let output = instance
946            .evaluate_closed(frame(0, 0, 1.0))
947            .expect("aliased array push should evaluate");
948        assert_eq!(plot_value(&output, "upper"), &Value::Float(1.0));
949    }
950
951    #[test]
952    fn self_pushed_arrays_obey_collection_limits() {
953        let source = r#"
954            language(1)
955            indicator("Bounded array", id = "test.bounded-array")
956            format(precision = "base_ticker_min_tick")
957            active = array.new()
958            active := array.push(active, (7, 10.0))
959            plot(array.size(active), id = "size")
960        "#;
961        let compiled = compile(source, "bounded-array.ind").expect("array source should compile");
962        let limits = RuntimeLimits {
963            max_collection_elements: 0,
964            ..RuntimeLimits::default()
965        };
966        let mut instance = compiled
967            .instantiate_with_limits(IndicatorConfig::default(), limits)
968            .expect("inputs should bind");
969        assert_eq!(
970            instance
971                .evaluate_closed(frame(0, 0, 1.0))
972                .expect_err("self-pushed array should hit the collection limit"),
973            RuntimeError::ResourceLimitExceeded("collection elements")
974        );
975    }
976
977    #[test]
978    fn large_array_reads_stay_within_untrusted_execution_limits() {
979        let source = r#"
980            language(1)
981            indicator("Large array reads", id = "test.large-array-reads")
982            format(precision = "base_ticker_min_tick")
983            values = array.new()
984            for index = 0 to 255 {
985                values := array.push(values, (index, index))
986            }
987            for index = 0 to array.size(values) - 1 {
988                (key, value) = array.get(values, index)
989            }
990            plot(array.size(values), id = "size")
991        "#;
992        let compiled =
993            compile(source, "large-array-reads.ind").expect("array source should compile");
994        let mut instance = compiled
995            .instantiate_with_limits(IndicatorConfig::default(), RuntimeLimits::untrusted())
996            .expect("inputs should bind");
997        let output = instance
998            .evaluate_closed(frame(0, 0, 1.0))
999            .expect("large array reads should remain within limits");
1000        assert_eq!(plot_value(&output, "size"), &Value::Int(256));
1001    }
1002
1003    #[test]
1004    fn flat_open_zones_emit_only_creation_and_completion_updates() {
1005        let compiled = compile(
1006            include_str!("../tests/fixtures/flat-open-zones.ind"),
1007            "flat-open-zones.ind",
1008        )
1009        .expect("flat open source should compile");
1010        let mut instance = compiled
1011            .instantiate(IndicatorConfig::default())
1012            .expect("flat open source should instantiate");
1013        let flat_frame = |index: usize, open: f64, high: f64, low: f64, close: f64| {
1014            let mut frame = frame(index, index as i64, close);
1015            frame.chart.open = Some(open);
1016            frame.chart.high = Some(high);
1017            frame.chart.low = Some(low);
1018            frame.chart.close = Some(close);
1019            frame
1020        };
1021
1022        let mut invalidated = compiled
1023            .instantiate(IndicatorConfig::default())
1024            .expect("flat open source should instantiate");
1025        assert!(
1026            invalidated
1027                .evaluate_closed(flat_frame(0, 100.0, 100.1, 99.95, 99.95))
1028                .expect("invalidated setup bar should evaluate")
1029                .region_updates
1030                .is_empty()
1031        );
1032        assert!(
1033            invalidated
1034                .evaluate_closed(flat_frame(1, 100.0, 101.0, 99.9, 101.0))
1035                .expect("invalidated creation bar should evaluate")
1036                .region_updates
1037                .is_empty()
1038        );
1039
1040        assert!(
1041            instance
1042                .evaluate_closed(flat_frame(0, 100.0, 101.0, 99.0, 100.0))
1043                .expect("first bar should evaluate")
1044                .region_updates
1045                .is_empty()
1046        );
1047        let created = instance
1048            .evaluate_closed(flat_frame(1, 100.0, 101.0, 99.9, 101.0))
1049            .expect("creation bar should evaluate");
1050        assert!(matches!(
1051            created.region_updates.as_ref(),
1052            [RegionUpdate::Upsert(region)] if region.left_index == 1 && region.extend_right
1053        ));
1054        for index in 2..4 {
1055            assert!(
1056                instance
1057                    .evaluate_closed(flat_frame(index, 100.5, 102.0, 99.5, 99.5))
1058                    .expect("inside bar should evaluate")
1059                    .region_updates
1060                    .is_empty()
1061            );
1062        }
1063        let completed = instance
1064            .evaluate_closed(flat_frame(4, 100.5, 102.0, 99.5, 99.5))
1065            .expect("completion bar should evaluate");
1066        assert!(matches!(
1067            completed.region_updates.as_ref(),
1068            [RegionUpdate::Remove { id, .. }] if id == "flat_open_zones"
1069        ));
1070    }
1071
1072    #[test]
1073    #[ignore = "slow 10k-bar history stress test; run explicitly"]
1074    fn flat_open_zones_handle_dense_10k_history_under_untrusted_limits() {
1075        let compiled = compile(
1076            include_str!("../tests/fixtures/flat-open-zones.ind"),
1077            "flat-open-zones.ind",
1078        )
1079        .expect("flat open source should compile");
1080        let mut instance = compiled
1081            .instantiate_with_limits(IndicatorConfig::default(), RuntimeLimits::untrusted())
1082            .expect("flat open source should instantiate");
1083        for index in 0..10_000 {
1084            let open = 100.0 + index as f64 * 0.2;
1085            let mut bar = frame(index, index as i64, open + 0.5);
1086            bar.chart.open = Some(open);
1087            bar.chart.high = Some(open + 1.0);
1088            bar.chart.low = Some(open - 0.1);
1089            bar.chart.close = Some(open + 0.5);
1090            instance
1091                .evaluate_closed(bar)
1092                .expect("dense flat-open history should remain within limits");
1093        }
1094    }
1095
1096    #[test]
1097    #[ignore = "slow 10k-bar history stress test; run explicitly"]
1098    fn volume_pivot_zones_handle_dense_10k_history_under_untrusted_limits() {
1099        let compiled = compile(
1100            include_str!("../tests/fixtures/volume-pivot-zones.ind"),
1101            "volume-pivot-zones.ind",
1102        )
1103        .expect("volume pivot source should compile");
1104        let mut instance = compiled
1105            .instantiate_with_limits(IndicatorConfig::default(), RuntimeLimits::untrusted())
1106            .expect("volume pivot source should instantiate");
1107        for index in 0..10_000 {
1108            let base = 100.0 + index as f64 * 0.2;
1109            instance
1110                .evaluate_closed(market_frame(
1111                    index,
1112                    Some(base + 1.0),
1113                    Some(base),
1114                    Some(base + 0.75),
1115                    Some(if index % 11 == 5 { 1_000.0 } else { 1.0 }),
1116                ))
1117                .expect("dense volume-pivot history should remain within limits");
1118        }
1119    }
1120
1121    #[test]
1122    fn flat_open_zones_remove_oldest_active_zones_at_the_configured_cap() {
1123        let compiled = compile(
1124            include_str!("../tests/fixtures/flat-open-zones.ind"),
1125            "flat-open-zones.ind",
1126        )
1127        .expect("flat open source should compile");
1128        let mut instance = compiled
1129            .instantiate_with_limits(
1130                IndicatorConfig::default().with("max_active_zones", InputValue::Int(16)),
1131                RuntimeLimits::untrusted(),
1132            )
1133            .expect("flat open source should instantiate");
1134        let mut removed = false;
1135        for index in 0..64 {
1136            let open = 100.0 + index as f64 * 0.2;
1137            let mut bar = frame(index, index as i64, open + 0.5);
1138            bar.chart.open = Some(open);
1139            bar.chart.high = Some(open + 1.0);
1140            bar.chart.low = Some(open - 0.1);
1141            bar.chart.close = Some(open + 0.5);
1142            let output = instance
1143                .evaluate_closed(bar)
1144                .expect("capped flat-open history should evaluate");
1145            removed |= output
1146                .region_updates
1147                .iter()
1148                .any(|update| matches!(update, RegionUpdate::Remove { .. }));
1149        }
1150        assert!(removed, "the active-zone cap should retire old regions");
1151    }
1152
1153    #[test]
1154    fn flat_open_zones_apply_one_cap_across_bullish_and_bearish_zones() {
1155        let compiled = compile(
1156            include_str!("../tests/fixtures/flat-open-zones.ind"),
1157            "flat-open-zones.ind",
1158        )
1159        .expect("flat open source should compile");
1160        let mut instance = compiled
1161            .instantiate_with_limits(
1162                IndicatorConfig::default().with("max_active_zones", InputValue::Int(16)),
1163                RuntimeLimits::untrusted(),
1164            )
1165            .expect("flat open source should instantiate");
1166        let mut removed = false;
1167        let mut first = frame(0, 0, 100.0);
1168        first.chart.open = Some(100.0);
1169        first.chart.high = Some(100.1);
1170        first.chart.low = Some(99.95);
1171        first.chart.close = Some(100.0);
1172        instance
1173            .evaluate_closed(first)
1174            .expect("setup bar should evaluate");
1175        for index in 1..24 {
1176            let bullish = index % 2 == 1;
1177            let mut bar = frame(index, index as i64, 100.0);
1178            bar.chart.open = Some(100.0);
1179            bar.chart.high = Some(100.1);
1180            bar.chart.low = Some(if bullish { 100.0 } else { 99.95 });
1181            bar.chart.close = Some(if bullish { 100.05 } else { 99.95 });
1182            let output = instance
1183                .evaluate_closed(bar)
1184                .expect("mixed flat open should evaluate");
1185            removed |= output
1186                .region_updates
1187                .iter()
1188                .any(|update| matches!(update, RegionUpdate::Remove { .. }));
1189        }
1190        assert!(
1191            removed,
1192            "the global active-zone cap should retire old regions"
1193        );
1194    }
1195
1196    #[test]
1197    fn volume_pivot_zones_remove_oldest_active_zones_at_the_configured_cap() {
1198        let compiled = compile(
1199            include_str!("../tests/fixtures/volume-pivot-zones.ind"),
1200            "volume-pivot-zones.ind",
1201        )
1202        .expect("volume pivot source should compile");
1203        let mut instance = compiled
1204            .instantiate_with_limits(
1205                IndicatorConfig::default().with("max_active_zones", InputValue::Int(16)),
1206                RuntimeLimits::untrusted(),
1207            )
1208            .expect("volume pivot source should instantiate");
1209        let mut removed = false;
1210        for index in 0..300 {
1211            let base = 100.0 + index as f64 * 0.2;
1212            let output = instance
1213                .evaluate_closed(market_frame(
1214                    index,
1215                    Some(base + 1.0),
1216                    Some(base),
1217                    Some(base + 0.75),
1218                    Some(if index % 11 == 5 { 1_000.0 } else { 1.0 }),
1219                ))
1220                .expect("dense volume-pivot history should evaluate");
1221            removed |= output
1222                .region_updates
1223                .iter()
1224                .any(|update| matches!(update, RegionUpdate::Remove { .. }));
1225        }
1226        assert!(removed, "the active-zone cap should retire old regions");
1227    }
1228
1229    #[test]
1230    fn history_indexing_reads_an_immutable_series_local() {
1231        let source = r#"
1232            language(1)
1233            indicator("Lagged value", id = "test.lagged")
1234            format(precision = "base_ticker_min_tick")
1235            value = chart.close * 2
1236            plot(value[1], id = "lagged")
1237        "#;
1238        let compiled = compile(source, "lagged.ind").expect("source should compile");
1239        let mut instance = compiled
1240            .instantiate(IndicatorConfig::default())
1241            .expect("inputs should bind");
1242
1243        assert_eq!(
1244            plot_value(
1245                &instance
1246                    .evaluate_closed(frame(0, 1, 10.0))
1247                    .expect("first frame should evaluate"),
1248                "lagged"
1249            ),
1250            &Value::Na
1251        );
1252        assert_eq!(
1253            plot_value(
1254                &instance
1255                    .evaluate_closed(frame(1, 2, 20.0))
1256                    .expect("second frame should evaluate"),
1257                "lagged"
1258            ),
1259            &Value::Float(20.0)
1260        );
1261    }
1262
1263    #[test]
1264    fn history_indexing_rejects_unretained_sources() {
1265        for source in [
1266            r#"
1267                language(1)
1268                indicator("Mutable history", id = "test.mutable-history")
1269                format(precision = "base_ticker_min_tick")
1270                value = chart.close
1271                value := value
1272                plot(value[1], id = "lagged")
1273            "#,
1274            r#"
1275                language(1)
1276                indicator("Parameter history", id = "test.parameter-history")
1277                format(precision = "base_ticker_min_tick")
1278                plot(lag(chart.close), id = "value")
1279                fn lag(value) {
1280                    return value[1]
1281                }
1282            "#,
1283            r#"
1284                language(1)
1285                indicator("Call history", id = "test.call-history")
1286                format(precision = "base_ticker_min_tick")
1287                plot(identity(chart.close)[1], id = "lagged")
1288                fn identity(value) {
1289                    return value
1290                }
1291            "#,
1292        ] {
1293            let diagnostics = compile(source, "unretained-history.ind")
1294                .expect_err("unretained history should fail compilation");
1295            assert!(
1296                diagnostics
1297                    .iter()
1298                    .any(|diagnostic| { diagnostic.message.contains("immutable retained series") })
1299            );
1300        }
1301    }
1302
1303    #[test]
1304    fn history_validation_is_order_independent_for_local_mutations() {
1305        for source in [
1306            r#"
1307                language(1)
1308                indicator("Later mutation", id = "test.later-mutation")
1309                format(precision = "base_ticker_min_tick")
1310                value = chart.close
1311                plot(value[1], id = "lagged")
1312                value := value
1313            "#,
1314            r#"
1315                language(1)
1316                indicator("Branch mutation", id = "test.branch-mutation")
1317                format(precision = "base_ticker_min_tick")
1318                value = chart.close
1319                plot(value[1], id = "lagged")
1320                if true {
1321                    value := value
1322                }
1323            "#,
1324            r#"
1325                language(1)
1326                indicator("Destructure mutation", id = "test.destructure-mutation")
1327                format(precision = "base_ticker_min_tick")
1328                value = chart.close
1329                other = chart.open
1330                plot(value[1], id = "lagged")
1331                (value, other) := (value, other)
1332            "#,
1333            r#"
1334                language(1)
1335                indicator("Loop mutation", id = "test.loop-mutation")
1336                format(precision = "base_ticker_min_tick")
1337                value = chart.close
1338                plot(value[1], id = "lagged")
1339                for index = 0 to 1 {
1340                    value := value
1341                }
1342            "#,
1343            r#"
1344                language(1)
1345                indicator("Loop target", id = "test.loop-target")
1346                format(precision = "base_ticker_min_tick")
1347                for index = 0 to 1 {
1348                    lagged = index[1]
1349                }
1350            "#,
1351        ] {
1352            let diagnostics = compile(source, "order-independent-history.ind")
1353                .expect_err("mutable history should fail compilation");
1354            assert!(
1355                diagnostics
1356                    .iter()
1357                    .any(|diagnostic| diagnostic.message.contains("immutable retained series"))
1358            );
1359        }
1360    }
1361
1362    #[test]
1363    fn parser_rejects_deep_input_without_recursing() {
1364        let deeply_nested = [
1365            "(".repeat(257) + "chart.close" + &")".repeat(257),
1366            "+".repeat(257) + "chart.close",
1367            "chart.close".to_owned() + &" + chart.close".repeat(257),
1368            "(chart.close)".to_owned() + &".field".repeat(257),
1369            "chart.close".to_owned() + &"[0]".repeat(257),
1370            {
1371                let mut expression = "chart.close".to_owned();
1372                for _ in 0..257 {
1373                    expression = format!("chart.close + ({expression})");
1374                }
1375                expression
1376            },
1377            {
1378                let mut expression = "chart.close".to_owned();
1379                for _ in 0..257 {
1380                    expression = format!("math.abs({expression})");
1381                }
1382                expression
1383            },
1384            {
1385                let mut expression = "chart.close".to_owned();
1386                for _ in 0..257 {
1387                    expression = format!("[{expression}]");
1388                }
1389                expression
1390            },
1391            {
1392                let mut expression = "chart.close".to_owned();
1393                for _ in 0..257 {
1394                    expression = format!("switch chart.close {{ default => {expression} }}");
1395                }
1396                expression
1397            },
1398        ];
1399        for (index, expression) in deeply_nested.iter().enumerate() {
1400            let source = format!(
1401                "language(1)\nindicator(\"Deep {index}\", id = \"test.deep-{index}\")\nformat(precision = \"base_ticker_min_tick\")\nplot({expression}, id = \"value\")"
1402            );
1403            let diagnostics =
1404                compile(&source, "deep-parser.ind").expect_err("deep source should be rejected");
1405            assert!(
1406                diagnostics
1407                    .iter()
1408                    .any(|diagnostic| diagnostic.message.contains("parser nesting limit")),
1409                "missing nesting diagnostic for expression {index}: {diagnostics:?}"
1410            );
1411        }
1412
1413        let mut source = String::from(
1414            "language(1)\nindicator(\"Deep statements\", id = \"test.deep-statements\")\nformat(precision = \"base_ticker_min_tick\")\n",
1415        );
1416        for _ in 0..257 {
1417            source.push_str("if true { ");
1418        }
1419        source.push_str("plot(chart.close, id = \"value\")");
1420        for _ in 0..257 {
1421            source.push_str(" }");
1422        }
1423        let diagnostics = compile(&source, "deep-statements.ind")
1424            .expect_err("deep statements should be rejected");
1425        assert!(
1426            diagnostics
1427                .iter()
1428                .any(|diagnostic| diagnostic.message.contains("parser nesting limit"))
1429        );
1430    }
1431
1432    #[test]
1433    fn compile_limits_reject_large_sources_and_variadic_underflow() {
1434        let diagnostics = compile_with_limits(
1435            "language(1)",
1436            "large-source.ind",
1437            CompileLimits {
1438                max_source_bytes: 4,
1439                ..CompileLimits::default()
1440            },
1441        )
1442        .expect_err("source limit should be enforced before lexing");
1443        assert!(
1444            diagnostics[0]
1445                .message
1446                .contains("source exceeds compile limit")
1447        );
1448
1449        for source in [
1450            r#"
1451                language(1)
1452                indicator("Missing arguments", id = "test.missing-arguments")
1453                format(precision = "base_ticker_min_tick")
1454                plot(math.max(), id = "value")
1455            "#,
1456            r#"
1457                language(1)
1458                indicator("Missing color", id = "test.missing-color")
1459                format(precision = "base_ticker_min_tick")
1460                plot(chart.close, id = "value", color = color.rgb(1, 2))
1461            "#,
1462        ] {
1463            let diagnostics = compile(source, "variadic-underflow.ind")
1464                .expect_err("variadic minimum arity should be diagnosed");
1465            assert!(
1466                diagnostics
1467                    .iter()
1468                    .any(|diagnostic| { diagnostic.message.contains("at least") })
1469            );
1470        }
1471    }
1472
1473    #[test]
1474    fn source_inputs_support_history_ta_and_host_overrides() {
1475        let source = r#"
1476            language(1)
1477            indicator("Source input", id = "test.source-input", sourceable = true)
1478            display(attachment = "panel")
1479            format(precision = "base_ticker_min_tick")
1480            source = input.source(chart.close, "Source")
1481            plot(source, id = "current")
1482            plot(source[1], id = "lagged")
1483            plot(ta.sma(source, 2), id = "average")
1484        "#;
1485        let compiled = compile(source, "source-input.ind").expect("source input should compile");
1486        assert!(compiled.descriptor.sourceable);
1487        assert_eq!(compiled.descriptor.series_inputs.len(), 1);
1488        assert_eq!(compiled.descriptor.series_inputs[0].id, "source");
1489        assert_eq!(compiled.descriptor.series_inputs[0].label, "Source");
1490        assert_eq!(
1491            compiled.descriptor.series_inputs[0].default_field,
1492            FrameField::ChartClose
1493        );
1494        assert!(compiled.descriptor.series_inputs[0].allow_indicator);
1495        assert_eq!(
1496            compiled.descriptor.presentation.attachment,
1497            Some(PresentationAttachmentMode::Panel)
1498        );
1499        assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(2));
1500        let mut instance = compiled
1501            .instantiate(IndicatorConfig::default())
1502            .expect("source input should bind");
1503
1504        let first = instance
1505            .evaluate_closed(frame(0, 1, 10.0))
1506            .expect("first frame should evaluate");
1507        assert_eq!(plot_value(&first, "current"), &Value::Float(10.0));
1508        assert_eq!(plot_value(&first, "lagged"), &Value::Na);
1509        assert_eq!(plot_value(&first, "average"), &Value::Na);
1510
1511        let second = instance
1512            .evaluate_closed(frame(1, 2, 20.0))
1513            .expect("fallback frame should evaluate");
1514        assert_eq!(plot_value(&second, "current"), &Value::Float(20.0));
1515        assert_eq!(plot_value(&second, "lagged"), &Value::Float(10.0));
1516        assert_eq!(plot_value(&second, "average"), &Value::Float(15.0));
1517
1518        let mut bound = frame(2, 3, 30.0);
1519        bound.series_inputs = vec![SeriesInputValue::Value(Some(100.0))].into();
1520        let bound = instance
1521            .evaluate_closed(bound)
1522            .expect("bound frame should evaluate");
1523        assert_eq!(plot_value(&bound, "current"), &Value::Float(100.0));
1524        assert_eq!(plot_value(&bound, "lagged"), &Value::Float(20.0));
1525        assert_eq!(plot_value(&bound, "average"), &Value::Float(60.0));
1526
1527        let mut unavailable = frame(3, 4, 40.0);
1528        unavailable.series_inputs = vec![SeriesInputValue::Value(None)].into();
1529        let unavailable = instance
1530            .evaluate_closed(unavailable)
1531            .expect("unavailable frame should evaluate");
1532        assert_eq!(plot_value(&unavailable, "current"), &Value::Na);
1533        assert_eq!(plot_value(&unavailable, "lagged"), &Value::Float(100.0));
1534        assert_eq!(plot_value(&unavailable, "average"), &Value::Na);
1535
1536        let mut selected = frame(4, 5, 50.0);
1537        selected.chart.high = Some(9.0);
1538        selected.chart.low = Some(3.0);
1539        selected.series_inputs = vec![SeriesInputValue::Base(BaseSeriesSource::Hl2)].into();
1540        let selected = instance
1541            .evaluate_closed(selected)
1542            .expect("base source selection should evaluate");
1543        assert_eq!(plot_value(&selected, "current"), &Value::Float(6.0));
1544    }
1545
1546    #[test]
1547    fn chart_composite_sources_compile_and_evaluate() {
1548        let source = r#"
1549            language(1)
1550            indicator("Composite sources", id = "test.composite-sources")
1551            display(attachment = "panel")
1552            format(precision = "base_ticker_min_tick")
1553            source = input.source(chart.hlc3, "Source")
1554            plot(chart.hl2, id = "hl2")
1555            plot(chart.hlc3, id = "hlc3")
1556            plot(chart.ohlc4, id = "ohlc4")
1557            plot(chart.hlcc4, id = "hlcc4")
1558            plot(source, id = "source")
1559        "#;
1560        let compiled = compile(source, "composite-sources.ind")
1561            .expect("composite chart sources should compile");
1562        assert!(compiled.requirements.ohlc);
1563        assert_eq!(compiled.descriptor.series_inputs.len(), 1);
1564        assert_eq!(compiled.descriptor.series_inputs[0].id, "source");
1565        assert_eq!(compiled.descriptor.series_inputs[0].label, "Source");
1566        assert_eq!(
1567            compiled.descriptor.series_inputs[0].default_field,
1568            FrameField::ChartHlc3
1569        );
1570
1571        let mut complete = frame(0, 1, 5.0);
1572        complete.chart.open = Some(1.0);
1573        complete.chart.high = Some(8.0);
1574        complete.chart.low = Some(3.0);
1575        let mut instance = compiled
1576            .instantiate(IndicatorConfig::default())
1577            .expect("composite source instance should bind");
1578        let output = instance
1579            .evaluate_closed(complete)
1580            .expect("complete composite frame should evaluate");
1581        assert_eq!(plot_value(&output, "hl2"), &Value::Float(5.5));
1582        assert_eq!(plot_value(&output, "hlc3"), &Value::Float(16.0 / 3.0));
1583        assert_eq!(plot_value(&output, "ohlc4"), &Value::Float(17.0 / 4.0));
1584        assert_eq!(plot_value(&output, "hlcc4"), &Value::Float(21.0 / 4.0));
1585        assert_eq!(plot_value(&output, "source"), &Value::Float(16.0 / 3.0));
1586
1587        let mut incomplete = frame(1, 2, 5.0);
1588        incomplete.chart.high = Some(8.0);
1589        incomplete.chart.low = None;
1590        let output = instance
1591            .evaluate_closed(incomplete)
1592            .expect("incomplete composite frame should evaluate");
1593        assert_eq!(plot_value(&output, "hl2"), &Value::Na);
1594        assert_eq!(plot_value(&output, "hlc3"), &Value::Na);
1595        assert_eq!(plot_value(&output, "ohlc4"), &Value::Na);
1596        assert_eq!(plot_value(&output, "hlcc4"), &Value::Na);
1597        assert_eq!(plot_value(&output, "source"), &Value::Na);
1598
1599        let mut nonfinite = frame(2, 3, 5.0);
1600        nonfinite.chart.high = Some(f64::NAN);
1601        let output = instance
1602            .evaluate_closed(nonfinite)
1603            .expect("non-finite composite frame should evaluate");
1604        assert_eq!(plot_value(&output, "source"), &Value::Na);
1605    }
1606
1607    #[test]
1608    fn source_input_requires_a_direct_chart_series_default() {
1609        let diagnostics = compile(
1610            r#"
1611                language(1)
1612                indicator("Invalid source", id = "test.invalid-source")
1613                display(attachment = "panel")
1614                format(precision = "base_ticker_min_tick")
1615                source = input.source(chart.close + 1, "Source")
1616                plot(source, id = "value")
1617            "#,
1618            "invalid-source.ind",
1619        )
1620        .expect_err("source defaults must be direct chart fields");
1621        assert!(diagnostics.iter().any(|diagnostic| {
1622            diagnostic
1623                .message
1624                .contains("input.source default must be chart.open")
1625        }));
1626    }
1627
1628    #[test]
1629    fn source_policies_map_and_allow_multiple_engine_inputs() {
1630        let policy = compile(
1631            r#"
1632                language(1)
1633                indicator("Policies", id = "test.policies", sourceable = false)
1634                display(attachment = "panel")
1635                format(precision = "base_ticker_min_tick")
1636                base = input.source(chart.close, "Base", allow_indicator = false)
1637                source = input.source(chart.close, "Source")
1638                plot(base + source, id = "value")
1639            "#,
1640            "policies.ind",
1641        )
1642        .expect("source policies should compile");
1643        assert!(!policy.descriptor.sourceable);
1644        assert_eq!(policy.descriptor.series_inputs.len(), 2);
1645        assert!(!policy.descriptor.series_inputs[0].allow_indicator);
1646        assert!(policy.descriptor.series_inputs[1].allow_indicator);
1647
1648        let multiple = compile(
1649            r#"
1650                language(1)
1651                indicator("Multiple sources", id = "test.multiple-sources")
1652                display(attachment = "panel")
1653                format(precision = "base_ticker_min_tick")
1654                left = input.source(chart.close, "Left")
1655                right = input.source(chart.open, "Right")
1656                plot(left - right, id = "value")
1657            "#,
1658            "multiple-sources.ind",
1659        )
1660        .expect("multiple source inputs should compile");
1661        assert_eq!(multiple.descriptor.series_inputs.len(), 2);
1662        assert!(
1663            multiple
1664                .descriptor
1665                .series_inputs
1666                .iter()
1667                .all(|input| input.allow_indicator)
1668        );
1669    }
1670
1671    #[test]
1672    fn sourceable_defaults_to_false_and_requires_visible_numeric_output() {
1673        let default = compile(
1674            r#"
1675                language(1)
1676                indicator("Default source policy", id = "test.default-source-policy")
1677                format(precision = "base_ticker_min_tick")
1678                plot(chart.close, id = "value")
1679            "#,
1680            "default-source-policy.ind",
1681        )
1682        .expect("default source policy should compile");
1683        assert!(!default.descriptor.sourceable);
1684        assert!(default.descriptor.series_inputs.is_empty());
1685
1686        let hidden = compile(
1687            r#"
1688                language(1)
1689                indicator("Private source", id = "test.private-source", sourceable = true)
1690                display(view = "primary_overlay")
1691                plot(na, id = "private", pane = "main", layer = false)
1692            "#,
1693            "private-source.ind",
1694        )
1695        .expect_err("sourceable indicators need a visible numeric output");
1696        assert!(hidden.iter().any(|diagnostic| {
1697            diagnostic
1698                .message
1699                .contains("sourceable = true requires at least one visible numeric plot layer")
1700        }));
1701    }
1702
1703    #[test]
1704    fn base_only_source_inputs_can_use_non_plot_views() {
1705        let table = compile(
1706            r#"
1707                language(1)
1708                indicator("Base table", id = "test.base-table")
1709                display(view = "table")
1710                source = input.source(chart.close, "Source", allow_indicator = false)
1711                plot(source, id = "value", layer = false)
1712                column(title = "Value", source = "value")
1713            "#,
1714            "base-table.ind",
1715        )
1716        .expect("base-only table source should compile");
1717        assert_eq!(
1718            table.descriptor.presentation.view,
1719            PresentationViewKind::Table
1720        );
1721
1722        let overlay = compile(
1723            r#"
1724                language(1)
1725                indicator("Base overlay", id = "test.base-overlay")
1726                display(view = "primary_overlay")
1727                source = input.source(chart.close, "Source", allow_indicator = false)
1728                plot(source, id = "value", pane = "main")
1729            "#,
1730            "base-overlay.ind",
1731        )
1732        .expect("base-only primary overlay source should compile");
1733        assert_eq!(
1734            overlay.descriptor.presentation.view,
1735            PresentationViewKind::PrimaryOverlay
1736        );
1737    }
1738
1739    #[test]
1740    fn indicator_enabled_source_inputs_require_plot_views_and_visible_numeric_layers() {
1741        let table = compile(
1742            r#"
1743                language(1)
1744                indicator("Invalid table", id = "test.invalid-table")
1745                display(view = "table")
1746                source = input.source(chart.close, "Source")
1747                plot(source, id = "value", layer = false)
1748                column(title = "Value", source = "value")
1749            "#,
1750            "invalid-table.ind",
1751        )
1752        .expect_err("indicator-enabled table source should fail");
1753        assert!(table.iter().any(|diagnostic| {
1754            diagnostic
1755                .message
1756                .contains("allow_indicator = true requires display(view = \"plot\")")
1757        }));
1758        assert!(table.iter().any(|diagnostic| {
1759            diagnostic
1760                .message
1761                .contains("requires at least one visible numeric plot layer")
1762        }));
1763
1764        let overlay = compile(
1765            r#"
1766                language(1)
1767                indicator("Invalid overlay", id = "test.invalid-overlay")
1768                display(view = "primary_overlay")
1769                source = input.source(chart.close, "Source")
1770                plot(source, id = "value", pane = "main")
1771            "#,
1772            "invalid-overlay.ind",
1773        )
1774        .expect_err("indicator-enabled primary overlay source should fail");
1775        assert!(overlay.iter().any(|diagnostic| {
1776            diagnostic
1777                .message
1778                .contains("allow_indicator = true requires display(view = \"plot\")")
1779        }));
1780    }
1781
1782    #[test]
1783    fn source_indicators_require_an_explicit_attachment_mode() {
1784        let overlay = compile(
1785            r#"
1786                language(1)
1787                indicator("Overlay source", id = "test.overlay-source")
1788                display(attachment = "overlay")
1789                format(precision = "base_ticker_min_tick")
1790                source = input.source(chart.close, "Source")
1791                plot(source, id = "value")
1792            "#,
1793            "overlay-source.ind",
1794        )
1795        .expect("overlay source should compile");
1796        assert_eq!(
1797            overlay.descriptor.presentation.attachment,
1798            Some(PresentationAttachmentMode::Overlay)
1799        );
1800
1801        let missing = compile(
1802            r#"
1803                language(1)
1804                indicator("Missing attachment", id = "test.missing-attachment")
1805                format(precision = "base_ticker_min_tick")
1806                source = input.source(chart.close, "Source")
1807                plot(source, id = "value")
1808            "#,
1809            "missing-attachment.ind",
1810        )
1811        .expect_err("source indicators must declare an attachment mode");
1812        assert!(missing.iter().any(|diagnostic| {
1813            diagnostic
1814                .message
1815                .contains("display must declare attachment")
1816        }));
1817
1818        let invalid = compile(
1819            r#"
1820                language(1)
1821                indicator("Invalid attachment", id = "test.invalid-attachment")
1822                display(attachment = "split")
1823                format(precision = "base_ticker_min_tick")
1824                source = input.source(chart.close, "Source")
1825                plot(source, id = "value")
1826            "#,
1827            "invalid-attachment.ind",
1828        )
1829        .expect_err("unknown attachment modes must be rejected");
1830        assert!(
1831            invalid
1832                .iter()
1833                .any(|diagnostic| { diagnostic.message.contains("unknown attachment split") })
1834        );
1835    }
1836
1837    #[test]
1838    fn named_panes_and_numeric_layer_targets_are_validated_by_the_compiler() {
1839        let named = compile(
1840            r#"
1841                language(1)
1842                indicator("Named", id = "test.named")
1843                format(precision = "base_ticker_min_tick")
1844                plot(chart.close, id = "value", pane = "named:7")
1845            "#,
1846            "named.ind",
1847        )
1848        .expect("named pane should compile");
1849        assert_eq!(named.descriptor.plots[0].pane, PaneTarget::Named(7));
1850        assert_eq!(
1851            named.descriptor.presentation.layers[0].pane,
1852            PaneTarget::Named(7)
1853        );
1854
1855        let primary_overlay = compile(
1856            r#"
1857                language(1)
1858                indicator("Overlay", id = "test.named-overlay")
1859                display(view = "primary_overlay")
1860                plot(chart.close, id = "value", pane = "named:7")
1861            "#,
1862            "named-overlay.ind",
1863        )
1864        .expect_err("primary overlays must target the main pane");
1865        assert!(primary_overlay.iter().any(|diagnostic| {
1866            diagnostic.message.contains("primary_overlay")
1867                && diagnostic.message.contains("pane main")
1868        }));
1869
1870        let mixed = compile(
1871            r#"
1872                language(1)
1873                indicator("Mixed", id = "test.mixed")
1874                format(precision = "base_ticker_min_tick")
1875                plot(chart.close, id = "main", pane = "main")
1876                plot(chart.close, id = "indicator", pane = "indicator")
1877            "#,
1878            "mixed.ind",
1879        )
1880        .expect_err("numeric layers must share a pane target");
1881        assert!(mixed.iter().any(|diagnostic| {
1882            diagnostic
1883                .message
1884                .contains("all numeric plot layers in one indicator must use the same pane")
1885        }));
1886    }
1887
1888    #[test]
1889    fn preview_restarts_from_the_last_committed_state() {
1890        let source = r#"
1891            language(1)
1892            indicator("Committed state", id = "test.state")
1893            format(precision = "base_ticker_min_tick")
1894            state total = 0
1895            total := total + chart.close
1896            plot(total, id = "total")
1897        "#;
1898        let compiled = compile(source, "state.ind").expect("source should compile");
1899        let mut instance = compiled
1900            .instantiate(IndicatorConfig::default())
1901            .expect("inputs should bind");
1902        instance
1903            .evaluate_closed(frame(0, 1, 1.0))
1904            .expect("first frame should evaluate");
1905        instance
1906            .evaluate_closed(frame(1, 2, 2.0))
1907            .expect("second frame should evaluate");
1908
1909        let first_preview = instance
1910            .preview_open(frame(2, 3, 10.0))
1911            .expect("preview should evaluate");
1912        let second_preview = instance
1913            .preview_open(frame(2, 3, 20.0))
1914            .expect("preview should evaluate");
1915        assert_eq!(plot_value(&first_preview, "total"), &Value::Float(13.0));
1916        assert_eq!(plot_value(&second_preview, "total"), &Value::Float(23.0));
1917
1918        let committed = instance
1919            .evaluate_closed(frame(2, 3, 20.0))
1920            .expect("final open frame should commit");
1921        assert_eq!(plot_value(&committed, "total"), &Value::Float(23.0));
1922    }
1923
1924    #[test]
1925    fn cumulative_volume_skips_missing_directional_samples() {
1926        let source = r#"
1927            language(1)
1928            indicator("CVD", id = "test.cvd")
1929            format(precision = "base_ticker_min_qty")
1930            delta = select(chart.directional_volume_available, chart.buy_qty - chart.sell_qty, na)
1931            value = ta.cum(delta)
1932            plot(value, id = "value")
1933        "#;
1934        let compiled = compile(source, "cvd.ind").expect("source should compile");
1935        let mut instance = compiled
1936            .instantiate(IndicatorConfig::default())
1937            .expect("inputs should bind");
1938
1939        let mut first = frame(0, 1, 10.0);
1940        first.chart.directional_volume_available = true;
1941        first.chart.buy_qty = 5.0;
1942        first.chart.sell_qty = 2.0;
1943        let first_output = instance
1944            .evaluate_closed(first)
1945            .expect("first directional frame should evaluate");
1946        assert_eq!(plot_value(&first_output, "value"), &Value::Float(3.0));
1947
1948        let mut second = frame(1, 2, 10.0);
1949        second.chart.directional_volume_available = true;
1950        second.chart.buy_qty = 4.0;
1951        second.chart.sell_qty = 1.0;
1952        let second_output = instance
1953            .evaluate_closed(second)
1954            .expect("second directional frame should evaluate");
1955        assert_eq!(plot_value(&second_output, "value"), &Value::Float(6.0));
1956
1957        let missing_output = instance
1958            .evaluate_closed(frame(2, 3, 10.0))
1959            .expect("missing directional frame should evaluate");
1960        assert_eq!(plot_value(&missing_output, "value"), &Value::Na);
1961
1962        let mut third = frame(3, 4, 10.0);
1963        third.chart.directional_volume_available = true;
1964        third.chart.buy_qty = 3.0;
1965        third.chart.sell_qty = 1.0;
1966        let third_output = instance
1967            .evaluate_closed(third)
1968            .expect("directional frame after gap should evaluate");
1969        assert_eq!(plot_value(&third_output, "value"), &Value::Float(8.0));
1970
1971        let mut fourth = frame(4, 5, 10.0);
1972        fourth.chart.directional_volume_available = true;
1973        fourth.chart.buy_qty = 2.0;
1974        fourth.chart.sell_qty = 1.0;
1975        let fourth_output = instance
1976            .evaluate_closed(fourth)
1977            .expect("directional frame should resume after gap");
1978        assert_eq!(plot_value(&fourth_output, "value"), &Value::Float(9.0));
1979    }
1980
1981    #[test]
1982    fn optional_trade_inputs_fall_back_to_candle_volume() {
1983        let source = r#"
1984            language(1)
1985            indicator("Volume", id = "test.volume", optional_trades = true)
1986            format(precision = "base_ticker_min_qty")
1987            has_directional_volume = chart.directional_volume_available
1988            buy = select(has_directional_volume, chart.buy_qty, chart.volume / 2)
1989            sell = select(has_directional_volume, chart.sell_qty, chart.volume / 2)
1990            signed = select(has_directional_volume, buy - sell, na)
1991            plot(buy, id = "buy")
1992            plot(signed, id = "signed")
1993        "#;
1994        let compiled = compile(source, "volume.ind").expect("source should compile");
1995        assert!(compiled.descriptor.optional_trades);
1996        assert!(compiled.requirements.needs_directional_volume());
1997        assert!(!compiled.requirements.trades);
1998        let mut instance = compiled
1999            .instantiate(IndicatorConfig::default())
2000            .expect("inputs should bind");
2001
2002        let output = instance
2003            .evaluate_closed(frame(0, 1, 10.0))
2004            .expect("candle-only frame should evaluate");
2005        assert_eq!(plot_value(&output, "buy"), &Value::Float(0.5));
2006        assert_eq!(plot_value(&output, "signed"), &Value::Na);
2007
2008        let mut flow_frame = frame(1, 2, 10.0);
2009        flow_frame.chart.directional_volume_available = true;
2010        flow_frame.chart.buy_qty = 3.0;
2011        flow_frame.chart.trade = Some(TradeAggregate {
2012            buy_qty: 3.0,
2013            sell_qty: 0.0,
2014            buy_count: 2,
2015            sell_count: 0,
2016        });
2017        let output = instance
2018            .evaluate_closed(flow_frame)
2019            .expect("trade frame should evaluate");
2020        assert_eq!(plot_value(&output, "buy"), &Value::Float(3.0));
2021        assert_eq!(plot_value(&output, "signed"), &Value::Float(3.0));
2022    }
2023
2024    #[test]
2025    fn chart_trade_fields_use_the_base_chart() {
2026        let source = r#"
2027            language(1)
2028            indicator("Base trades", id = "test.base-trades")
2029            format(precision = "base_ticker_min_qty")
2030            plot(chart.trade.available, id = "chart_available")
2031            plot(chart.trade.buy_qty, id = "chart_buy")
2032            plot(chart.trade.sell_qty, id = "chart_sell")
2033            plot(chart.trade.buy_count, id = "chart_cbuy")
2034            plot(chart.trade.sell_count, id = "chart_csell")
2035            plot(chart.trade.count, id = "chart_trades")
2036            plot(chart.directional_volume_available, id = "directional_available")
2037            plot(chart.buy_qty, id = "buy_qty")
2038            plot(chart.sell_qty, id = "sell_qty")
2039        "#;
2040        let compiled = compile(source, "base-trades.ind").expect("source should compile");
2041        assert!(compiled.requirements.trades);
2042        assert!(compiled.requirements.needs_directional_volume());
2043
2044        let mut instance = compiled
2045            .instantiate(IndicatorConfig::default())
2046            .expect("inputs should bind");
2047        let trade = TradeAggregate {
2048            buy_qty: 7.0,
2049            sell_qty: 3.0,
2050            buy_count: 3,
2051            sell_count: 1,
2052        };
2053        let mut populated = frame(0, 1, 10.0);
2054        populated.chart.directional_volume_available = true;
2055        populated.chart.buy_qty = trade.buy_qty;
2056        populated.chart.sell_qty = trade.sell_qty;
2057        populated.chart.trade = Some(trade);
2058
2059        let populated_output = instance
2060            .evaluate_closed(populated)
2061            .expect("populated trade frame should evaluate");
2062        assert_eq!(
2063            plot_value(&populated_output, "chart_available"),
2064            &Value::Bool(true)
2065        );
2066        assert_eq!(
2067            plot_value(&populated_output, "chart_buy"),
2068            &Value::Float(7.0)
2069        );
2070        assert_eq!(
2071            plot_value(&populated_output, "chart_sell"),
2072            &Value::Float(3.0)
2073        );
2074        assert_eq!(plot_value(&populated_output, "chart_cbuy"), &Value::Int(3));
2075        assert_eq!(plot_value(&populated_output, "chart_csell"), &Value::Int(1));
2076        assert_eq!(
2077            plot_value(&populated_output, "chart_trades"),
2078            &Value::Int(4)
2079        );
2080        assert_eq!(
2081            plot_value(&populated_output, "directional_available"),
2082            &Value::Bool(true)
2083        );
2084        assert_eq!(plot_value(&populated_output, "buy_qty"), &Value::Float(7.0));
2085        assert_eq!(
2086            plot_value(&populated_output, "sell_qty"),
2087            &Value::Float(3.0)
2088        );
2089
2090        for (name, expression) in [
2091            ("bare-close", "close"),
2092            ("bare-directional", "directional_volume_available"),
2093            ("vbuy", "vbuy"),
2094            ("vsell", "vsell"),
2095            ("trades", "trades"),
2096            ("cbuy", "cbuy"),
2097            ("csell", "csell"),
2098        ] {
2099            let source = format!(
2100                "language(1)\nindicator(\"Removed\", id = \"community.removed-{name}\")\nformat(precision = \"base_ticker_min_qty\")\nplot({expression}, id = \"value\")"
2101            );
2102            assert!(
2103                compile(&source, format!("{name}.ind")).is_err(),
2104                "{name} should fail"
2105            );
2106        }
2107    }
2108
2109    #[test]
2110    fn signed_table_columns_require_typed_color_bindings() {
2111        let source = r#"
2112            language(1)
2113            indicator("Signed table", id = "test.signed-table")
2114            display(view = "table")
2115            delta = chart.close - chart.open
2116            plot(delta, id = "delta")
2117            style("positive", title = "Positive values")
2118            style("negative", title = "Negative values")
2119            column(title = "Delta", source = "delta", color_mode = "signed",
2120                positive_color_slot = "positive", negative_color_slot = "negative")
2121        "#;
2122        let compiled = compile(source, "signed-table.ind").expect("table should compile");
2123        assert_eq!(compiled.descriptor.presentation.columns.len(), 1);
2124        assert!(matches!(
2125            compiled.descriptor.presentation.columns[0].color,
2126            PresentationColumnColor::Signed {
2127                ref positive_slot,
2128                ref negative_slot
2129            } if positive_slot == "positive" && negative_slot == "negative"
2130        ));
2131
2132        for (source, expected_message) in [
2133            (
2134                r#"
2135                    language(1)
2136                    indicator("Invalid table", id = "test.invalid-table")
2137                    display(view = "table")
2138                    plot(chart.close, id = "value")
2139                    style("positive")
2140                    style("negative")
2141                    column(title = "Value", source = "value", color_mode = "signed",
2142                        color_slot = "positive", positive_color_slot = "positive",
2143                        negative_color_slot = "negative")
2144                "#,
2145                "cannot provide color_slot",
2146            ),
2147            (
2148                r#"
2149                    language(1)
2150                    indicator("Invalid table", id = "test.invalid-table")
2151                    display(view = "table")
2152                    plot(chart.close, id = "value")
2153                    style("positive")
2154                    column(title = "Value", source = "value", color_mode = "signed",
2155                        positive_color_slot = "positive")
2156                "#,
2157                "require negative_color_slot",
2158            ),
2159        ] {
2160            let diagnostics = compile(source, "invalid-table.ind")
2161                .expect_err("invalid table colors should be diagnosed");
2162            assert!(
2163                diagnostics
2164                    .iter()
2165                    .any(|diagnostic| diagnostic.message.contains(expected_message))
2166            );
2167        }
2168    }
2169
2170    #[test]
2171    fn unsupported_execution_paths_are_rejected_at_compile_time() {
2172        for (name, expression) in [
2173            (
2174                "request",
2175                "request.security(\"ETHUSDT\", \"1h\", chart.close)",
2176            ),
2177            ("drawing", "box.new(1, 2, 3, 4)"),
2178            ("map", "map.new()"),
2179        ] {
2180            let source = format!(
2181                "language(1)\nindicator(\"Unsupported\", id = \"test.{name}\")\nvalue = {expression}\nplot(value)"
2182            );
2183            let diagnostics = compile(&source, format!("{name}.ind"))
2184                .expect_err("unsupported paths must fail compilation");
2185            assert!(
2186                diagnostics
2187                    .iter()
2188                    .any(|diagnostic| { diagnostic.message.contains("not available") })
2189            );
2190        }
2191
2192        let diagnostics = compile(
2193            "language(1)\nindicator(\"Intrabar\", id = \"test.intrabar\")\nvarip ticks = 0\nplot(ticks)",
2194            "intrabar.ind",
2195        )
2196        .expect_err("varip must fail compilation");
2197        assert!(
2198            diagnostics
2199                .iter()
2200                .any(|diagnostic| diagnostic.message.contains("varip"))
2201        );
2202    }
2203
2204    #[test]
2205    fn presentation_scene_is_compiled_with_owned_defaults() {
2206        let source = r#"
2207            language(1)
2208            indicator("Scene", id = "community.scene")
2209            display(panel_title = "Panel", layer_name = "Layer")
2210            format(scale = "fit_visible_include_zero", precision = "fixed_step:0.5",
2211                axis_labels = "abbreviated", header_labels = "commas", max_decimals = 2)
2212            period = input.int(3, "Period", min = 1, max = 20, step = 1)
2213            value = ta.sma(chart.close, period)
2214            upper = value + 1
2215            lower = value - 1
2216            rising = value > value[1]
2217            plot(value, id = "value", title = "Value", precision = "decimals:2",
2218                width = 1.5, offset = 1, color_slot = "base", style_by = "rising",
2219                true_color_slot = "bull", false_color_slot = "bear")
2220            plot(upper, id = "upper", title = "Upper", color_slot = "band")
2221            plot(lower, id = "lower", title = "Lower", color_slot = "band")
2222            plot(rising, id = "rising", title = "Rising", layer = false)
2223            style("base", title = "Base", role = "neutral", shade = "base")
2224            style("bull", title = "Bull", role = "success", shade = "base")
2225            style("bear", title = "Bear", role = "danger", shade = "base")
2226            style("band", title = "Band", role = "secondary", shade = "soft")
2227            mark(title = "Histogram", source = "value", kind = "histogram", color_slot = "base",
2228                highlight_by = "rising", highlight_color_slot = "bull",
2229                requires = "directional_volume", fallback = "line")
2230            mark(title = "Line", source = "value", kind = "line", color_slot = "base")
2231            fill(upper = "upper", lower = "lower", color_slot = "band", opacity = 0.2,
2232                style_by = "rising", true_color_slot = "bull", false_color_slot = "bear")
2233            readout(title = "Signal", source = "value", color_slot = "base")
2234            segment(value, upper, bar_index, bar_index, id = "projection",
2235                title = "Projection", color_slot = "band", width = 1.5)
2236        "#;
2237        let compiled = compile(source, "scene.ind").expect("presentation scene should compile");
2238        let presentation = &compiled.descriptor.presentation;
2239        assert!(!compiled.requirements.needs_directional_volume());
2240        assert_eq!(
2241            presentation.value_precision,
2242            Some(PresentationPrecision::FixedStep(0.5))
2243        );
2244        assert_eq!(
2245            compiled.descriptor.plots[0].output_precision,
2246            Some(OutputPrecision::Decimals(2))
2247        );
2248        assert_eq!(presentation.panel_title.as_deref(), Some("Panel"));
2249        assert_eq!(presentation.layer_name.as_deref(), Some("Layer"));
2250        assert_eq!(presentation.styles.len(), 4);
2251        assert_eq!(presentation.layers.len(), 3);
2252        assert_eq!(
2253            presentation
2254                .layers
2255                .iter()
2256                .map(|layer| layer.channel.as_str())
2257                .collect::<Vec<_>>(),
2258            vec!["value", "upper", "lower"]
2259        );
2260        assert_eq!(
2261            compiled.descriptor.plots[0].color_slot.as_deref(),
2262            Some("base")
2263        );
2264        assert_eq!(compiled.descriptor.plots[0].offset_bars, 1);
2265        assert_eq!(
2266            compiled.descriptor.plots[3].readout,
2267            ReadoutBehavior::Hidden,
2268            "boolean plots are output-only by default"
2269        );
2270        assert_eq!(presentation.marks[0].kind, PresentationMarkKind::Histogram);
2271        assert_eq!(
2272            presentation.marks[0].required_input,
2273            Some(PresentationInputSource::DirectionalVolume)
2274        );
2275        assert_eq!(
2276            presentation.marks[0].fallback_mark,
2277            Some(PresentationMarkKind::Line)
2278        );
2279        assert_eq!(
2280            presentation.marks[0].highlight_by.as_deref(),
2281            Some("rising")
2282        );
2283        assert_eq!(
2284            presentation.marks[0].highlight_color_slot.as_deref(),
2285            Some("bull")
2286        );
2287        assert_eq!(presentation.fills[0].style_by.as_deref(), Some("rising"));
2288        assert_eq!(presentation.fills[0].opacity, 0.2);
2289        assert_eq!(presentation.readouts[0].channel, "value");
2290        assert_eq!(presentation.segments[0].id, "projection");
2291    }
2292
2293    #[test]
2294    fn mark_highlight_bindings_are_validated() {
2295        let cases = [
2296            (
2297                "numeric",
2298                r#"highlight_by = "value", highlight_color_slot = "warning""#,
2299                "highlight_by must reference a boolean output",
2300            ),
2301            (
2302                "missing-color",
2303                r#"highlight_by = "rising""#,
2304                "highlight_by requires highlight_color_slot",
2305            ),
2306            (
2307                "line",
2308                r#"highlight_by = "rising", highlight_color_slot = "warning""#,
2309                "highlight_by requires a bar mark",
2310            ),
2311        ];
2312
2313        for (name, binding, expected_message) in cases {
2314            let kind = if name == "line" { "line" } else { "histogram" };
2315            let source = format!(
2316                r#"
2317                    language(1)
2318                    indicator("Highlight", id = "community.highlight-{name}")
2319                    format(precision = "base_ticker_min_tick")
2320                    value = chart.close
2321                    rising = chart.close > chart.open
2322                    style("base")
2323                    style("warning", role = "warning")
2324                    plot(value, id = "value", title = "Value", color_slot = "base")
2325                    plot(rising, id = "rising", layer = false)
2326                    mark(title = "Mark", source = "value", kind = "{kind}", color_slot = "base", {binding})
2327                "#
2328            );
2329            let diagnostics = compile(&source, format!("{name}.ind"))
2330                .expect_err("invalid mark highlight binding should be diagnosed");
2331            assert!(
2332                diagnostics
2333                    .iter()
2334                    .any(|diagnostic| diagnostic.message.contains(expected_message)),
2335                "expected {expected_message}, got {diagnostics:?}"
2336            );
2337        }
2338    }
2339
2340    #[test]
2341    fn numeric_plot_panels_require_format_precision() {
2342        let diagnostics = compile(
2343            "language(1)\nindicator(\"Missing precision\", id = \"test.missing-precision\")\nplot(chart.close, id = \"value\")\n",
2344            "missing-precision.ind",
2345        )
2346        .expect_err("numeric plot panels should require value precision");
2347        assert!(
2348            diagnostics
2349                .iter()
2350                .any(|diagnostic| { diagnostic.message.contains("must declare format(precision") })
2351        );
2352
2353        let explicit = compile(
2354            "language(1)\nindicator(\"Explicit precision\", id = \"test.explicit-precision\")\nformat(precision = \"fixed_step:0.01\")\nplot(chart.close, id = \"value\")\n",
2355            "explicit-precision.ind",
2356        )
2357        .expect("numeric plot panels with value precision should compile");
2358        assert_eq!(
2359            explicit.descriptor.presentation.value_precision,
2360            Some(PresentationPrecision::FixedStep(0.01))
2361        );
2362
2363        compile(
2364            "language(1)\nindicator(\"Overlay\", id = \"test.overlay\")\ndisplay(view = \"primary_overlay\")\nplot(chart.close, id = \"value\")\n",
2365            "overlay.ind",
2366        )
2367        .expect("primary overlays should not require panel precision");
2368
2369        compile(
2370            "language(1)\nindicator(\"Table\", id = \"test.table\")\ndisplay(view = \"table\")\nplot(chart.close, id = \"value\", display = \"none\", overlay = false, layer = false)\ncolumn(title = \"Value\", source = \"value\")\n",
2371            "table.ind",
2372        )
2373        .expect("table views should not require panel precision");
2374    }
2375
2376    #[test]
2377    fn display_and_format_declarations_are_unique_and_precede_outputs() {
2378        for (source, message) in [
2379            (
2380                "language(1)\nindicator(\"Duplicate display\", id = \"test.duplicate-display\")\ndisplay()\ndisplay()\nplot(chart.close, id = \"value\")\n",
2381                "duplicate display declaration",
2382            ),
2383            (
2384                "language(1)\nindicator(\"Duplicate format\", id = \"test.duplicate-format\")\nformat(precision = \"base_ticker_min_tick\")\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close, id = \"value\")\n",
2385                "duplicate format declaration",
2386            ),
2387            (
2388                "language(1)\nindicator(\"Late display\", id = \"test.late-display\")\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close, id = \"value\")\ndisplay()\n",
2389                "display declarations must appear before output declarations",
2390            ),
2391            (
2392                "language(1)\nindicator(\"Late format\", id = \"test.late-format\")\nplot(chart.close, id = \"value\")\nformat(precision = \"base_ticker_min_tick\")\n",
2393                "format declarations must appear before output declarations",
2394            ),
2395        ] {
2396            let diagnostics = compile(source, "metadata.ind")
2397                .expect_err("invalid metadata declarations should be diagnosed");
2398            assert!(
2399                diagnostics
2400                    .iter()
2401                    .any(|diagnostic| diagnostic.message.contains(message))
2402            );
2403        }
2404    }
2405
2406    #[test]
2407    fn generic_plot_accepts_scalar_styles_and_structured_constructors() {
2408        let scalar_source = r#"
2409            language(1)
2410            indicator("Scalar styles", id = "test.scalar-styles")
2411            format(precision = "base_ticker_min_tick")
2412            plot(chart.close, id = "line", style = "line")
2413            plot(chart.close, id = "step", style = "step")
2414            plot(chart.close, id = "histogram", style = "histogram")
2415            plot(chart.close, id = "area", style = "area")
2416        "#;
2417        compile(scalar_source, "scalar-styles.ind").expect("scalar plot styles should compile");
2418
2419        let structured_source = r#"
2420            language(1)
2421            indicator("Structured plots", id = "test.structured-plots")
2422            display(view = "primary_overlay")
2423            format(precision = "base_ticker_min_tick")
2424            plotcandle(chart.open, chart.high, chart.low, chart.close, id = "candles")
2425            plotohlc(chart.open, chart.high, chart.low, chart.close, id = "ohlc")
2426            plotrange(chart.high, chart.low, id = "range")
2427            broken_cloud(chart.high, chart.low, id = "broken")
2428        "#;
2429        compile(structured_source, "structured-plots.ind")
2430            .expect("structured plot constructors should compile");
2431    }
2432
2433    #[test]
2434    fn generic_plot_rejects_structured_styles() {
2435        for (style, constructors) in [
2436            ("candles", "plotcandle or plotohlc"),
2437            ("bars", "plotcandle or plotohlc"),
2438            ("cloud", "plotrange or broken_cloud"),
2439            ("broken_cloud", "plotrange or broken_cloud"),
2440        ] {
2441            let source = format!(
2442                "language(1)\nindicator(\"Invalid plot\", id = \"test.invalid-{style}\")\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close, id = \"value\", style = \"{style}\")"
2443            );
2444            let diagnostics = compile(&source, format!("invalid-{style}.ind"))
2445                .expect_err("structured styles should not be accepted by generic plot");
2446            assert!(diagnostics.iter().any(|diagnostic| {
2447                diagnostic
2448                    .message
2449                    .contains(&format!("plot style '{style}'"))
2450                    && diagnostic.message.contains("requires a structured plot")
2451                    && diagnostic.message.contains(constructors)
2452            }));
2453        }
2454    }
2455
2456    #[test]
2457    fn unknown_presentation_input_is_rejected_without_becoming_an_indicator_requirement() {
2458        let diagnostics = compile(
2459            r#"
2460                language(1)
2461                indicator("Unknown input", id = "community.unknown-input")
2462                format(precision = "base_ticker_min_tick")
2463                value = chart.close
2464                style("base", title = "Base")
2465                plot(value, id = "value", title = "Value", color_slot = "base")
2466                mark(title = "Bars", source = "value", kind = "histogram", color_slot = "base",
2467                    requires = "not_a_real_input")
2468            "#,
2469            "unknown-input.ind",
2470        )
2471        .expect_err("unknown presentation input should be diagnosed");
2472
2473        assert!(diagnostics.iter().any(|diagnostic| {
2474            diagnostic
2475                .message
2476                .contains("unknown presentation input not_a_real_input")
2477        }));
2478    }
2479
2480    #[test]
2481    fn invalid_presentation_fallbacks_are_rejected() {
2482        let cases = [
2483            (
2484                "unknown",
2485                r#"fallback = "not_a_real_mark""#,
2486                "unknown presentation mark not_a_real_mark",
2487            ),
2488            (
2489                "missing",
2490                r#"fallback = "line""#,
2491                "references unknown fallback mark",
2492            ),
2493            (
2494                "self",
2495                r#"fallback = "histogram""#,
2496                "cannot fall back to itself",
2497            ),
2498        ];
2499
2500        for (name, fallback, expected_message) in cases {
2501            let source = format!(
2502                r#"
2503                    language(1)
2504                    indicator("Fallback", id = "community.fallback-{name}")
2505                    format(precision = "base_ticker_min_tick")
2506                    value = chart.close
2507                    style("base")
2508                    plot(value, id = "value", title = "Value", color_slot = "base")
2509                    mark(title = "Histogram", source = "value", kind = "histogram",
2510                        color_slot = "base", {fallback})
2511                "#
2512            );
2513            let diagnostics = compile(&source, format!("{name}.ind"))
2514                .expect_err("invalid presentation fallback should be diagnosed");
2515            assert!(
2516                diagnostics
2517                    .iter()
2518                    .any(|diagnostic| diagnostic.message.contains(expected_message))
2519            );
2520        }
2521
2522        let diagnostics = compile(
2523            r#"
2524                language(1)
2525                indicator("Input fallback", id = "community.input-fallback")
2526                format(precision = "base_ticker_min_tick")
2527                value = chart.close
2528                style("base")
2529                plot(value, id = "value", title = "Value", color_slot = "base")
2530                mark(title = "Overlay", source = "value", kind = "histogram",
2531                    color_slot = "base", requires = "directional_volume", fallback = "line")
2532                mark(title = "Line", source = "value", kind = "line", color_slot = "base",
2533                    requires = "trades")
2534            "#,
2535            "input-fallback.ind",
2536        )
2537        .expect_err("input-dependent fallback should be diagnosed");
2538        assert!(diagnostics.iter().any(|diagnostic| {
2539            diagnostic
2540                .message
2541                .contains("fallback mark Line cannot require input")
2542        }));
2543    }
2544
2545    #[test]
2546    fn sparse_plot_declarations_materialize_the_same_layer_defaults() {
2547        let explicit = compile(
2548            r#"
2549                language(1)
2550                indicator("Sparse", id = "community.sparse")
2551                display(view = "plot", panel_title = "Sparse", layer_name = "Sparse")
2552                format(precision = "base_ticker_min_tick", scale = "fit_visible")
2553                value = chart.close
2554                style("value", title = "value", role = "neutral", shade = "base")
2555                plot(value, id = "value", title = "Value", pane = "indicator",
2556                    scale = "price", style = "line", display = "value", width = 1,
2557                    offset = 0, color_slot = "value", overlay = true,
2558                    data_kind = "scalar", mark = "line", axis = "secondary", layer = true)
2559            "#,
2560            "explicit.ind",
2561        )
2562        .expect("explicit defaults should compile");
2563        let sparse = compile(
2564            r#"
2565                language(1)
2566                indicator("Sparse", id = "community.sparse")
2567                format(precision = "base_ticker_min_tick")
2568                value = chart.close
2569                style("value")
2570                plot(value, id = "value", title = "Value")
2571            "#,
2572            "sparse.ind",
2573        )
2574        .expect("sparse declarations should compile");
2575
2576        assert_eq!(
2577            explicit.descriptor.presentation.layers,
2578            sparse.descriptor.presentation.layers
2579        );
2580        assert_eq!(
2581            explicit.descriptor.presentation.styles,
2582            sparse.descriptor.presentation.styles
2583        );
2584        assert_eq!(
2585            explicit.descriptor.plots[0].pane,
2586            sparse.descriptor.plots[0].pane
2587        );
2588        assert_eq!(
2589            explicit.descriptor.plots[0].scale,
2590            sparse.descriptor.plots[0].scale
2591        );
2592        assert_eq!(
2593            explicit.descriptor.plots[0].width,
2594            sparse.descriptor.plots[0].width
2595        );
2596        assert_eq!(
2597            explicit.descriptor.plots[0].overlay,
2598            sparse.descriptor.plots[0].overlay
2599        );
2600        assert_eq!(
2601            explicit.descriptor.plots[0].layer,
2602            sparse.descriptor.plots[0].layer
2603        );
2604    }
2605
2606    #[test]
2607    fn na_comparisons_propagate_and_switch_falls_back() {
2608        let source = r#"
2609            language(1)
2610            indicator("Missing values", id = "community.missing-values")
2611            format(precision = "base_ticker_min_tick")
2612            plot(na == na, id = "equal")
2613            plot(switch na { na => 1, default => 2 }, id = "switch")
2614        "#;
2615        let compiled = compile(source, "missing-values.ind").expect("source should compile");
2616        let mut instance = compiled
2617            .instantiate(IndicatorConfig::default())
2618            .expect("inputs should bind");
2619        let output = instance
2620            .evaluate_closed(frame(0, 1, 10.0))
2621            .expect("frame should evaluate");
2622
2623        assert_eq!(plot_value(&output, "equal"), &Value::Na);
2624        assert_eq!(plot_value(&output, "switch"), &Value::Int(2));
2625    }
2626
2627    #[test]
2628    fn state_initializers_retry_na_and_warm_up() {
2629        let source = r#"
2630            language(1)
2631            indicator("State seed", id = "community.state-seed")
2632            format(precision = "base_ticker_min_tick")
2633            seed = ta.sma(chart.close, 2)
2634            state average = seed
2635            plot(average, id = "average")
2636        "#;
2637        let compiled = compile(source, "state-seed.ind").expect("source should compile");
2638        assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(2));
2639        let mut instance = compiled
2640            .instantiate(IndicatorConfig::default())
2641            .expect("inputs should bind");
2642
2643        assert_eq!(
2644            plot_value(
2645                &instance
2646                    .evaluate_closed(frame(0, 1, 10.0))
2647                    .expect("first frame should evaluate"),
2648                "average"
2649            ),
2650            &Value::Na
2651        );
2652        assert_eq!(
2653            plot_value(
2654                &instance
2655                    .evaluate_closed(frame(1, 2, 20.0))
2656                    .expect("second frame should evaluate"),
2657                "average"
2658            ),
2659            &Value::Float(15.0)
2660        );
2661    }
2662
2663    #[test]
2664    fn explicit_na_state_updates_remain_authoritative() {
2665        let source = r#"
2666            language(1)
2667            indicator("Explicit missing", id = "community.explicit-missing")
2668            format(precision = "base_ticker_min_tick")
2669            state value = chart.close
2670            if bar_index == 0 {
2671                value := na
2672            }
2673            plot(value, id = "value")
2674        "#;
2675        let compiled = compile(source, "explicit-missing.ind").expect("source should compile");
2676        let mut instance = compiled
2677            .instantiate(IndicatorConfig::default())
2678            .expect("inputs should bind");
2679        instance
2680            .evaluate_closed(frame(0, 1, 10.0))
2681            .expect("first frame should evaluate");
2682        let output = instance
2683            .evaluate_closed(frame(1, 2, 20.0))
2684            .expect("second frame should evaluate");
2685
2686        assert_eq!(plot_value(&output, "value"), &Value::Na);
2687    }
2688
2689    #[test]
2690    fn mutable_bindings_keep_compatible_types_across_updates_and_branches() {
2691        for source in [
2692            r#"
2693                language(1)
2694                indicator("Invalid state", id = "test.invalid-state")
2695                state value = 0
2696                value := true
2697                plot(value, id = "value")
2698            "#,
2699            r#"
2700                language(1)
2701                indicator("Invalid local", id = "test.invalid-local")
2702                value = 0
2703                value := true
2704                plot(value, id = "value")
2705            "#,
2706            r#"
2707                language(1)
2708                indicator("Invalid conditional", id = "test.invalid-conditional")
2709                state value = 0
2710                if true {
2711                    value := true
2712                }
2713                plot(value, id = "value")
2714            "#,
2715        ] {
2716            let diagnostics = compile(source, "invalid-binding.ind")
2717                .expect_err("incompatible binding updates should fail");
2718            assert!(
2719                diagnostics
2720                    .iter()
2721                    .any(|diagnostic| diagnostic.message.contains("incompatible types"))
2722            );
2723        }
2724
2725        let source = r#"
2726            language(1)
2727            indicator("Widened state", id = "test.widened-state")
2728            format(precision = "base_ticker_min_tick")
2729            state value = 0
2730            value := chart.close
2731            value := na
2732            plot(value, id = "value")
2733        "#;
2734        let compiled = compile(source, "widened-state.ind").expect("compatible updates compile");
2735        assert_eq!(
2736            compiled.descriptor.plots[0].value_type,
2737            PlotValueType::Number
2738        );
2739
2740        let source = r#"
2741            language(1)
2742            indicator("Conditional value", id = "test.conditional-value")
2743            state value = na
2744            if true {
2745                value := true
2746            } else {
2747                value := false
2748            }
2749            plot(value, id = "value")
2750        "#;
2751        let compiled = compile(source, "conditional-value.ind").expect("Any should resolve");
2752        assert_eq!(
2753            compiled.descriptor.plots[0].value_type,
2754            PlotValueType::Boolean
2755        );
2756
2757        for source in [
2758            r#"
2759                language(1)
2760                indicator("While value", id = "test.while-value")
2761                state value = na
2762                while false {
2763                    value := true
2764                }
2765                plot(value, id = "value")
2766            "#,
2767            r#"
2768                language(1)
2769                indicator("For value", id = "test.for-value")
2770                state value = na
2771                for index = 0 to 1 {
2772                    value := true
2773                }
2774                plot(value, id = "value")
2775            "#,
2776        ] {
2777            let compiled = compile(source, "loop-value.ind").expect("loop joins compile");
2778            assert_eq!(
2779                compiled.descriptor.plots[0].value_type,
2780                PlotValueType::Boolean
2781            );
2782        }
2783    }
2784
2785    #[test]
2786    fn result_branches_and_binary_operands_are_checked() {
2787        for source in [
2788            r#"
2789                language(1)
2790                indicator("Invalid select", id = "test.invalid-select")
2791                plot(select(true, 1, false), id = "value")
2792            "#,
2793            r#"
2794                language(1)
2795                indicator("Invalid coalesce", id = "test.invalid-coalesce")
2796                plot(coalesce(1, false), id = "value")
2797            "#,
2798            r#"
2799                language(1)
2800                indicator("Invalid switch", id = "test.invalid-switch")
2801                plot(switch 1 { 1 => 1, default => false }, id = "value")
2802            "#,
2803            r#"
2804                language(1)
2805                indicator("Invalid order", id = "test.invalid-order")
2806                plot(true < false, id = "value")
2807            "#,
2808            r#"
2809                language(1)
2810                indicator("Invalid logical", id = "test.invalid-logical")
2811                plot(1 && true, id = "value")
2812            "#,
2813        ] {
2814            let diagnostics = compile(source, "invalid-expression.ind")
2815                .expect_err("invalid expression types should fail");
2816            assert!(
2817                diagnostics
2818                    .iter()
2819                    .any(|diagnostic| diagnostic.message.contains("incompatible")
2820                        || diagnostic.message.contains("operands"))
2821            );
2822        }
2823
2824        let source = r#"
2825            language(1)
2826            indicator("Valid comparisons", id = "test.valid-comparisons")
2827            plot(chart.close < chart.open, id = "numeric")
2828            plot(chart.time < chart.time[1], id = "time")
2829        "#;
2830        let compiled = compile(source, "valid-comparisons.ind").expect("valid comparisons compile");
2831        assert_eq!(
2832            compiled.descriptor.plots[0].value_type,
2833            PlotValueType::Boolean
2834        );
2835        assert_eq!(
2836            compiled.descriptor.plots[1].value_type,
2837            PlotValueType::Boolean
2838        );
2839    }
2840
2841    #[test]
2842    fn cumulative_overflow_becomes_missing_without_poisoning_state() {
2843        let source = r#"
2844            language(1)
2845            indicator("Cumulative overflow", id = "test.cumulative-overflow")
2846            format(precision = "base_ticker_min_tick")
2847            plot(ta.cum(chart.close), id = "value")
2848        "#;
2849        let compiled = compile(source, "cumulative-overflow.ind").expect("source should compile");
2850        let mut instance = compiled
2851            .instantiate(IndicatorConfig::default())
2852            .expect("inputs should bind");
2853
2854        assert_eq!(
2855            plot_value(
2856                &instance
2857                    .evaluate_closed(frame(0, 1, f64::MAX))
2858                    .expect("first frame should evaluate"),
2859                "value"
2860            ),
2861            &Value::Float(f64::MAX)
2862        );
2863        assert_eq!(
2864            plot_value(
2865                &instance
2866                    .evaluate_closed(frame(1, 2, f64::MAX))
2867                    .expect("overflow should remain recoverable"),
2868                "value"
2869            ),
2870            &Value::Na
2871        );
2872        assert_eq!(
2873            plot_value(
2874                &instance
2875                    .evaluate_closed(frame(2, 3, -f64::MAX))
2876                    .expect("state should remain usable"),
2877                "value"
2878            ),
2879            &Value::Float(0.0)
2880        );
2881    }
2882
2883    #[test]
2884    fn functions_are_numeric_and_typed_before_their_bodies_compile() {
2885        let source = r#"
2886            language(1)
2887            indicator("Function", id = "community.function")
2888            format(precision = "base_ticker_min_tick")
2889            plot(identity(chart.close), id = "value")
2890            fn identity(value) {
2891                return value
2892            }
2893        "#;
2894        let compiled = compile(source, "function.ind").expect("numeric function should compile");
2895        let mut instance = compiled
2896            .instantiate(IndicatorConfig::default())
2897            .expect("inputs should bind");
2898        let output = instance
2899            .evaluate_closed(frame(0, 1, 10.0))
2900            .expect("frame should evaluate");
2901        assert_eq!(plot_value(&output, "value"), &Value::Float(10.0));
2902
2903        let diagnostics = compile(
2904            r#"
2905                language(1)
2906                indicator("Invalid function", id = "community.invalid-function")
2907                format(precision = "base_ticker_min_tick")
2908                plot(identity(chart.close > chart.open), id = "value")
2909                fn identity(value) {
2910                    return value
2911                }
2912            "#,
2913            "invalid-function.ind",
2914        )
2915        .expect_err("boolean function arguments should be rejected");
2916        assert!(
2917            diagnostics
2918                .iter()
2919                .any(|diagnostic| diagnostic.message.contains("must be numeric or na"))
2920        );
2921    }
2922
2923    #[test]
2924    fn input_arguments_and_runtime_values_are_validated() {
2925        for (source, message) in [
2926            (
2927                r#"
2928                    language(1)
2929                    indicator("Unknown input argument", id = "community.unknown-input")
2930                    value = input.int(1, "Value", unknown = 2)
2931                    plot(value, id = "value")
2932                "#,
2933                "unknown argument",
2934            ),
2935            (
2936                r#"
2937                    language(1)
2938                    indicator("Duplicate options", id = "community.duplicate-options")
2939                    value = input.string("a", "Value", options = ["a", "a"])
2940                    plot(chart.close, id = "value")
2941                "#,
2942                "is duplicated",
2943            ),
2944        ] {
2945            let diagnostics = compile(source, "invalid-input.ind")
2946                .expect_err("invalid input declarations should be diagnosed");
2947            assert!(
2948                diagnostics
2949                    .iter()
2950                    .any(|diagnostic| diagnostic.message.contains(message))
2951            );
2952        }
2953
2954        let compiled = compile(
2955            r#"
2956                language(1)
2957                indicator("Bounded input", id = "community.bounded-input")
2958                format(precision = "base_ticker_min_tick")
2959                value = input.int(1, "Value", min = 1, max = 5)
2960                plot(chart.close, id = "value")
2961            "#,
2962            "bounded-input.ind",
2963        )
2964        .expect("bounded input should compile");
2965        let error = compiled
2966            .instantiate(IndicatorConfig::default().with("value", InputValue::Int(6)))
2967            .expect_err("out-of-range input should be rejected");
2968        assert_eq!(error, PlanningError::InvalidInput("value".to_owned()));
2969
2970        let compiled = compile(
2971            r#"
2972                language(1)
2973                indicator("Choice input", id = "community.choice-input")
2974                format(precision = "base_ticker_min_tick")
2975                method = input.string("a", "Method", options = ["a", "b"])
2976                plot(chart.close, id = "value")
2977            "#,
2978            "choice-input.ind",
2979        )
2980        .expect("choice input should compile");
2981        let error = compiled
2982            .instantiate(
2983                IndicatorConfig::default().with("method", InputValue::String("other".to_owned())),
2984            )
2985            .expect_err("unknown option should be rejected");
2986        assert_eq!(error, PlanningError::InvalidInput("method".to_owned()));
2987    }
2988
2989    #[test]
2990    fn transient_collection_values_obey_heap_limits() {
2991        let source = r#"
2992            language(1)
2993            indicator("Collection", id = "community.collection")
2994            format(precision = "base_ticker_min_tick")
2995            plot(array.size(array.push(array.new(), "12345")), id = "size")
2996        "#;
2997        let compiled = compile(source, "collection.ind").expect("source should compile");
2998        let limits = RuntimeLimits {
2999            max_heap_bytes: 4,
3000            ..RuntimeLimits::default()
3001        };
3002        let mut instance = compiled
3003            .instantiate_with_limits(IndicatorConfig::default(), limits)
3004            .expect("inputs should bind");
3005        assert_eq!(
3006            instance
3007                .evaluate_closed(frame(0, 1, 10.0))
3008                .expect_err("temporary string collection should hit the heap limit"),
3009            RuntimeError::ResourceLimitExceeded("heap bytes")
3010        );
3011    }
3012
3013    #[test]
3014    fn ta_window_limits_are_checked_without_counting_ta_state_as_collections() {
3015        let source = r#"
3016            language(1)
3017            indicator("Window", id = "community.window")
3018            format(precision = "base_ticker_min_tick")
3019            value = ta.sma(chart.close, 6)
3020            plot(value, id = "value")
3021        "#;
3022        let compiled = compile(source, "window.ind").expect("source should compile");
3023        let limits = RuntimeLimits {
3024            max_window_length: 5,
3025            ..RuntimeLimits::default()
3026        };
3027        let mut instance = compiled
3028            .instantiate_with_limits(IndicatorConfig::default(), limits)
3029            .expect("inputs should bind");
3030        assert_eq!(
3031            instance
3032                .evaluate_closed(frame(0, 1, 10.0))
3033                .expect_err("oversized TA window should hit the window limit"),
3034            RuntimeError::ResourceLimitExceeded("window length")
3035        );
3036
3037        let source = source.replace("ta.sma(chart.close, 6)", "ta.sma(chart.close, 2)");
3038        let compiled = compile(&source, "window-collection.ind").expect("source should compile");
3039        let limits = RuntimeLimits {
3040            max_collection_elements: 0,
3041            ..RuntimeLimits::default()
3042        };
3043        let mut instance = compiled
3044            .instantiate_with_limits(IndicatorConfig::default(), limits)
3045            .expect("inputs should bind");
3046        instance
3047            .evaluate_closed(frame(0, 1, 10.0))
3048            .expect("TA state should not consume collection elements");
3049    }
3050
3051    #[test]
3052    fn numeric_contracts_preserve_integers_and_match_warmup() {
3053        let source = r#"language(1)
3054indicator("Numbers", id = "test.numbers")
3055format(precision = "fixed_step:0.1")
3056length = 2 + 2
3057plot(9007199254740992 + 1, id = "integer")
3058plot(1 / 2, id = "division")
3059plot(9223372036854775807 + 1, id = "overflow")
3060plot(9007199254740992 < 9007199254740993, id = "order")
3061plot(9007199254740993 != 9007199254740992.0, id = "mixed")
3062plot(ta.pivothigh(chart.high, length, 2), id = "pivot")"#;
3063        let compiled = compile(source, "numbers.ind").unwrap();
3064        assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(7));
3065        assert_eq!(compiled.program.plots[1].value_type, PlotValueType::Number);
3066        let mut instance = compiled.instantiate(IndicatorConfig::default()).unwrap();
3067        for index in 0..7 {
3068            let output = instance
3069                .evaluate_closed(frame(index, index as i64, 10.0))
3070                .unwrap();
3071            assert_eq!(
3072                plot_value(&output, "integer"),
3073                &Value::Int(9007199254740993)
3074            );
3075            assert_eq!(plot_value(&output, "division"), &Value::Float(0.5));
3076            assert_eq!(plot_value(&output, "overflow"), &Value::Na);
3077            assert_eq!(plot_value(&output, "order"), &Value::Bool(true));
3078            assert_eq!(plot_value(&output, "mixed"), &Value::Bool(true));
3079            if index == 6 {
3080                assert_eq!(plot_value(&output, "pivot"), &Value::Float(10.0));
3081            }
3082        }
3083        assert!(matches!(
3084            instance.evaluate_closed(frame(6, 6, 99.0)),
3085            Err(RuntimeError::InvalidValue {
3086                operation: "closed frame order"
3087            })
3088        ));
3089        assert_eq!(instance.committed_index(), Some(6));
3090        instance.preview_open(frame(7, 7, 20.0)).unwrap();
3091        instance.evaluate_closed(frame(7, 7, 20.0)).unwrap();
3092        for (integer, float, order) in [
3093            (i64::MAX, 9223372036854775808.0, std::cmp::Ordering::Less),
3094            (i64::MIN, -9223372036854775808.0, std::cmp::Ordering::Equal),
3095            (-1, -1.5, std::cmp::Ordering::Greater),
3096            (1, 1.5, std::cmp::Ordering::Less),
3097        ] {
3098            assert_eq!(
3099                Value::Int(integer).numeric_compare(&Value::Float(float)),
3100                Some(order)
3101            );
3102            assert_eq!(
3103                Value::Float(float).numeric_compare(&Value::Int(integer)),
3104                Some(order.reverse())
3105            );
3106        }
3107        for (left, operator, right, expected) in [
3108            (i64::MIN, hir::BinaryOperator::Subtract, 1, Value::Na),
3109            (i64::MAX, hir::BinaryOperator::Multiply, 2, Value::Na),
3110            (i64::MIN, hir::BinaryOperator::Remainder, -1, Value::Na),
3111            (9, hir::BinaryOperator::Remainder, 4, Value::Int(1)),
3112            (9, hir::BinaryOperator::Divide, 0, Value::Na),
3113            (9, hir::BinaryOperator::Remainder, 0, Value::Na),
3114        ] {
3115            assert_eq!(
3116                Value::Int(left).binary(operator, Value::Int(right)),
3117                expected
3118            );
3119        }
3120        for kind in ["int", "time"] {
3121            let source = format!(
3122                "language(1)\nindicator(\"Invalid\", id = \"test.invalid\")\nvalue = input.{kind}(2.9)\nplot(chart.close)"
3123            );
3124            assert!(
3125                compile(&source, "invalid.ind")
3126                    .unwrap_err()
3127                    .iter()
3128                    .any(|error| error.message.contains("default has the wrong type"))
3129            );
3130        }
3131    }
3132
3133    #[test]
3134    fn dynamic_output_boundaries_reject_wrong_kinds() {
3135        for body in [
3136            "plot(array.get(array.new(array.new(1, 2)), 0))",
3137            "plotrange(array.get(array.new(false), 0), chart.low)",
3138            "plotcandle(chart.open, chart.high, chart.low, array.get(array.new(\"wrong\"), 0))",
3139            "display(view = \"primary_overlay\")\nplot(na)\nregion(key = array.get(array.new(1.5), 0), upper = chart.high, lower = chart.low, left_index = 0, right_index = 0)",
3140            "display(view = \"primary_overlay\")\nplot(na)\nregion(key = 1, upper = chart.high, lower = chart.low, left_index = 0, right_index = 0, visible = array.get(array.new(1), 0))",
3141            "plot(chart.close, color = array.get(array.new(1), 0))",
3142        ] {
3143            let source = format!(
3144                "language(1)\nindicator(\"Boundary\", id = \"test.boundary\")\nformat(precision = \"fixed_step:1\")\n{body}"
3145            );
3146            let compiled = compile(&source, "boundary.ind").unwrap();
3147            let error = compiled
3148                .instantiate(IndicatorConfig::default())
3149                .unwrap()
3150                .evaluate_closed(frame(0, 0, 10.0))
3151                .unwrap_err();
3152            assert!(
3153                matches!(error, RuntimeError::InvalidOutput { .. }),
3154                "{body}: {error}"
3155            );
3156        }
3157        let compiled = compile("language(1)\nindicator(\"Valid\", id = \"test.valid\")\nformat(precision = \"fixed_step:1\")\nplot(array.get(array.new(1, na), 0), id = \"number\")\nplot(na, id = \"missing\")", "valid.ind").unwrap();
3158        let output = compiled
3159            .instantiate(IndicatorConfig::default())
3160            .unwrap()
3161            .evaluate_closed(frame(0, 0, 10.0))
3162            .unwrap();
3163        assert_eq!(plot_value(&output, "number"), &Value::Int(1));
3164        assert_eq!(plot_value(&output, "missing"), &Value::Na);
3165    }
3166
3167    #[test]
3168    fn stateful_ta_requires_per_bar_execution_paths() {
3169        for body in [
3170            "plot(select(chart.close > 0, ta.sma(chart.close, 2), na))",
3171            "value = na\nfor index = 0 to 1 { value := ta.sma(chart.close, 2) }\nplot(value)",
3172            "plot(coalesce(chart.close, ta.ema(chart.close, 2)))",
3173            "plot(chart.close > 0 && ta.ema(chart.close, 2) > 0)",
3174            "state seed = ta.sma(chart.close, 2)\nplot(seed)",
3175            "fn average(value) { return ta.ema(value, 2) }\nplot(select(chart.close > 0, average(chart.close), na))",
3176            "fn inner(value) { return ta.ema(value, 2) }\nfn average(value) { return inner(value) }\nplot(select(chart.close > 0, average(chart.close), na))",
3177            "fn average(value) { if value > 0 { return 0 } return ta.ema(value, 2) }\nplot(average(chart.close))",
3178            "fn average(value = ta.ema(chart.close, 2)) { return value }\nplot(average())",
3179            "plot(ta.sma(chart.close, 2)[1])",
3180            "display(view = \"primary_overlay\")\nplot(na)\nregion(key = 1, upper = ta.sma(chart.high, 2), lower = chart.low, left_index = 0, right_index = 0, visible = chart.close > 0)",
3181        ] {
3182            let source = format!(
3183                "language(1)\nindicator(\"Schedule\", id = \"test.schedule\")\nformat(precision = \"fixed_step:1\")\n{body}"
3184            );
3185            let errors = compile(&source, "schedule.ind").unwrap_err();
3186            assert!(
3187                errors.iter().any(|error| error
3188                    .message
3189                    .contains("stateful TA must execute once per bar")
3190                    && error.span.end > error.span.start),
3191                "{body}: {errors:?}"
3192            );
3193        }
3194        let source = "language(1)\nindicator(\"Fixed\", id = \"test.fixed\")\nformat(precision = \"fixed_step:1\")\nenabled = input.bool(true)\naverage = select(enabled, ta.sma(chart.close, 2), ta.ema(chart.close, 2))\nstate seed = average\nplot(select(chart.close > 0, average, na))";
3195        let compiled = compile(source, "fixed.ind").unwrap();
3196        let mut instance = compiled.instantiate(IndicatorConfig::default()).unwrap();
3197        instance.evaluate_closed(frame(0, 0, 10.0)).unwrap();
3198        instance.evaluate_closed(frame(1, 1, -100.0)).unwrap();
3199        let output = instance.evaluate_closed(frame(2, 2, 20.0)).unwrap();
3200        assert_eq!(plot_value(&output, "plot_0"), &Value::Float(-40.0));
3201    }
3202
3203    #[test]
3204    fn work_budget_charges_scans_and_collection_copies() {
3205        let mut work = usize::MAX;
3206        assert!(!runtime::charge_work(&mut work, usize::MAX, 1));
3207        let compiled = compile("language(1)\nindicator(\"Work\", id = \"test.work\")\nformat(precision = \"fixed_step:1\")\nplot(ta.sma(chart.close, 100))", "work.ind").unwrap();
3208        let limits = RuntimeLimits {
3209            max_evaluated_nodes: 250,
3210            ..RuntimeLimits::untrusted()
3211        };
3212        let mut instance = compiled
3213            .instantiate_with_limits(IndicatorConfig::default(), limits)
3214            .unwrap();
3215        assert!((0..100).any(|index| matches!(
3216            instance.evaluate_closed(frame(index, index as i64, 10.0)),
3217            Err(RuntimeError::ExecutionLimitExceeded)
3218        )));
3219        let destructure = format!(
3220            "values = array.new(\"{}\", 0)\nfor index = 0 to 11 {{ (first, second) = values }}\nplot(chart.close)",
3221            "x".repeat(1024)
3222        );
3223        for (body, expected) in [
3224            (
3225                "values = array.new()\nfor index = 0 to 127 { values := array.push(values, index) }\nplot(array.size(values))",
3226                true,
3227            ),
3228            (
3229                "values = array.new()\nfor index = 0 to 127 { previous = values\n values := array.push(values, index) }\nplot(array.size(values))",
3230                false,
3231            ),
3232            (destructure.as_str(), false),
3233        ] {
3234            let source = format!(
3235                "language(1)\nindicator(\"Copy\", id = \"test.copy\")\nformat(precision = \"fixed_step:1\")\n{body}"
3236            );
3237            let compiled = compile(&source, "copy.ind").unwrap();
3238            let limits = RuntimeLimits {
3239                max_evaluated_nodes: 4000,
3240                ..RuntimeLimits::untrusted()
3241            };
3242            let result = compiled
3243                .instantiate_with_limits(IndicatorConfig::default(), limits)
3244                .unwrap()
3245                .evaluate_closed(frame(0, 0, 10.0));
3246            assert_eq!(result.is_ok(), expected, "{body}: {result:?}");
3247            if !expected {
3248                assert!(matches!(result, Err(RuntimeError::ExecutionLimitExceeded)));
3249            }
3250        }
3251        let compiled = compile("language(1)\nindicator(\"Depth\", id = \"test.depth\")\nformat(precision = \"fixed_step:1\")\nvalues = array.new()\nfor index = 0 to 256 { values := array.new(values) }\nplot(array.size(values))", "depth.ind").unwrap();
3252        assert!(matches!(
3253            compiled
3254                .instantiate(IndicatorConfig::default())
3255                .unwrap()
3256                .evaluate_closed(frame(0, 0, 10.0)),
3257            Err(RuntimeError::ResourceLimitExceeded("collection nesting"))
3258        ));
3259    }
3260}