mod diagnostics;
mod frame;
mod hir;
mod intrinsic;
mod parser;
mod runtime;
mod scene;
mod types;
mod warmup;
pub use diagnostics::{Diagnostic, Severity, Span};
pub use frame::{
BaseSeriesSource, ChartFrame, FrameField, FrameOutput, HorizontalLevelOutput, IndicatorFrame,
InstrumentContext, MarkerOutput, OhlcOutput, PlotOutput, RangeOutput, RegionOutput,
RegionUpdate, SegmentOutput, SeriesInputValue, TimeOrTick, TradeAggregate,
};
pub use hir::{
BarStateField, BinaryOperator, CallSiteId, ConstantId, ExprId, ExpressionKind, FunctionId,
HirFunction, HirProgram, IntrinsicId, LocalSlot, PlotId, SeriesSlot, SourceInputSlot,
StateSlot, StatementBlock, StatementBlockId, StatementId, StatementKind, TypedExpression,
TypedStatement, UnaryOperator,
};
use intrinsic::IntrinsicRegistry;
pub use runtime::{IndicatorInstance, PlanningError, RuntimeError, RuntimeLimits};
pub use scene::{
Color, IndicatorPresentation, OutputPrecision, PaneTarget, PlotDescriptor, PlotScale,
PlotStyle, PlotValueType, PresentationAttachmentMode, PresentationAxis, PresentationColumn,
PresentationColumnColor, PresentationDataKind, PresentationFill, PresentationGeometry,
PresentationInputSource, PresentationLabelMode, PresentationLayer, PresentationMark,
PresentationMarkKind, PresentationPrecision, PresentationReadout, PresentationScale,
PresentationSegment, PresentationStyle, PresentationViewKind, ReadoutBehavior,
};
pub use types::{
ArrayValue, IndicatorAuthor, IndicatorConfig, IndicatorDescriptor, IndicatorId,
InputDescriptor, InputOption, InputType, InputValue, Qualifier, SeriesInputDescriptor, Type,
Value, ValueType,
};
pub fn source_files_in_directory(
path: impl AsRef<std::path::Path>,
) -> Result<(Vec<std::path::PathBuf>, Vec<String>), String> {
let root = path.as_ref();
let mut directories = vec![root.to_owned()];
let mut files = Vec::new();
let mut diagnostics = Vec::new();
while let Some(directory) = directories.pop() {
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) => {
let diagnostic =
format!("{}: could not read directory: {error}", directory.display());
if directory == root {
return Err(diagnostic);
}
diagnostics.push(diagnostic);
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
diagnostics.push(format!(
"{}: could not read directory entry: {error}",
directory.display()
));
continue;
}
};
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
diagnostics.push(format!(
"{}: could not inspect entry: {error}",
path.display()
));
continue;
}
};
if file_type.is_dir() {
directories.push(path);
} else if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("ind")
{
files.push(path);
}
}
}
files.sort();
diagnostics.sort();
Ok((files, diagnostics))
}
pub type Program = HirProgram;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompileLimits {
pub max_source_bytes: usize,
pub max_tokens: usize,
pub max_expressions: usize,
pub max_statements: usize,
pub max_functions: usize,
pub max_output_channels: usize,
}
impl Default for CompileLimits {
fn default() -> Self {
Self {
max_source_bytes: 1 << 20,
max_tokens: 100_000,
max_expressions: 100_000,
max_statements: 50_000,
max_functions: 256,
max_output_channels: 256,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DataRequirements {
pub ohlc: bool,
pub directional_volume: bool,
pub trades: bool,
pub open_interest: bool,
}
impl DataRequirements {
pub const fn needs_directional_volume(self) -> bool {
self.directional_volume
}
pub const fn needs_open_interest(self) -> bool {
self.open_interest
}
}
use std::path::Path;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct CompiledIndicator {
pub descriptor: IndicatorDescriptor,
pub requirements: DataRequirements,
pub program: Program,
pub(crate) registry: intrinsic::IntrinsicRegistry,
}
pub fn compile(source: &str, path: impl AsRef<Path>) -> Result<CompiledIndicator, Vec<Diagnostic>> {
compile_with_limits(source, path, CompileLimits::default())
}
pub fn compile_with_limits(
source: &str,
path: impl AsRef<Path>,
limits: CompileLimits,
) -> Result<CompiledIndicator, Vec<Diagnostic>> {
let path = path.as_ref();
if source.len() > limits.max_source_bytes {
return Err(vec![Diagnostic::error(
path,
Span::new(0, source.len().min(limits.max_source_bytes)),
format!(
"source exceeds compile limit of {} bytes",
limits.max_source_bytes
),
)]);
}
compile_with_registry(source, path, IntrinsicRegistry::standard(), limits)
}
pub fn compile_file(path: impl AsRef<Path>) -> Result<CompiledIndicator, Vec<Diagnostic>> {
compile_file_with_limits(path, CompileLimits::default())
}
pub fn compile_file_with_limits(
path: impl AsRef<Path>,
limits: CompileLimits,
) -> Result<CompiledIndicator, Vec<Diagnostic>> {
let path = path.as_ref();
match std::fs::metadata(path) {
Ok(metadata) if metadata.len() > limits.max_source_bytes as u64 => {
return Err(vec![Diagnostic::error(
path,
Span::new(0, 0),
format!(
"source exceeds compile limit of {} bytes",
limits.max_source_bytes
),
)]);
}
Err(error) => {
return Err(vec![Diagnostic::error(
path,
Span::new(0, 0),
format!("could not read source: {error}"),
)]);
}
Ok(_) => {}
}
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
return Err(vec![Diagnostic::error(
path,
Span::new(0, 0),
format!("could not read source: {error}"),
)]);
}
};
compile_with_limits(&source, path, limits)
}
pub(crate) fn compile_with_registry(
source: &str,
path: impl AsRef<Path>,
registry: intrinsic::IntrinsicRegistry,
limits: CompileLimits,
) -> Result<CompiledIndicator, Vec<Diagnostic>> {
parser::compile_with_registry(source, path.as_ref(), registry, limits)
}
impl CompiledIndicator {
pub fn warmup_bars(&self, config: &IndicatorConfig) -> Option<usize> {
self.warmup_bars_with_limits(config, RuntimeLimits::default())
}
pub fn warmup_bars_with_limits(
&self,
config: &IndicatorConfig,
limits: RuntimeLimits,
) -> Option<usize> {
warmup::warmup_bars(
&self.program,
&self.registry,
&self.descriptor,
config,
limits.max_window_length,
)
.ok()
}
pub fn anchor_start(&self, config: &IndicatorConfig, reference_time: i64) -> Option<i64> {
warmup::anchor_start(
&self.program,
&self.registry,
&self.descriptor,
config,
reference_time,
)
.ok()
.flatten()
}
pub fn validate_history_analysis(&self, config: &IndicatorConfig) -> Result<(), PlanningError> {
warmup::warmup_bars(
&self.program,
&self.registry,
&self.descriptor,
config,
RuntimeLimits::default().max_window_length,
)?;
warmup::anchor_start(&self.program, &self.registry, &self.descriptor, config, 0)?;
Ok(())
}
pub fn requires_anchored_history(&self) -> bool {
warmup::requires_anchored_history(&self.program, &self.registry)
}
pub fn instantiate(&self, config: IndicatorConfig) -> Result<IndicatorInstance, PlanningError> {
self.instantiate_with_limits(config, RuntimeLimits::default())
}
pub fn instantiate_with_limits(
&self,
config: IndicatorConfig,
limits: RuntimeLimits,
) -> Result<IndicatorInstance, PlanningError> {
IndicatorInstance::with_limits(Arc::new(self.clone()), config, limits)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
fn frame(index: usize, time: i64, close: f64) -> IndicatorFrame {
IndicatorFrame {
index,
time,
basis: TimeOrTick::Time,
instrument: InstrumentContext {
min_tick: Some(0.1),
},
chart: ChartFrame {
open: Some(close),
high: Some(close),
low: Some(close),
close: Some(close),
volume: Some(1.0),
directional_volume_available: false,
buy_qty: 0.0,
sell_qty: 0.0,
trade: None,
},
open_interest: None,
series_inputs: Vec::new().into(),
}
}
fn market_frame(
index: usize,
high: Option<f64>,
low: Option<f64>,
close: Option<f64>,
volume: Option<f64>,
) -> IndicatorFrame {
let close_value = close.unwrap_or(0.0);
let mut frame = frame(index, index as i64, close_value);
frame.chart.open = close;
frame.chart.high = high;
frame.chart.low = low;
frame.chart.close = close;
frame.chart.volume = volume;
frame
}
fn plot_value<'a>(output: &'a FrameOutput, id: &str) -> &'a Value {
output
.plots
.iter()
.find(|plot| plot.id == id)
.map(|plot| &plot.value)
.expect("plot output should be present")
}
#[test]
fn indicator_basis_restrictions() {
for (declaration, expected) in [
("", None),
(", bases = [\"time\"]", Some(vec![TimeOrTick::Time])),
(", bases = [\"tick\"]", Some(vec![TimeOrTick::Tick])),
(
", bases = [\"time\", \"tick\"]",
Some(vec![TimeOrTick::Time, TimeOrTick::Tick]),
),
(
", bases = [\"tick\", \"tick\"]",
Some(vec![TimeOrTick::Tick]),
),
] {
let source = format!(
"language(1)\nindicator(\"Bases\", id = \"test.bases\"{declaration})\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close)"
);
let compiled = compile(&source, "bases.ind").expect("valid basis restriction");
assert_eq!(compiled.descriptor.bases, expected);
}
for declaration in [
"[]",
"[\"range\"]",
"[1]",
"[\"time\", false]",
"\"time\"",
"chart.close",
] {
let source = format!(
"language(1)\nindicator(\"Bases\", id = \"test.bases\", bases = {declaration})\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close)"
);
let error = compile(&source, "bases.ind").expect_err("invalid basis restriction");
assert!(
error
.iter()
.any(|diagnostic| diagnostic.message.contains("bases"))
);
}
}
#[test]
fn anchored_history_is_inferred_through_functions() {
for (body, expected) in [
("plot(chart.close)", false),
("plot(ta.avwap(chart.close, chart.volume, \"Day\"))", true),
(
"fn anchored(price) { return ta.avwap(price, chart.volume, \"Week\") }\nplot(anchored(chart.close))",
true,
),
] {
let source = format!(
"language(1)\nindicator(\"History\", id = \"test.history\")\nformat(precision = \"base_ticker_min_tick\")\n{body}"
);
let compiled = compile(&source, "history.ind").expect("history source should compile");
assert_eq!(compiled.requires_anchored_history(), expected);
}
}
#[test]
fn record_origin_alias_chain_is_bounded() {
const ALIAS_COUNT: usize = 24_000;
let mut source = String::from(
"language(1)\nindicator(\"Long aliases\", id = \"test.long-aliases\")\nformat(precision = \"base_ticker_min_tick\")\nalias0 = chart\n",
);
for index in 1..ALIAS_COUNT {
writeln!(&mut source, "alias{index} = alias{}", index - 1)
.expect("writing an in-memory source should succeed");
}
writeln!(
&mut source,
"plot(alias{}.close, id = \"value\")",
ALIAS_COUNT - 1
)
.expect("writing an in-memory source should succeed");
let result = std::thread::Builder::new()
.name("record-origin-regression".to_owned())
.stack_size(2 * 1024 * 1024)
.spawn(move || compile(&source, "long-aliases.ind"))
.expect("regression thread should start")
.join()
.expect("record-origin traversal should not overflow the stack");
assert!(
result.is_ok(),
"long immutable alias chains should compile: {result:?}"
);
}
#[test]
fn record_field_classification_preserves_builtin_and_dynamic_records() {
let chart_alias = compile(
r#"
language(1)
indicator("Chart alias", id = "test.chart-alias")
format(precision = "base_ticker_min_tick")
chart_alias = chart
plot(chart_alias.close, id = "value")
"#,
"chart-alias.ind",
)
.expect("an aliased chart should retain strict fields");
assert!(chart_alias.requirements.ohlc);
let trade_alias = compile(
r#"
language(1)
indicator("Trade aliases", id = "test.trade-aliases")
format(precision = "base_ticker_min_qty")
chart_alias = chart
chart_trade = chart.trade
aliased_trade = chart_alias.trade
plot(chart_trade.count, id = "direct")
plot(aliased_trade.count, id = "aliased")
"#,
"trade-aliases.ind",
)
.expect("chart.trade aliases should retain strict trade fields");
assert!(trade_alias.requirements.trades);
for source in [
r#"
language(1)
indicator("Invalid chart field", id = "test.invalid-chart-field")
format(precision = "base_ticker_min_tick")
chart_alias = chart
plot(chart_alias.missing, id = "value")
"#,
r#"
language(1)
indicator("Invalid trade field", id = "test.invalid-trade-field")
format(precision = "base_ticker_min_qty")
trade_alias = chart.trade
plot(trade_alias.close, id = "value")
"#,
] {
let diagnostics = compile(source, "invalid-record-field.ind")
.expect_err("unknown built-in fields should remain strict");
assert!(
diagnostics
.iter()
.any(|diagnostic| { diagnostic.message.contains("unknown record field") })
);
}
let nested = compile(
r#"
language(1)
indicator("Nested user record", id = "test.nested-user-record")
format(precision = "base_ticker_min_tick")
value = { trade: { close: 7 } }
plot(value.trade.close, id = "value")
"#,
"nested-user-record.ind",
)
.expect("user records named trade should stay dynamic");
let mut nested_instance = nested
.instantiate(IndicatorConfig::default())
.expect("nested user record should instantiate");
let nested_output = nested_instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("nested user record should evaluate");
assert_eq!(plot_value(&nested_output, "value"), &Value::Int(7));
let mutable = compile(
r#"
language(1)
indicator("Mutable record", id = "test.mutable-record")
format(precision = "base_ticker_min_tick")
value = chart
value := { close: 9 }
plot(value.close, id = "value")
"#,
"mutable-record.ind",
)
.expect("mutable records should use dynamic field access");
let mut mutable_instance = mutable
.instantiate(IndicatorConfig::default())
.expect("mutable record should instantiate");
let mutable_output = mutable_instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("mutable record should evaluate");
assert_eq!(plot_value(&mutable_output, "value"), &Value::Int(9));
}
#[test]
fn rsi_uses_wilder_seed_and_exact_zero_edges() {
let source = r#"
language(1)
indicator("RSI", id = "test.rsi")
format(precision = "base_ticker_min_tick")
period = input.int(3, "Period", min = 1, max = 100)
plot(ta.rsi(chart.close, period), id = "value")
"#;
let compiled = compile(source, "rsi.ind").expect("RSI source should compile");
assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(4));
let evaluate = |values: &[Option<f64>]| {
let mut instance = compiled
.clone()
.instantiate(IndicatorConfig::default())
.expect("RSI inputs should bind");
values
.iter()
.enumerate()
.map(|(index, close)| {
plot_value(
&instance
.evaluate_closed(market_frame(index, *close, *close, *close, Some(1.0)))
.expect("RSI frame should evaluate"),
"value",
)
.clone()
})
.collect::<Vec<_>>()
};
let alternating = evaluate(&[Some(1.0), Some(2.0), Some(1.0), Some(2.0), Some(1.0)]);
assert_eq!(&alternating[..3], &[Value::Na, Value::Na, Value::Na]);
assert!(
matches!(alternating[3], Value::Float(value) if (value - 66.6666666667).abs() < 1e-9)
);
assert!(
matches!(alternating[4], Value::Float(value) if (value - 44.4444444444).abs() < 1e-9)
);
for (values, expected) in [
(&[Some(1.0), Some(2.0), Some(3.0), Some(4.0)][..], 100.0),
(&[Some(4.0), Some(3.0), Some(2.0), Some(1.0)][..], 0.0),
(&[Some(1.0), Some(1.0), Some(1.0), Some(1.0)][..], 50.0),
(
&[
Some(1.0),
Some(1.0 + 1e-12),
Some(1.0 + 2e-12),
Some(1.0 + 3e-12),
][..],
100.0,
),
] {
let output = evaluate(values);
assert!(matches!(output[3], Value::Float(value) if (value - expected).abs() < 1e-9));
}
}
#[test]
fn rsi_gaps_retain_state_and_previews_do_not_commit() {
let source = r#"
language(1)
indicator("RSI gaps", id = "test.rsi-gaps")
format(precision = "base_ticker_min_tick")
period = input.int(3, "Period", min = 1, max = 100)
plot(ta.rsi(chart.close, period), id = "value")
"#;
let compiled = compile(source, "rsi-gaps.ind").expect("RSI source should compile");
let mut instance = compiled
.clone()
.instantiate(IndicatorConfig::default())
.expect("RSI inputs should bind");
for (index, close) in [Some(1.0), Some(2.0)].into_iter().enumerate() {
instance
.evaluate_closed(market_frame(index, close, close, close, Some(1.0)))
.expect("seed frame should evaluate");
}
let preview = instance
.preview_open(market_frame(2, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
.expect("RSI preview should evaluate");
assert_eq!(plot_value(&preview, "value"), &Value::Na);
let second_preview = instance
.preview_open(market_frame(2, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
.expect("repeated RSI preview should evaluate");
assert_eq!(plot_value(&second_preview, "value"), &Value::Na);
let committed = instance
.evaluate_closed(market_frame(2, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
.expect("RSI commit should evaluate");
assert_eq!(plot_value(&committed, "value"), &Value::Na);
let committed = instance
.evaluate_closed(market_frame(3, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
.expect("RSI seeded output should evaluate");
assert!(
matches!(plot_value(&committed, "value"), Value::Float(value) if (*value - 66.6666666667).abs() < 1e-9)
);
let mut gap_instance = compiled
.instantiate(IndicatorConfig::default())
.expect("RSI gap inputs should bind");
let values = [Some(1.0), Some(2.0), None, Some(1.0), Some(2.0), Some(1.0)];
let outputs = values
.iter()
.enumerate()
.map(|(index, close)| {
plot_value(
&gap_instance
.evaluate_closed(market_frame(index, *close, *close, *close, Some(1.0)))
.expect("RSI gap frame should evaluate"),
"value",
)
.clone()
})
.collect::<Vec<_>>();
assert_eq!(outputs[2], Value::Na);
assert!(matches!(outputs[5], Value::Float(value) if (value - 66.6666666667).abs() < 1e-9));
let mut seeded_gap_instance = compiled
.instantiate(IndicatorConfig::default())
.expect("seeded RSI gap inputs should bind");
for (index, close) in [Some(1.0), Some(2.0), Some(1.0), Some(2.0), None, Some(3.0)]
.into_iter()
.enumerate()
{
let output = seeded_gap_instance
.evaluate_closed(market_frame(index, close, close, close, Some(1.0)))
.expect("seeded RSI gap frame should evaluate");
if index == 4 || index == 5 {
assert_eq!(plot_value(&output, "value"), &Value::Na);
}
}
let output = seeded_gap_instance
.evaluate_closed(market_frame(6, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
.expect("post-gap RSI frame should evaluate");
assert!(
matches!(plot_value(&output, "value"), Value::Float(value) if (value - 44.4444444444).abs() < 1e-9)
);
}
#[test]
fn pivots_confirm_after_the_right_leg_and_accept_ties() {
let source = r#"
language(1)
indicator("Pivots", id = "test.pivots")
format(precision = "base_ticker_min_tick")
left = input.int(1, "Left", min = 1, max = 10)
right = input.int(1, "Right", min = 1, max = 10)
plot(ta.pivothigh(chart.high, left, right), id = "high")
plot(ta.pivotlow(chart.low, left, right), id = "low")
"#;
let compiled = compile(source, "pivots.ind").expect("pivots should compile");
assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(3));
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let values = [(1.0, 3.0), (3.0, 3.0), (2.0, 2.0)];
let first = instance
.evaluate_closed(market_frame(0, Some(1.0), Some(3.0), Some(1.0), Some(1.0)))
.expect("first frame should evaluate");
assert_eq!(plot_value(&first, "high"), &Value::Na);
assert_eq!(plot_value(&first, "low"), &Value::Na);
let second = instance
.evaluate_closed(market_frame(1, Some(3.0), Some(3.0), Some(3.0), Some(1.0)))
.expect("second frame should evaluate");
assert_eq!(plot_value(&second, "high"), &Value::Na);
let third = instance
.evaluate_closed(market_frame(2, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
.expect("third frame should evaluate");
assert_eq!(plot_value(&third, "high"), &Value::Float(values[1].0));
assert_eq!(plot_value(&third, "low"), &Value::Na);
let mut preview_instance = compiled
.instantiate(IndicatorConfig::default())
.expect("preview inputs should bind");
preview_instance
.evaluate_closed(market_frame(0, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
.expect("first preview frame should evaluate");
preview_instance
.evaluate_closed(market_frame(1, Some(3.0), Some(3.0), Some(3.0), Some(1.0)))
.expect("second preview frame should evaluate");
let preview = preview_instance
.preview_open(market_frame(2, Some(2.0), Some(2.0), Some(2.0), Some(1.0)))
.expect("pivot preview should evaluate");
assert_eq!(plot_value(&preview, "high"), &Value::Float(3.0));
let closed_after_preview = preview_instance
.evaluate_closed(market_frame(2, Some(1.0), Some(1.0), Some(1.0), Some(1.0)))
.expect("closed frame after preview should evaluate");
assert_eq!(
plot_value(&closed_after_preview, "high"),
&Value::Float(3.0)
);
}
#[test]
fn pivots_return_na_for_missing_samples_and_dynamic_legs() {
let source = r#"
language(1)
indicator("Dynamic pivots", id = "test.dynamic-pivots")
format(precision = "base_ticker_min_tick")
left = input.int(1, "Left", min = 1, max = 10)
right = input.int(2, "Right", min = 1, max = 10)
plot(ta.pivothigh(chart.high, left, right), id = "high")
"#;
let compiled = compile(source, "dynamic-pivots.ind").expect("pivots should compile");
assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(4));
let mut instance = compiled
.instantiate(IndicatorConfig::default().with("right", InputValue::Int(2)))
.expect("inputs should bind");
for (index, high) in [Some(1.0), None, Some(2.0), Some(1.0)]
.into_iter()
.enumerate()
{
let output = instance
.evaluate_closed(market_frame(index, high, high, high, Some(1.0)))
.expect("frame should evaluate");
assert_eq!(plot_value(&output, "high"), &Value::Na);
}
let mut instance = compiled
.instantiate(IndicatorConfig::default().with("right", InputValue::Int(1)))
.expect("dynamic input should bind");
for (index, high) in [Some(1.0), Some(4.0), Some(2.0)].into_iter().enumerate() {
let output = instance
.evaluate_closed(market_frame(index, high, high, high, Some(1.0)))
.expect("frame should evaluate");
if index == 2 {
assert_eq!(plot_value(&output, "high"), &Value::Float(4.0));
}
}
}
#[test]
fn pivots_reject_non_positive_legs_and_bounded_windows() {
let invalid = r#"
language(1)
indicator("Invalid pivot", id = "test.invalid-pivot")
format(precision = "base_ticker_min_tick")
plot(ta.pivothigh(chart.high, 0, 1), id = "high")
"#;
let invalid =
compile(invalid, "invalid-pivot.ind").expect("leg values are checked at runtime");
let result = invalid
.instantiate(IndicatorConfig::default())
.expect("invalid leg source should still plan")
.evaluate_closed(frame(0, 0, 1.0));
assert!(result.is_err());
let bounded = r#"
language(1)
indicator("Bounded pivot", id = "test.bounded-pivot")
format(precision = "base_ticker_min_tick")
left = input.int(2, "Left", min = 1, max = 10)
right = input.int(2, "Right", min = 1, max = 10)
plot(ta.pivotlow(chart.low, left, right), id = "low")
"#;
let compiled = compile(bounded, "bounded-pivot.ind").expect("pivot should compile");
let limits = RuntimeLimits {
max_window_length: 3,
..Default::default()
};
let result = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.expect("bounded source should plan")
.evaluate_closed(frame(0, 0, 1.0));
assert!(result.is_err());
}
#[test]
fn regions_emit_loop_snapshots_last_write_wins_and_preview_isolated() {
let source = r#"
language(1)
indicator("Regions", id = "test.regions")
display(view = "primary_overlay")
plot(na, id = "anchor", display = "none", pane = "main", layer = true)
state threshold = chart.close
if chart.close > threshold {
for index = 0 to 1 {
region(key = 7, upper = 10 + index, lower = 5,
left_index = 0, right_index = bar_index,
extend_right = true, id = "zones")
}
}
threshold := chart.close
"#;
let compiled = compile(source, "regions.ind").expect("regions should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let first = instance
.evaluate_closed(frame(0, 0, 10.0))
.expect("first frame should evaluate");
assert!(first.regions.is_empty());
let preview = instance
.preview_open(frame(1, 1, 20.0))
.expect("preview should evaluate");
assert_eq!(preview.regions.len(), 1);
assert_eq!(preview.regions[0].key, 7);
assert_eq!(preview.regions[0].upper, 11.0);
let closed = instance
.evaluate_closed(frame(1, 1, 15.0))
.expect("closed frame should evaluate");
assert_eq!(closed.regions.len(), 1);
assert_eq!(closed.regions[0].upper, 11.0);
let removed = instance
.evaluate_closed(frame(2, 2, 5.0))
.expect("region removal should evaluate");
assert!(removed.regions.is_empty());
}
#[test]
fn persistent_regions_emit_updates_and_last_write_wins() {
let source = r#"
language(1)
indicator("Persistent regions", id = "test.persistent-regions")
display(view = "primary_overlay")
plot(na, id = "anchor", display = "none", pane = "main", layer = true)
if chart.close > 0 {
region(key = 7, upper = 10, lower = 5,
left_index = 0, right_index = bar_index,
extend_right = true, persistent = true, id = "zones")
region(key = 7, upper = 12, lower = 5,
left_index = 0, right_index = bar_index,
extend_right = false, persistent = true, id = "zones")
region_remove(id = "zones", key = 8)
}
"#;
let compiled = compile(source, "persistent-regions.ind")
.expect("persistent region source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 0, 1.0))
.expect("persistent region should evaluate");
assert_eq!(output.regions.len(), 0);
assert_eq!(output.region_updates.len(), 2);
assert!(matches!(
&output.region_updates[0],
RegionUpdate::Upsert(region)
if region.key == 7 && region.upper == 12.0 && !region.extend_right
));
assert!(matches!(
&output.region_updates[1],
RegionUpdate::Remove { id, key } if id == "zones" && *key == 8
));
let omitted = instance
.evaluate_closed(frame(1, 1, -1.0))
.expect("omitted persistent regions should evaluate");
assert!(omitted.region_updates.is_empty());
}
#[test]
fn arrays_support_tuple_storage_push_get_and_size() {
let source = r#"
language(1)
indicator("Array zones", id = "test.array-zones")
format(precision = "base_ticker_min_tick")
active = array.new()
active := array.push(active, (7, 10.0, 5.0, 3, 2))
active := array.push(active, (8, 20.5, 15.0, 4, 1))
(key, upper, lower, left, count) = array.get(active, 1)
plot(array.size(active), id = "size")
plot(upper, id = "upper")
plot(left, id = "left")
plot(count, id = "count")
"#;
let compiled = compile(source, "array-zones.ind").expect("array source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 0, 1.0))
.expect("array source should evaluate");
assert_eq!(plot_value(&output, "size"), &Value::Int(2));
assert_eq!(plot_value(&output, "upper"), &Value::Float(20.5));
assert_eq!(plot_value(&output, "left"), &Value::Int(4));
assert_eq!(plot_value(&output, "count"), &Value::Int(1));
}
#[test]
fn array_push_evaluates_side_effecting_arguments_once_when_aliased() {
let source = r#"
language(1)
indicator("Array alias", id = "test.array-alias")
format(precision = "base_ticker_min_tick")
active = array.new()
alias = active
active := array.push(active, (7, ta.cum(1), 0.0, 0, 0))
(_, upper, _, _, _) = array.get(active, 0)
plot(upper, id = "upper")
"#;
let compiled = compile(source, "array-alias.ind").expect("aliased array should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 0, 1.0))
.expect("aliased array push should evaluate");
assert_eq!(plot_value(&output, "upper"), &Value::Float(1.0));
}
#[test]
fn self_pushed_arrays_obey_collection_limits() {
let source = r#"
language(1)
indicator("Bounded array", id = "test.bounded-array")
format(precision = "base_ticker_min_tick")
active = array.new()
active := array.push(active, (7, 10.0))
plot(array.size(active), id = "size")
"#;
let compiled = compile(source, "bounded-array.ind").expect("array source should compile");
let limits = RuntimeLimits {
max_collection_elements: 0,
..RuntimeLimits::default()
};
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.expect("inputs should bind");
assert_eq!(
instance
.evaluate_closed(frame(0, 0, 1.0))
.expect_err("self-pushed array should hit the collection limit"),
RuntimeError::ResourceLimitExceeded("collection elements")
);
}
#[test]
fn large_array_reads_stay_within_untrusted_execution_limits() {
let source = r#"
language(1)
indicator("Large array reads", id = "test.large-array-reads")
format(precision = "base_ticker_min_tick")
values = array.new()
for index = 0 to 255 {
values := array.push(values, (index, index))
}
for index = 0 to array.size(values) - 1 {
(key, value) = array.get(values, index)
}
plot(array.size(values), id = "size")
"#;
let compiled =
compile(source, "large-array-reads.ind").expect("array source should compile");
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), RuntimeLimits::untrusted())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 0, 1.0))
.expect("large array reads should remain within limits");
assert_eq!(plot_value(&output, "size"), &Value::Int(256));
}
#[test]
fn flat_open_zones_emit_only_creation_and_completion_updates() {
let compiled = compile(
include_str!("../tests/fixtures/flat-open-zones.ind"),
"flat-open-zones.ind",
)
.expect("flat open source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("flat open source should instantiate");
let flat_frame = |index: usize, open: f64, high: f64, low: f64, close: f64| {
let mut frame = frame(index, index as i64, close);
frame.chart.open = Some(open);
frame.chart.high = Some(high);
frame.chart.low = Some(low);
frame.chart.close = Some(close);
frame
};
let mut invalidated = compiled
.instantiate(IndicatorConfig::default())
.expect("flat open source should instantiate");
assert!(
invalidated
.evaluate_closed(flat_frame(0, 100.0, 100.1, 99.95, 99.95))
.expect("invalidated setup bar should evaluate")
.region_updates
.is_empty()
);
assert!(
invalidated
.evaluate_closed(flat_frame(1, 100.0, 101.0, 99.9, 101.0))
.expect("invalidated creation bar should evaluate")
.region_updates
.is_empty()
);
assert!(
instance
.evaluate_closed(flat_frame(0, 100.0, 101.0, 99.0, 100.0))
.expect("first bar should evaluate")
.region_updates
.is_empty()
);
let created = instance
.evaluate_closed(flat_frame(1, 100.0, 101.0, 99.9, 101.0))
.expect("creation bar should evaluate");
assert!(matches!(
created.region_updates.as_ref(),
[RegionUpdate::Upsert(region)] if region.left_index == 1 && region.extend_right
));
for index in 2..4 {
assert!(
instance
.evaluate_closed(flat_frame(index, 100.5, 102.0, 99.5, 99.5))
.expect("inside bar should evaluate")
.region_updates
.is_empty()
);
}
let completed = instance
.evaluate_closed(flat_frame(4, 100.5, 102.0, 99.5, 99.5))
.expect("completion bar should evaluate");
assert!(matches!(
completed.region_updates.as_ref(),
[RegionUpdate::Remove { id, .. }] if id == "flat_open_zones"
));
}
#[test]
#[ignore = "slow 10k-bar history stress test; run explicitly"]
fn flat_open_zones_handle_dense_10k_history_under_untrusted_limits() {
let compiled = compile(
include_str!("../tests/fixtures/flat-open-zones.ind"),
"flat-open-zones.ind",
)
.expect("flat open source should compile");
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), RuntimeLimits::untrusted())
.expect("flat open source should instantiate");
for index in 0..10_000 {
let open = 100.0 + index as f64 * 0.2;
let mut bar = frame(index, index as i64, open + 0.5);
bar.chart.open = Some(open);
bar.chart.high = Some(open + 1.0);
bar.chart.low = Some(open - 0.1);
bar.chart.close = Some(open + 0.5);
instance
.evaluate_closed(bar)
.expect("dense flat-open history should remain within limits");
}
}
#[test]
#[ignore = "slow 10k-bar history stress test; run explicitly"]
fn volume_pivot_zones_handle_dense_10k_history_under_untrusted_limits() {
let compiled = compile(
include_str!("../tests/fixtures/volume-pivot-zones.ind"),
"volume-pivot-zones.ind",
)
.expect("volume pivot source should compile");
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), RuntimeLimits::untrusted())
.expect("volume pivot source should instantiate");
for index in 0..10_000 {
let base = 100.0 + index as f64 * 0.2;
instance
.evaluate_closed(market_frame(
index,
Some(base + 1.0),
Some(base),
Some(base + 0.75),
Some(if index % 11 == 5 { 1_000.0 } else { 1.0 }),
))
.expect("dense volume-pivot history should remain within limits");
}
}
#[test]
fn flat_open_zones_remove_oldest_active_zones_at_the_configured_cap() {
let compiled = compile(
include_str!("../tests/fixtures/flat-open-zones.ind"),
"flat-open-zones.ind",
)
.expect("flat open source should compile");
let mut instance = compiled
.instantiate_with_limits(
IndicatorConfig::default().with("max_active_zones", InputValue::Int(16)),
RuntimeLimits::untrusted(),
)
.expect("flat open source should instantiate");
let mut removed = false;
for index in 0..64 {
let open = 100.0 + index as f64 * 0.2;
let mut bar = frame(index, index as i64, open + 0.5);
bar.chart.open = Some(open);
bar.chart.high = Some(open + 1.0);
bar.chart.low = Some(open - 0.1);
bar.chart.close = Some(open + 0.5);
let output = instance
.evaluate_closed(bar)
.expect("capped flat-open history should evaluate");
removed |= output
.region_updates
.iter()
.any(|update| matches!(update, RegionUpdate::Remove { .. }));
}
assert!(removed, "the active-zone cap should retire old regions");
}
#[test]
fn flat_open_zones_apply_one_cap_across_bullish_and_bearish_zones() {
let compiled = compile(
include_str!("../tests/fixtures/flat-open-zones.ind"),
"flat-open-zones.ind",
)
.expect("flat open source should compile");
let mut instance = compiled
.instantiate_with_limits(
IndicatorConfig::default().with("max_active_zones", InputValue::Int(16)),
RuntimeLimits::untrusted(),
)
.expect("flat open source should instantiate");
let mut removed = false;
let mut first = frame(0, 0, 100.0);
first.chart.open = Some(100.0);
first.chart.high = Some(100.1);
first.chart.low = Some(99.95);
first.chart.close = Some(100.0);
instance
.evaluate_closed(first)
.expect("setup bar should evaluate");
for index in 1..24 {
let bullish = index % 2 == 1;
let mut bar = frame(index, index as i64, 100.0);
bar.chart.open = Some(100.0);
bar.chart.high = Some(100.1);
bar.chart.low = Some(if bullish { 100.0 } else { 99.95 });
bar.chart.close = Some(if bullish { 100.05 } else { 99.95 });
let output = instance
.evaluate_closed(bar)
.expect("mixed flat open should evaluate");
removed |= output
.region_updates
.iter()
.any(|update| matches!(update, RegionUpdate::Remove { .. }));
}
assert!(
removed,
"the global active-zone cap should retire old regions"
);
}
#[test]
fn volume_pivot_zones_remove_oldest_active_zones_at_the_configured_cap() {
let compiled = compile(
include_str!("../tests/fixtures/volume-pivot-zones.ind"),
"volume-pivot-zones.ind",
)
.expect("volume pivot source should compile");
let mut instance = compiled
.instantiate_with_limits(
IndicatorConfig::default().with("max_active_zones", InputValue::Int(16)),
RuntimeLimits::untrusted(),
)
.expect("volume pivot source should instantiate");
let mut removed = false;
for index in 0..300 {
let base = 100.0 + index as f64 * 0.2;
let output = instance
.evaluate_closed(market_frame(
index,
Some(base + 1.0),
Some(base),
Some(base + 0.75),
Some(if index % 11 == 5 { 1_000.0 } else { 1.0 }),
))
.expect("dense volume-pivot history should evaluate");
removed |= output
.region_updates
.iter()
.any(|update| matches!(update, RegionUpdate::Remove { .. }));
}
assert!(removed, "the active-zone cap should retire old regions");
}
#[test]
fn history_indexing_reads_an_immutable_series_local() {
let source = r#"
language(1)
indicator("Lagged value", id = "test.lagged")
format(precision = "base_ticker_min_tick")
value = chart.close * 2
plot(value[1], id = "lagged")
"#;
let compiled = compile(source, "lagged.ind").expect("source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("first frame should evaluate"),
"lagged"
),
&Value::Na
);
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(1, 2, 20.0))
.expect("second frame should evaluate"),
"lagged"
),
&Value::Float(20.0)
);
}
#[test]
fn history_indexing_rejects_unretained_sources() {
for source in [
r#"
language(1)
indicator("Mutable history", id = "test.mutable-history")
format(precision = "base_ticker_min_tick")
value = chart.close
value := value
plot(value[1], id = "lagged")
"#,
r#"
language(1)
indicator("Parameter history", id = "test.parameter-history")
format(precision = "base_ticker_min_tick")
plot(lag(chart.close), id = "value")
fn lag(value) {
return value[1]
}
"#,
r#"
language(1)
indicator("Call history", id = "test.call-history")
format(precision = "base_ticker_min_tick")
plot(identity(chart.close)[1], id = "lagged")
fn identity(value) {
return value
}
"#,
] {
let diagnostics = compile(source, "unretained-history.ind")
.expect_err("unretained history should fail compilation");
assert!(
diagnostics
.iter()
.any(|diagnostic| { diagnostic.message.contains("immutable retained series") })
);
}
}
#[test]
fn history_validation_is_order_independent_for_local_mutations() {
for source in [
r#"
language(1)
indicator("Later mutation", id = "test.later-mutation")
format(precision = "base_ticker_min_tick")
value = chart.close
plot(value[1], id = "lagged")
value := value
"#,
r#"
language(1)
indicator("Branch mutation", id = "test.branch-mutation")
format(precision = "base_ticker_min_tick")
value = chart.close
plot(value[1], id = "lagged")
if true {
value := value
}
"#,
r#"
language(1)
indicator("Destructure mutation", id = "test.destructure-mutation")
format(precision = "base_ticker_min_tick")
value = chart.close
other = chart.open
plot(value[1], id = "lagged")
(value, other) := (value, other)
"#,
r#"
language(1)
indicator("Loop mutation", id = "test.loop-mutation")
format(precision = "base_ticker_min_tick")
value = chart.close
plot(value[1], id = "lagged")
for index = 0 to 1 {
value := value
}
"#,
r#"
language(1)
indicator("Loop target", id = "test.loop-target")
format(precision = "base_ticker_min_tick")
for index = 0 to 1 {
lagged = index[1]
}
"#,
] {
let diagnostics = compile(source, "order-independent-history.ind")
.expect_err("mutable history should fail compilation");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("immutable retained series"))
);
}
}
#[test]
fn parser_rejects_deep_input_without_recursing() {
let deeply_nested = [
"(".repeat(257) + "chart.close" + &")".repeat(257),
"+".repeat(257) + "chart.close",
"chart.close".to_owned() + &" + chart.close".repeat(257),
"(chart.close)".to_owned() + &".field".repeat(257),
"chart.close".to_owned() + &"[0]".repeat(257),
{
let mut expression = "chart.close".to_owned();
for _ in 0..257 {
expression = format!("chart.close + ({expression})");
}
expression
},
{
let mut expression = "chart.close".to_owned();
for _ in 0..257 {
expression = format!("math.abs({expression})");
}
expression
},
{
let mut expression = "chart.close".to_owned();
for _ in 0..257 {
expression = format!("[{expression}]");
}
expression
},
{
let mut expression = "chart.close".to_owned();
for _ in 0..257 {
expression = format!("switch chart.close {{ default => {expression} }}");
}
expression
},
];
for (index, expression) in deeply_nested.iter().enumerate() {
let source = format!(
"language(1)\nindicator(\"Deep {index}\", id = \"test.deep-{index}\")\nformat(precision = \"base_ticker_min_tick\")\nplot({expression}, id = \"value\")"
);
let diagnostics =
compile(&source, "deep-parser.ind").expect_err("deep source should be rejected");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("parser nesting limit")),
"missing nesting diagnostic for expression {index}: {diagnostics:?}"
);
}
let mut source = String::from(
"language(1)\nindicator(\"Deep statements\", id = \"test.deep-statements\")\nformat(precision = \"base_ticker_min_tick\")\n",
);
for _ in 0..257 {
source.push_str("if true { ");
}
source.push_str("plot(chart.close, id = \"value\")");
for _ in 0..257 {
source.push_str(" }");
}
let diagnostics = compile(&source, "deep-statements.ind")
.expect_err("deep statements should be rejected");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("parser nesting limit"))
);
}
#[test]
fn compile_limits_reject_large_sources_and_variadic_underflow() {
let diagnostics = compile_with_limits(
"language(1)",
"large-source.ind",
CompileLimits {
max_source_bytes: 4,
..CompileLimits::default()
},
)
.expect_err("source limit should be enforced before lexing");
assert!(
diagnostics[0]
.message
.contains("source exceeds compile limit")
);
for source in [
r#"
language(1)
indicator("Missing arguments", id = "test.missing-arguments")
format(precision = "base_ticker_min_tick")
plot(math.max(), id = "value")
"#,
r#"
language(1)
indicator("Missing color", id = "test.missing-color")
format(precision = "base_ticker_min_tick")
plot(chart.close, id = "value", color = color.rgb(1, 2))
"#,
] {
let diagnostics = compile(source, "variadic-underflow.ind")
.expect_err("variadic minimum arity should be diagnosed");
assert!(
diagnostics
.iter()
.any(|diagnostic| { diagnostic.message.contains("at least") })
);
}
}
#[test]
fn source_inputs_support_history_ta_and_host_overrides() {
let source = r#"
language(1)
indicator("Source input", id = "test.source-input", sourceable = true)
display(attachment = "panel")
format(precision = "base_ticker_min_tick")
source = input.source(chart.close, "Source")
plot(source, id = "current")
plot(source[1], id = "lagged")
plot(ta.sma(source, 2), id = "average")
"#;
let compiled = compile(source, "source-input.ind").expect("source input should compile");
assert!(compiled.descriptor.sourceable);
assert_eq!(compiled.descriptor.series_inputs.len(), 1);
assert_eq!(compiled.descriptor.series_inputs[0].id, "source");
assert_eq!(compiled.descriptor.series_inputs[0].label, "Source");
assert_eq!(
compiled.descriptor.series_inputs[0].default_field,
FrameField::ChartClose
);
assert!(compiled.descriptor.series_inputs[0].allow_indicator);
assert_eq!(
compiled.descriptor.presentation.attachment,
Some(PresentationAttachmentMode::Panel)
);
assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(2));
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("source input should bind");
let first = instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("first frame should evaluate");
assert_eq!(plot_value(&first, "current"), &Value::Float(10.0));
assert_eq!(plot_value(&first, "lagged"), &Value::Na);
assert_eq!(plot_value(&first, "average"), &Value::Na);
let second = instance
.evaluate_closed(frame(1, 2, 20.0))
.expect("fallback frame should evaluate");
assert_eq!(plot_value(&second, "current"), &Value::Float(20.0));
assert_eq!(plot_value(&second, "lagged"), &Value::Float(10.0));
assert_eq!(plot_value(&second, "average"), &Value::Float(15.0));
let mut bound = frame(2, 3, 30.0);
bound.series_inputs = vec![SeriesInputValue::Value(Some(100.0))].into();
let bound = instance
.evaluate_closed(bound)
.expect("bound frame should evaluate");
assert_eq!(plot_value(&bound, "current"), &Value::Float(100.0));
assert_eq!(plot_value(&bound, "lagged"), &Value::Float(20.0));
assert_eq!(plot_value(&bound, "average"), &Value::Float(60.0));
let mut unavailable = frame(3, 4, 40.0);
unavailable.series_inputs = vec![SeriesInputValue::Value(None)].into();
let unavailable = instance
.evaluate_closed(unavailable)
.expect("unavailable frame should evaluate");
assert_eq!(plot_value(&unavailable, "current"), &Value::Na);
assert_eq!(plot_value(&unavailable, "lagged"), &Value::Float(100.0));
assert_eq!(plot_value(&unavailable, "average"), &Value::Na);
let mut selected = frame(4, 5, 50.0);
selected.chart.high = Some(9.0);
selected.chart.low = Some(3.0);
selected.series_inputs = vec![SeriesInputValue::Base(BaseSeriesSource::Hl2)].into();
let selected = instance
.evaluate_closed(selected)
.expect("base source selection should evaluate");
assert_eq!(plot_value(&selected, "current"), &Value::Float(6.0));
}
#[test]
fn chart_composite_sources_compile_and_evaluate() {
let source = r#"
language(1)
indicator("Composite sources", id = "test.composite-sources")
display(attachment = "panel")
format(precision = "base_ticker_min_tick")
source = input.source(chart.hlc3, "Source")
plot(chart.hl2, id = "hl2")
plot(chart.hlc3, id = "hlc3")
plot(chart.ohlc4, id = "ohlc4")
plot(chart.hlcc4, id = "hlcc4")
plot(source, id = "source")
"#;
let compiled = compile(source, "composite-sources.ind")
.expect("composite chart sources should compile");
assert!(compiled.requirements.ohlc);
assert_eq!(compiled.descriptor.series_inputs.len(), 1);
assert_eq!(compiled.descriptor.series_inputs[0].id, "source");
assert_eq!(compiled.descriptor.series_inputs[0].label, "Source");
assert_eq!(
compiled.descriptor.series_inputs[0].default_field,
FrameField::ChartHlc3
);
let mut complete = frame(0, 1, 5.0);
complete.chart.open = Some(1.0);
complete.chart.high = Some(8.0);
complete.chart.low = Some(3.0);
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("composite source instance should bind");
let output = instance
.evaluate_closed(complete)
.expect("complete composite frame should evaluate");
assert_eq!(plot_value(&output, "hl2"), &Value::Float(5.5));
assert_eq!(plot_value(&output, "hlc3"), &Value::Float(16.0 / 3.0));
assert_eq!(plot_value(&output, "ohlc4"), &Value::Float(17.0 / 4.0));
assert_eq!(plot_value(&output, "hlcc4"), &Value::Float(21.0 / 4.0));
assert_eq!(plot_value(&output, "source"), &Value::Float(16.0 / 3.0));
let mut incomplete = frame(1, 2, 5.0);
incomplete.chart.high = Some(8.0);
incomplete.chart.low = None;
let output = instance
.evaluate_closed(incomplete)
.expect("incomplete composite frame should evaluate");
assert_eq!(plot_value(&output, "hl2"), &Value::Na);
assert_eq!(plot_value(&output, "hlc3"), &Value::Na);
assert_eq!(plot_value(&output, "ohlc4"), &Value::Na);
assert_eq!(plot_value(&output, "hlcc4"), &Value::Na);
assert_eq!(plot_value(&output, "source"), &Value::Na);
let mut nonfinite = frame(2, 3, 5.0);
nonfinite.chart.high = Some(f64::NAN);
let output = instance
.evaluate_closed(nonfinite)
.expect("non-finite composite frame should evaluate");
assert_eq!(plot_value(&output, "source"), &Value::Na);
}
#[test]
fn source_input_requires_a_direct_chart_series_default() {
let diagnostics = compile(
r#"
language(1)
indicator("Invalid source", id = "test.invalid-source")
display(attachment = "panel")
format(precision = "base_ticker_min_tick")
source = input.source(chart.close + 1, "Source")
plot(source, id = "value")
"#,
"invalid-source.ind",
)
.expect_err("source defaults must be direct chart fields");
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("input.source default must be chart.open")
}));
}
#[test]
fn source_policies_map_and_allow_multiple_engine_inputs() {
let policy = compile(
r#"
language(1)
indicator("Policies", id = "test.policies", sourceable = false)
display(attachment = "panel")
format(precision = "base_ticker_min_tick")
base = input.source(chart.close, "Base", allow_indicator = false)
source = input.source(chart.close, "Source")
plot(base + source, id = "value")
"#,
"policies.ind",
)
.expect("source policies should compile");
assert!(!policy.descriptor.sourceable);
assert_eq!(policy.descriptor.series_inputs.len(), 2);
assert!(!policy.descriptor.series_inputs[0].allow_indicator);
assert!(policy.descriptor.series_inputs[1].allow_indicator);
let multiple = compile(
r#"
language(1)
indicator("Multiple sources", id = "test.multiple-sources")
display(attachment = "panel")
format(precision = "base_ticker_min_tick")
left = input.source(chart.close, "Left")
right = input.source(chart.open, "Right")
plot(left - right, id = "value")
"#,
"multiple-sources.ind",
)
.expect("multiple source inputs should compile");
assert_eq!(multiple.descriptor.series_inputs.len(), 2);
assert!(
multiple
.descriptor
.series_inputs
.iter()
.all(|input| input.allow_indicator)
);
}
#[test]
fn sourceable_defaults_to_false_and_requires_visible_numeric_output() {
let default = compile(
r#"
language(1)
indicator("Default source policy", id = "test.default-source-policy")
format(precision = "base_ticker_min_tick")
plot(chart.close, id = "value")
"#,
"default-source-policy.ind",
)
.expect("default source policy should compile");
assert!(!default.descriptor.sourceable);
assert!(default.descriptor.series_inputs.is_empty());
let hidden = compile(
r#"
language(1)
indicator("Private source", id = "test.private-source", sourceable = true)
display(view = "primary_overlay")
plot(na, id = "private", pane = "main", layer = false)
"#,
"private-source.ind",
)
.expect_err("sourceable indicators need a visible numeric output");
assert!(hidden.iter().any(|diagnostic| {
diagnostic
.message
.contains("sourceable = true requires at least one visible numeric plot layer")
}));
}
#[test]
fn base_only_source_inputs_can_use_non_plot_views() {
let table = compile(
r#"
language(1)
indicator("Base table", id = "test.base-table")
display(view = "table")
source = input.source(chart.close, "Source", allow_indicator = false)
plot(source, id = "value", layer = false)
column(title = "Value", source = "value")
"#,
"base-table.ind",
)
.expect("base-only table source should compile");
assert_eq!(
table.descriptor.presentation.view,
PresentationViewKind::Table
);
let overlay = compile(
r#"
language(1)
indicator("Base overlay", id = "test.base-overlay")
display(view = "primary_overlay")
source = input.source(chart.close, "Source", allow_indicator = false)
plot(source, id = "value", pane = "main")
"#,
"base-overlay.ind",
)
.expect("base-only primary overlay source should compile");
assert_eq!(
overlay.descriptor.presentation.view,
PresentationViewKind::PrimaryOverlay
);
}
#[test]
fn indicator_enabled_source_inputs_require_plot_views_and_visible_numeric_layers() {
let table = compile(
r#"
language(1)
indicator("Invalid table", id = "test.invalid-table")
display(view = "table")
source = input.source(chart.close, "Source")
plot(source, id = "value", layer = false)
column(title = "Value", source = "value")
"#,
"invalid-table.ind",
)
.expect_err("indicator-enabled table source should fail");
assert!(table.iter().any(|diagnostic| {
diagnostic
.message
.contains("allow_indicator = true requires display(view = \"plot\")")
}));
assert!(table.iter().any(|diagnostic| {
diagnostic
.message
.contains("requires at least one visible numeric plot layer")
}));
let overlay = compile(
r#"
language(1)
indicator("Invalid overlay", id = "test.invalid-overlay")
display(view = "primary_overlay")
source = input.source(chart.close, "Source")
plot(source, id = "value", pane = "main")
"#,
"invalid-overlay.ind",
)
.expect_err("indicator-enabled primary overlay source should fail");
assert!(overlay.iter().any(|diagnostic| {
diagnostic
.message
.contains("allow_indicator = true requires display(view = \"plot\")")
}));
}
#[test]
fn source_indicators_require_an_explicit_attachment_mode() {
let overlay = compile(
r#"
language(1)
indicator("Overlay source", id = "test.overlay-source")
display(attachment = "overlay")
format(precision = "base_ticker_min_tick")
source = input.source(chart.close, "Source")
plot(source, id = "value")
"#,
"overlay-source.ind",
)
.expect("overlay source should compile");
assert_eq!(
overlay.descriptor.presentation.attachment,
Some(PresentationAttachmentMode::Overlay)
);
let missing = compile(
r#"
language(1)
indicator("Missing attachment", id = "test.missing-attachment")
format(precision = "base_ticker_min_tick")
source = input.source(chart.close, "Source")
plot(source, id = "value")
"#,
"missing-attachment.ind",
)
.expect_err("source indicators must declare an attachment mode");
assert!(missing.iter().any(|diagnostic| {
diagnostic
.message
.contains("display must declare attachment")
}));
let invalid = compile(
r#"
language(1)
indicator("Invalid attachment", id = "test.invalid-attachment")
display(attachment = "split")
format(precision = "base_ticker_min_tick")
source = input.source(chart.close, "Source")
plot(source, id = "value")
"#,
"invalid-attachment.ind",
)
.expect_err("unknown attachment modes must be rejected");
assert!(
invalid
.iter()
.any(|diagnostic| { diagnostic.message.contains("unknown attachment split") })
);
}
#[test]
fn named_panes_and_numeric_layer_targets_are_validated_by_the_compiler() {
let named = compile(
r#"
language(1)
indicator("Named", id = "test.named")
format(precision = "base_ticker_min_tick")
plot(chart.close, id = "value", pane = "named:7")
"#,
"named.ind",
)
.expect("named pane should compile");
assert_eq!(named.descriptor.plots[0].pane, PaneTarget::Named(7));
assert_eq!(
named.descriptor.presentation.layers[0].pane,
PaneTarget::Named(7)
);
let primary_overlay = compile(
r#"
language(1)
indicator("Overlay", id = "test.named-overlay")
display(view = "primary_overlay")
plot(chart.close, id = "value", pane = "named:7")
"#,
"named-overlay.ind",
)
.expect_err("primary overlays must target the main pane");
assert!(primary_overlay.iter().any(|diagnostic| {
diagnostic.message.contains("primary_overlay")
&& diagnostic.message.contains("pane main")
}));
let mixed = compile(
r#"
language(1)
indicator("Mixed", id = "test.mixed")
format(precision = "base_ticker_min_tick")
plot(chart.close, id = "main", pane = "main")
plot(chart.close, id = "indicator", pane = "indicator")
"#,
"mixed.ind",
)
.expect_err("numeric layers must share a pane target");
assert!(mixed.iter().any(|diagnostic| {
diagnostic
.message
.contains("all numeric plot layers in one indicator must use the same pane")
}));
}
#[test]
fn preview_restarts_from_the_last_committed_state() {
let source = r#"
language(1)
indicator("Committed state", id = "test.state")
format(precision = "base_ticker_min_tick")
state total = 0
total := total + chart.close
plot(total, id = "total")
"#;
let compiled = compile(source, "state.ind").expect("source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
instance
.evaluate_closed(frame(0, 1, 1.0))
.expect("first frame should evaluate");
instance
.evaluate_closed(frame(1, 2, 2.0))
.expect("second frame should evaluate");
let first_preview = instance
.preview_open(frame(2, 3, 10.0))
.expect("preview should evaluate");
let second_preview = instance
.preview_open(frame(2, 3, 20.0))
.expect("preview should evaluate");
assert_eq!(plot_value(&first_preview, "total"), &Value::Float(13.0));
assert_eq!(plot_value(&second_preview, "total"), &Value::Float(23.0));
let committed = instance
.evaluate_closed(frame(2, 3, 20.0))
.expect("final open frame should commit");
assert_eq!(plot_value(&committed, "total"), &Value::Float(23.0));
}
#[test]
fn cumulative_volume_skips_missing_directional_samples() {
let source = r#"
language(1)
indicator("CVD", id = "test.cvd")
format(precision = "base_ticker_min_qty")
delta = select(chart.directional_volume_available, chart.buy_qty - chart.sell_qty, na)
value = ta.cum(delta)
plot(value, id = "value")
"#;
let compiled = compile(source, "cvd.ind").expect("source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let mut first = frame(0, 1, 10.0);
first.chart.directional_volume_available = true;
first.chart.buy_qty = 5.0;
first.chart.sell_qty = 2.0;
let first_output = instance
.evaluate_closed(first)
.expect("first directional frame should evaluate");
assert_eq!(plot_value(&first_output, "value"), &Value::Float(3.0));
let mut second = frame(1, 2, 10.0);
second.chart.directional_volume_available = true;
second.chart.buy_qty = 4.0;
second.chart.sell_qty = 1.0;
let second_output = instance
.evaluate_closed(second)
.expect("second directional frame should evaluate");
assert_eq!(plot_value(&second_output, "value"), &Value::Float(6.0));
let missing_output = instance
.evaluate_closed(frame(2, 3, 10.0))
.expect("missing directional frame should evaluate");
assert_eq!(plot_value(&missing_output, "value"), &Value::Na);
let mut third = frame(3, 4, 10.0);
third.chart.directional_volume_available = true;
third.chart.buy_qty = 3.0;
third.chart.sell_qty = 1.0;
let third_output = instance
.evaluate_closed(third)
.expect("directional frame after gap should evaluate");
assert_eq!(plot_value(&third_output, "value"), &Value::Float(8.0));
let mut fourth = frame(4, 5, 10.0);
fourth.chart.directional_volume_available = true;
fourth.chart.buy_qty = 2.0;
fourth.chart.sell_qty = 1.0;
let fourth_output = instance
.evaluate_closed(fourth)
.expect("directional frame should resume after gap");
assert_eq!(plot_value(&fourth_output, "value"), &Value::Float(9.0));
}
#[test]
fn optional_trade_inputs_fall_back_to_candle_volume() {
let source = r#"
language(1)
indicator("Volume", id = "test.volume", optional_trades = true)
format(precision = "base_ticker_min_qty")
has_directional_volume = chart.directional_volume_available
buy = select(has_directional_volume, chart.buy_qty, chart.volume / 2)
sell = select(has_directional_volume, chart.sell_qty, chart.volume / 2)
signed = select(has_directional_volume, buy - sell, na)
plot(buy, id = "buy")
plot(signed, id = "signed")
"#;
let compiled = compile(source, "volume.ind").expect("source should compile");
assert!(compiled.descriptor.optional_trades);
assert!(compiled.requirements.needs_directional_volume());
assert!(!compiled.requirements.trades);
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("candle-only frame should evaluate");
assert_eq!(plot_value(&output, "buy"), &Value::Float(0.5));
assert_eq!(plot_value(&output, "signed"), &Value::Na);
let mut flow_frame = frame(1, 2, 10.0);
flow_frame.chart.directional_volume_available = true;
flow_frame.chart.buy_qty = 3.0;
flow_frame.chart.trade = Some(TradeAggregate {
buy_qty: 3.0,
sell_qty: 0.0,
buy_count: 2,
sell_count: 0,
});
let output = instance
.evaluate_closed(flow_frame)
.expect("trade frame should evaluate");
assert_eq!(plot_value(&output, "buy"), &Value::Float(3.0));
assert_eq!(plot_value(&output, "signed"), &Value::Float(3.0));
}
#[test]
fn chart_trade_fields_use_the_base_chart() {
let source = r#"
language(1)
indicator("Base trades", id = "test.base-trades")
format(precision = "base_ticker_min_qty")
plot(chart.trade.available, id = "chart_available")
plot(chart.trade.buy_qty, id = "chart_buy")
plot(chart.trade.sell_qty, id = "chart_sell")
plot(chart.trade.buy_count, id = "chart_cbuy")
plot(chart.trade.sell_count, id = "chart_csell")
plot(chart.trade.count, id = "chart_trades")
plot(chart.directional_volume_available, id = "directional_available")
plot(chart.buy_qty, id = "buy_qty")
plot(chart.sell_qty, id = "sell_qty")
"#;
let compiled = compile(source, "base-trades.ind").expect("source should compile");
assert!(compiled.requirements.trades);
assert!(compiled.requirements.needs_directional_volume());
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let trade = TradeAggregate {
buy_qty: 7.0,
sell_qty: 3.0,
buy_count: 3,
sell_count: 1,
};
let mut populated = frame(0, 1, 10.0);
populated.chart.directional_volume_available = true;
populated.chart.buy_qty = trade.buy_qty;
populated.chart.sell_qty = trade.sell_qty;
populated.chart.trade = Some(trade);
let populated_output = instance
.evaluate_closed(populated)
.expect("populated trade frame should evaluate");
assert_eq!(
plot_value(&populated_output, "chart_available"),
&Value::Bool(true)
);
assert_eq!(
plot_value(&populated_output, "chart_buy"),
&Value::Float(7.0)
);
assert_eq!(
plot_value(&populated_output, "chart_sell"),
&Value::Float(3.0)
);
assert_eq!(plot_value(&populated_output, "chart_cbuy"), &Value::Int(3));
assert_eq!(plot_value(&populated_output, "chart_csell"), &Value::Int(1));
assert_eq!(
plot_value(&populated_output, "chart_trades"),
&Value::Int(4)
);
assert_eq!(
plot_value(&populated_output, "directional_available"),
&Value::Bool(true)
);
assert_eq!(plot_value(&populated_output, "buy_qty"), &Value::Float(7.0));
assert_eq!(
plot_value(&populated_output, "sell_qty"),
&Value::Float(3.0)
);
for (name, expression) in [
("bare-close", "close"),
("bare-directional", "directional_volume_available"),
("vbuy", "vbuy"),
("vsell", "vsell"),
("trades", "trades"),
("cbuy", "cbuy"),
("csell", "csell"),
] {
let source = format!(
"language(1)\nindicator(\"Removed\", id = \"community.removed-{name}\")\nformat(precision = \"base_ticker_min_qty\")\nplot({expression}, id = \"value\")"
);
assert!(
compile(&source, format!("{name}.ind")).is_err(),
"{name} should fail"
);
}
}
#[test]
fn signed_table_columns_require_typed_color_bindings() {
let source = r#"
language(1)
indicator("Signed table", id = "test.signed-table")
display(view = "table")
delta = chart.close - chart.open
plot(delta, id = "delta")
style("positive", title = "Positive values")
style("negative", title = "Negative values")
column(title = "Delta", source = "delta", color_mode = "signed",
positive_color_slot = "positive", negative_color_slot = "negative")
"#;
let compiled = compile(source, "signed-table.ind").expect("table should compile");
assert_eq!(compiled.descriptor.presentation.columns.len(), 1);
assert!(matches!(
compiled.descriptor.presentation.columns[0].color,
PresentationColumnColor::Signed {
ref positive_slot,
ref negative_slot
} if positive_slot == "positive" && negative_slot == "negative"
));
for (source, expected_message) in [
(
r#"
language(1)
indicator("Invalid table", id = "test.invalid-table")
display(view = "table")
plot(chart.close, id = "value")
style("positive")
style("negative")
column(title = "Value", source = "value", color_mode = "signed",
color_slot = "positive", positive_color_slot = "positive",
negative_color_slot = "negative")
"#,
"cannot provide color_slot",
),
(
r#"
language(1)
indicator("Invalid table", id = "test.invalid-table")
display(view = "table")
plot(chart.close, id = "value")
style("positive")
column(title = "Value", source = "value", color_mode = "signed",
positive_color_slot = "positive")
"#,
"require negative_color_slot",
),
] {
let diagnostics = compile(source, "invalid-table.ind")
.expect_err("invalid table colors should be diagnosed");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(expected_message))
);
}
}
#[test]
fn unsupported_execution_paths_are_rejected_at_compile_time() {
for (name, expression) in [
(
"request",
"request.security(\"ETHUSDT\", \"1h\", chart.close)",
),
("drawing", "box.new(1, 2, 3, 4)"),
("map", "map.new()"),
] {
let source = format!(
"language(1)\nindicator(\"Unsupported\", id = \"test.{name}\")\nvalue = {expression}\nplot(value)"
);
let diagnostics = compile(&source, format!("{name}.ind"))
.expect_err("unsupported paths must fail compilation");
assert!(
diagnostics
.iter()
.any(|diagnostic| { diagnostic.message.contains("not available") })
);
}
let diagnostics = compile(
"language(1)\nindicator(\"Intrabar\", id = \"test.intrabar\")\nvarip ticks = 0\nplot(ticks)",
"intrabar.ind",
)
.expect_err("varip must fail compilation");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("varip"))
);
}
#[test]
fn presentation_scene_is_compiled_with_owned_defaults() {
let source = r#"
language(1)
indicator("Scene", id = "community.scene")
display(panel_title = "Panel", layer_name = "Layer")
format(scale = "fit_visible_include_zero", precision = "fixed_step:0.5",
axis_labels = "abbreviated", header_labels = "commas", max_decimals = 2)
period = input.int(3, "Period", min = 1, max = 20, step = 1)
value = ta.sma(chart.close, period)
upper = value + 1
lower = value - 1
rising = value > value[1]
plot(value, id = "value", title = "Value", precision = "decimals:2",
width = 1.5, offset = 1, color_slot = "base", style_by = "rising",
true_color_slot = "bull", false_color_slot = "bear")
plot(upper, id = "upper", title = "Upper", color_slot = "band")
plot(lower, id = "lower", title = "Lower", color_slot = "band")
plot(rising, id = "rising", title = "Rising", layer = false)
style("base", title = "Base", role = "neutral", shade = "base")
style("bull", title = "Bull", role = "success", shade = "base")
style("bear", title = "Bear", role = "danger", shade = "base")
style("band", title = "Band", role = "secondary", shade = "soft")
mark(title = "Histogram", source = "value", kind = "histogram", color_slot = "base",
highlight_by = "rising", highlight_color_slot = "bull",
requires = "directional_volume", fallback = "line")
mark(title = "Line", source = "value", kind = "line", color_slot = "base")
fill(upper = "upper", lower = "lower", color_slot = "band", opacity = 0.2,
style_by = "rising", true_color_slot = "bull", false_color_slot = "bear")
readout(title = "Signal", source = "value", color_slot = "base")
segment(value, upper, bar_index, bar_index, id = "projection",
title = "Projection", color_slot = "band", width = 1.5)
"#;
let compiled = compile(source, "scene.ind").expect("presentation scene should compile");
let presentation = &compiled.descriptor.presentation;
assert!(!compiled.requirements.needs_directional_volume());
assert_eq!(
presentation.value_precision,
Some(PresentationPrecision::FixedStep(0.5))
);
assert_eq!(
compiled.descriptor.plots[0].output_precision,
Some(OutputPrecision::Decimals(2))
);
assert_eq!(presentation.panel_title.as_deref(), Some("Panel"));
assert_eq!(presentation.layer_name.as_deref(), Some("Layer"));
assert_eq!(presentation.styles.len(), 4);
assert_eq!(presentation.layers.len(), 3);
assert_eq!(
presentation
.layers
.iter()
.map(|layer| layer.channel.as_str())
.collect::<Vec<_>>(),
vec!["value", "upper", "lower"]
);
assert_eq!(
compiled.descriptor.plots[0].color_slot.as_deref(),
Some("base")
);
assert_eq!(compiled.descriptor.plots[0].offset_bars, 1);
assert_eq!(
compiled.descriptor.plots[3].readout,
ReadoutBehavior::Hidden,
"boolean plots are output-only by default"
);
assert_eq!(presentation.marks[0].kind, PresentationMarkKind::Histogram);
assert_eq!(
presentation.marks[0].required_input,
Some(PresentationInputSource::DirectionalVolume)
);
assert_eq!(
presentation.marks[0].fallback_mark,
Some(PresentationMarkKind::Line)
);
assert_eq!(
presentation.marks[0].highlight_by.as_deref(),
Some("rising")
);
assert_eq!(
presentation.marks[0].highlight_color_slot.as_deref(),
Some("bull")
);
assert_eq!(presentation.fills[0].style_by.as_deref(), Some("rising"));
assert_eq!(presentation.fills[0].opacity, 0.2);
assert_eq!(presentation.readouts[0].channel, "value");
assert_eq!(presentation.segments[0].id, "projection");
}
#[test]
fn mark_highlight_bindings_are_validated() {
let cases = [
(
"numeric",
r#"highlight_by = "value", highlight_color_slot = "warning""#,
"highlight_by must reference a boolean output",
),
(
"missing-color",
r#"highlight_by = "rising""#,
"highlight_by requires highlight_color_slot",
),
(
"line",
r#"highlight_by = "rising", highlight_color_slot = "warning""#,
"highlight_by requires a bar mark",
),
];
for (name, binding, expected_message) in cases {
let kind = if name == "line" { "line" } else { "histogram" };
let source = format!(
r#"
language(1)
indicator("Highlight", id = "community.highlight-{name}")
format(precision = "base_ticker_min_tick")
value = chart.close
rising = chart.close > chart.open
style("base")
style("warning", role = "warning")
plot(value, id = "value", title = "Value", color_slot = "base")
plot(rising, id = "rising", layer = false)
mark(title = "Mark", source = "value", kind = "{kind}", color_slot = "base", {binding})
"#
);
let diagnostics = compile(&source, format!("{name}.ind"))
.expect_err("invalid mark highlight binding should be diagnosed");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(expected_message)),
"expected {expected_message}, got {diagnostics:?}"
);
}
}
#[test]
fn numeric_plot_panels_require_format_precision() {
let diagnostics = compile(
"language(1)\nindicator(\"Missing precision\", id = \"test.missing-precision\")\nplot(chart.close, id = \"value\")\n",
"missing-precision.ind",
)
.expect_err("numeric plot panels should require value precision");
assert!(
diagnostics
.iter()
.any(|diagnostic| { diagnostic.message.contains("must declare format(precision") })
);
let explicit = compile(
"language(1)\nindicator(\"Explicit precision\", id = \"test.explicit-precision\")\nformat(precision = \"fixed_step:0.01\")\nplot(chart.close, id = \"value\")\n",
"explicit-precision.ind",
)
.expect("numeric plot panels with value precision should compile");
assert_eq!(
explicit.descriptor.presentation.value_precision,
Some(PresentationPrecision::FixedStep(0.01))
);
compile(
"language(1)\nindicator(\"Overlay\", id = \"test.overlay\")\ndisplay(view = \"primary_overlay\")\nplot(chart.close, id = \"value\")\n",
"overlay.ind",
)
.expect("primary overlays should not require panel precision");
compile(
"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",
"table.ind",
)
.expect("table views should not require panel precision");
}
#[test]
fn display_and_format_declarations_are_unique_and_precede_outputs() {
for (source, message) in [
(
"language(1)\nindicator(\"Duplicate display\", id = \"test.duplicate-display\")\ndisplay()\ndisplay()\nplot(chart.close, id = \"value\")\n",
"duplicate display declaration",
),
(
"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",
"duplicate format declaration",
),
(
"language(1)\nindicator(\"Late display\", id = \"test.late-display\")\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close, id = \"value\")\ndisplay()\n",
"display declarations must appear before output declarations",
),
(
"language(1)\nindicator(\"Late format\", id = \"test.late-format\")\nplot(chart.close, id = \"value\")\nformat(precision = \"base_ticker_min_tick\")\n",
"format declarations must appear before output declarations",
),
] {
let diagnostics = compile(source, "metadata.ind")
.expect_err("invalid metadata declarations should be diagnosed");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(message))
);
}
}
#[test]
fn generic_plot_accepts_scalar_styles_and_structured_constructors() {
let scalar_source = r#"
language(1)
indicator("Scalar styles", id = "test.scalar-styles")
format(precision = "base_ticker_min_tick")
plot(chart.close, id = "line", style = "line")
plot(chart.close, id = "step", style = "step")
plot(chart.close, id = "histogram", style = "histogram")
plot(chart.close, id = "area", style = "area")
"#;
compile(scalar_source, "scalar-styles.ind").expect("scalar plot styles should compile");
let structured_source = r#"
language(1)
indicator("Structured plots", id = "test.structured-plots")
display(view = "primary_overlay")
format(precision = "base_ticker_min_tick")
plotcandle(chart.open, chart.high, chart.low, chart.close, id = "candles")
plotohlc(chart.open, chart.high, chart.low, chart.close, id = "ohlc")
plotrange(chart.high, chart.low, id = "range")
broken_cloud(chart.high, chart.low, id = "broken")
"#;
compile(structured_source, "structured-plots.ind")
.expect("structured plot constructors should compile");
}
#[test]
fn generic_plot_rejects_structured_styles() {
for (style, constructors) in [
("candles", "plotcandle or plotohlc"),
("bars", "plotcandle or plotohlc"),
("cloud", "plotrange or broken_cloud"),
("broken_cloud", "plotrange or broken_cloud"),
] {
let source = format!(
"language(1)\nindicator(\"Invalid plot\", id = \"test.invalid-{style}\")\nformat(precision = \"base_ticker_min_tick\")\nplot(chart.close, id = \"value\", style = \"{style}\")"
);
let diagnostics = compile(&source, format!("invalid-{style}.ind"))
.expect_err("structured styles should not be accepted by generic plot");
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains(&format!("plot style '{style}'"))
&& diagnostic.message.contains("requires a structured plot")
&& diagnostic.message.contains(constructors)
}));
}
}
#[test]
fn unknown_presentation_input_is_rejected_without_becoming_an_indicator_requirement() {
let diagnostics = compile(
r#"
language(1)
indicator("Unknown input", id = "community.unknown-input")
format(precision = "base_ticker_min_tick")
value = chart.close
style("base", title = "Base")
plot(value, id = "value", title = "Value", color_slot = "base")
mark(title = "Bars", source = "value", kind = "histogram", color_slot = "base",
requires = "not_a_real_input")
"#,
"unknown-input.ind",
)
.expect_err("unknown presentation input should be diagnosed");
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("unknown presentation input not_a_real_input")
}));
}
#[test]
fn invalid_presentation_fallbacks_are_rejected() {
let cases = [
(
"unknown",
r#"fallback = "not_a_real_mark""#,
"unknown presentation mark not_a_real_mark",
),
(
"missing",
r#"fallback = "line""#,
"references unknown fallback mark",
),
(
"self",
r#"fallback = "histogram""#,
"cannot fall back to itself",
),
];
for (name, fallback, expected_message) in cases {
let source = format!(
r#"
language(1)
indicator("Fallback", id = "community.fallback-{name}")
format(precision = "base_ticker_min_tick")
value = chart.close
style("base")
plot(value, id = "value", title = "Value", color_slot = "base")
mark(title = "Histogram", source = "value", kind = "histogram",
color_slot = "base", {fallback})
"#
);
let diagnostics = compile(&source, format!("{name}.ind"))
.expect_err("invalid presentation fallback should be diagnosed");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(expected_message))
);
}
let diagnostics = compile(
r#"
language(1)
indicator("Input fallback", id = "community.input-fallback")
format(precision = "base_ticker_min_tick")
value = chart.close
style("base")
plot(value, id = "value", title = "Value", color_slot = "base")
mark(title = "Overlay", source = "value", kind = "histogram",
color_slot = "base", requires = "directional_volume", fallback = "line")
mark(title = "Line", source = "value", kind = "line", color_slot = "base",
requires = "trades")
"#,
"input-fallback.ind",
)
.expect_err("input-dependent fallback should be diagnosed");
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("fallback mark Line cannot require input")
}));
}
#[test]
fn sparse_plot_declarations_materialize_the_same_layer_defaults() {
let explicit = compile(
r#"
language(1)
indicator("Sparse", id = "community.sparse")
display(view = "plot", panel_title = "Sparse", layer_name = "Sparse")
format(precision = "base_ticker_min_tick", scale = "fit_visible")
value = chart.close
style("value", title = "value", role = "neutral", shade = "base")
plot(value, id = "value", title = "Value", pane = "indicator",
scale = "price", style = "line", display = "value", width = 1,
offset = 0, color_slot = "value", overlay = true,
data_kind = "scalar", mark = "line", axis = "secondary", layer = true)
"#,
"explicit.ind",
)
.expect("explicit defaults should compile");
let sparse = compile(
r#"
language(1)
indicator("Sparse", id = "community.sparse")
format(precision = "base_ticker_min_tick")
value = chart.close
style("value")
plot(value, id = "value", title = "Value")
"#,
"sparse.ind",
)
.expect("sparse declarations should compile");
assert_eq!(
explicit.descriptor.presentation.layers,
sparse.descriptor.presentation.layers
);
assert_eq!(
explicit.descriptor.presentation.styles,
sparse.descriptor.presentation.styles
);
assert_eq!(
explicit.descriptor.plots[0].pane,
sparse.descriptor.plots[0].pane
);
assert_eq!(
explicit.descriptor.plots[0].scale,
sparse.descriptor.plots[0].scale
);
assert_eq!(
explicit.descriptor.plots[0].width,
sparse.descriptor.plots[0].width
);
assert_eq!(
explicit.descriptor.plots[0].overlay,
sparse.descriptor.plots[0].overlay
);
assert_eq!(
explicit.descriptor.plots[0].layer,
sparse.descriptor.plots[0].layer
);
}
#[test]
fn na_comparisons_propagate_and_switch_falls_back() {
let source = r#"
language(1)
indicator("Missing values", id = "community.missing-values")
format(precision = "base_ticker_min_tick")
plot(na == na, id = "equal")
plot(switch na { na => 1, default => 2 }, id = "switch")
"#;
let compiled = compile(source, "missing-values.ind").expect("source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("frame should evaluate");
assert_eq!(plot_value(&output, "equal"), &Value::Na);
assert_eq!(plot_value(&output, "switch"), &Value::Int(2));
}
#[test]
fn state_initializers_retry_na_and_warm_up() {
let source = r#"
language(1)
indicator("State seed", id = "community.state-seed")
format(precision = "base_ticker_min_tick")
seed = ta.sma(chart.close, 2)
state average = seed
plot(average, id = "average")
"#;
let compiled = compile(source, "state-seed.ind").expect("source should compile");
assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(2));
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("first frame should evaluate"),
"average"
),
&Value::Na
);
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(1, 2, 20.0))
.expect("second frame should evaluate"),
"average"
),
&Value::Float(15.0)
);
}
#[test]
fn explicit_na_state_updates_remain_authoritative() {
let source = r#"
language(1)
indicator("Explicit missing", id = "community.explicit-missing")
format(precision = "base_ticker_min_tick")
state value = chart.close
if bar_index == 0 {
value := na
}
plot(value, id = "value")
"#;
let compiled = compile(source, "explicit-missing.ind").expect("source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("first frame should evaluate");
let output = instance
.evaluate_closed(frame(1, 2, 20.0))
.expect("second frame should evaluate");
assert_eq!(plot_value(&output, "value"), &Value::Na);
}
#[test]
fn mutable_bindings_keep_compatible_types_across_updates_and_branches() {
for source in [
r#"
language(1)
indicator("Invalid state", id = "test.invalid-state")
state value = 0
value := true
plot(value, id = "value")
"#,
r#"
language(1)
indicator("Invalid local", id = "test.invalid-local")
value = 0
value := true
plot(value, id = "value")
"#,
r#"
language(1)
indicator("Invalid conditional", id = "test.invalid-conditional")
state value = 0
if true {
value := true
}
plot(value, id = "value")
"#,
] {
let diagnostics = compile(source, "invalid-binding.ind")
.expect_err("incompatible binding updates should fail");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("incompatible types"))
);
}
let source = r#"
language(1)
indicator("Widened state", id = "test.widened-state")
format(precision = "base_ticker_min_tick")
state value = 0
value := chart.close
value := na
plot(value, id = "value")
"#;
let compiled = compile(source, "widened-state.ind").expect("compatible updates compile");
assert_eq!(
compiled.descriptor.plots[0].value_type,
PlotValueType::Number
);
let source = r#"
language(1)
indicator("Conditional value", id = "test.conditional-value")
state value = na
if true {
value := true
} else {
value := false
}
plot(value, id = "value")
"#;
let compiled = compile(source, "conditional-value.ind").expect("Any should resolve");
assert_eq!(
compiled.descriptor.plots[0].value_type,
PlotValueType::Boolean
);
for source in [
r#"
language(1)
indicator("While value", id = "test.while-value")
state value = na
while false {
value := true
}
plot(value, id = "value")
"#,
r#"
language(1)
indicator("For value", id = "test.for-value")
state value = na
for index = 0 to 1 {
value := true
}
plot(value, id = "value")
"#,
] {
let compiled = compile(source, "loop-value.ind").expect("loop joins compile");
assert_eq!(
compiled.descriptor.plots[0].value_type,
PlotValueType::Boolean
);
}
}
#[test]
fn result_branches_and_binary_operands_are_checked() {
for source in [
r#"
language(1)
indicator("Invalid select", id = "test.invalid-select")
plot(select(true, 1, false), id = "value")
"#,
r#"
language(1)
indicator("Invalid coalesce", id = "test.invalid-coalesce")
plot(coalesce(1, false), id = "value")
"#,
r#"
language(1)
indicator("Invalid switch", id = "test.invalid-switch")
plot(switch 1 { 1 => 1, default => false }, id = "value")
"#,
r#"
language(1)
indicator("Invalid order", id = "test.invalid-order")
plot(true < false, id = "value")
"#,
r#"
language(1)
indicator("Invalid logical", id = "test.invalid-logical")
plot(1 && true, id = "value")
"#,
] {
let diagnostics = compile(source, "invalid-expression.ind")
.expect_err("invalid expression types should fail");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("incompatible")
|| diagnostic.message.contains("operands"))
);
}
let source = r#"
language(1)
indicator("Valid comparisons", id = "test.valid-comparisons")
plot(chart.close < chart.open, id = "numeric")
plot(chart.time < chart.time[1], id = "time")
"#;
let compiled = compile(source, "valid-comparisons.ind").expect("valid comparisons compile");
assert_eq!(
compiled.descriptor.plots[0].value_type,
PlotValueType::Boolean
);
assert_eq!(
compiled.descriptor.plots[1].value_type,
PlotValueType::Boolean
);
}
#[test]
fn cumulative_overflow_becomes_missing_without_poisoning_state() {
let source = r#"
language(1)
indicator("Cumulative overflow", id = "test.cumulative-overflow")
format(precision = "base_ticker_min_tick")
plot(ta.cum(chart.close), id = "value")
"#;
let compiled = compile(source, "cumulative-overflow.ind").expect("source should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(0, 1, f64::MAX))
.expect("first frame should evaluate"),
"value"
),
&Value::Float(f64::MAX)
);
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(1, 2, f64::MAX))
.expect("overflow should remain recoverable"),
"value"
),
&Value::Na
);
assert_eq!(
plot_value(
&instance
.evaluate_closed(frame(2, 3, -f64::MAX))
.expect("state should remain usable"),
"value"
),
&Value::Float(0.0)
);
}
#[test]
fn functions_are_numeric_and_typed_before_their_bodies_compile() {
let source = r#"
language(1)
indicator("Function", id = "community.function")
format(precision = "base_ticker_min_tick")
plot(identity(chart.close), id = "value")
fn identity(value) {
return value
}
"#;
let compiled = compile(source, "function.ind").expect("numeric function should compile");
let mut instance = compiled
.instantiate(IndicatorConfig::default())
.expect("inputs should bind");
let output = instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("frame should evaluate");
assert_eq!(plot_value(&output, "value"), &Value::Float(10.0));
let diagnostics = compile(
r#"
language(1)
indicator("Invalid function", id = "community.invalid-function")
format(precision = "base_ticker_min_tick")
plot(identity(chart.close > chart.open), id = "value")
fn identity(value) {
return value
}
"#,
"invalid-function.ind",
)
.expect_err("boolean function arguments should be rejected");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("must be numeric or na"))
);
}
#[test]
fn input_arguments_and_runtime_values_are_validated() {
for (source, message) in [
(
r#"
language(1)
indicator("Unknown input argument", id = "community.unknown-input")
value = input.int(1, "Value", unknown = 2)
plot(value, id = "value")
"#,
"unknown argument",
),
(
r#"
language(1)
indicator("Duplicate options", id = "community.duplicate-options")
value = input.string("a", "Value", options = ["a", "a"])
plot(chart.close, id = "value")
"#,
"is duplicated",
),
] {
let diagnostics = compile(source, "invalid-input.ind")
.expect_err("invalid input declarations should be diagnosed");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(message))
);
}
let compiled = compile(
r#"
language(1)
indicator("Bounded input", id = "community.bounded-input")
format(precision = "base_ticker_min_tick")
value = input.int(1, "Value", min = 1, max = 5)
plot(chart.close, id = "value")
"#,
"bounded-input.ind",
)
.expect("bounded input should compile");
let error = compiled
.instantiate(IndicatorConfig::default().with("value", InputValue::Int(6)))
.expect_err("out-of-range input should be rejected");
assert_eq!(error, PlanningError::InvalidInput("value".to_owned()));
let compiled = compile(
r#"
language(1)
indicator("Choice input", id = "community.choice-input")
format(precision = "base_ticker_min_tick")
method = input.string("a", "Method", options = ["a", "b"])
plot(chart.close, id = "value")
"#,
"choice-input.ind",
)
.expect("choice input should compile");
let error = compiled
.instantiate(
IndicatorConfig::default().with("method", InputValue::String("other".to_owned())),
)
.expect_err("unknown option should be rejected");
assert_eq!(error, PlanningError::InvalidInput("method".to_owned()));
}
#[test]
fn transient_collection_values_obey_heap_limits() {
let source = r#"
language(1)
indicator("Collection", id = "community.collection")
format(precision = "base_ticker_min_tick")
plot(array.size(array.push(array.new(), "12345")), id = "size")
"#;
let compiled = compile(source, "collection.ind").expect("source should compile");
let limits = RuntimeLimits {
max_heap_bytes: 4,
..RuntimeLimits::default()
};
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.expect("inputs should bind");
assert_eq!(
instance
.evaluate_closed(frame(0, 1, 10.0))
.expect_err("temporary string collection should hit the heap limit"),
RuntimeError::ResourceLimitExceeded("heap bytes")
);
}
#[test]
fn ta_window_limits_are_checked_without_counting_ta_state_as_collections() {
let source = r#"
language(1)
indicator("Window", id = "community.window")
format(precision = "base_ticker_min_tick")
value = ta.sma(chart.close, 6)
plot(value, id = "value")
"#;
let compiled = compile(source, "window.ind").expect("source should compile");
let limits = RuntimeLimits {
max_window_length: 5,
..RuntimeLimits::default()
};
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.expect("inputs should bind");
assert_eq!(
instance
.evaluate_closed(frame(0, 1, 10.0))
.expect_err("oversized TA window should hit the window limit"),
RuntimeError::ResourceLimitExceeded("window length")
);
let source = source.replace("ta.sma(chart.close, 6)", "ta.sma(chart.close, 2)");
let compiled = compile(&source, "window-collection.ind").expect("source should compile");
let limits = RuntimeLimits {
max_collection_elements: 0,
..RuntimeLimits::default()
};
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.expect("inputs should bind");
instance
.evaluate_closed(frame(0, 1, 10.0))
.expect("TA state should not consume collection elements");
}
#[test]
fn numeric_contracts_preserve_integers_and_match_warmup() {
let source = r#"language(1)
indicator("Numbers", id = "test.numbers")
format(precision = "fixed_step:0.1")
length = 2 + 2
plot(9007199254740992 + 1, id = "integer")
plot(1 / 2, id = "division")
plot(9223372036854775807 + 1, id = "overflow")
plot(9007199254740992 < 9007199254740993, id = "order")
plot(9007199254740993 != 9007199254740992.0, id = "mixed")
plot(ta.pivothigh(chart.high, length, 2), id = "pivot")"#;
let compiled = compile(source, "numbers.ind").unwrap();
assert_eq!(compiled.warmup_bars(&IndicatorConfig::default()), Some(7));
assert_eq!(compiled.program.plots[1].value_type, PlotValueType::Number);
let mut instance = compiled.instantiate(IndicatorConfig::default()).unwrap();
for index in 0..7 {
let output = instance
.evaluate_closed(frame(index, index as i64, 10.0))
.unwrap();
assert_eq!(
plot_value(&output, "integer"),
&Value::Int(9007199254740993)
);
assert_eq!(plot_value(&output, "division"), &Value::Float(0.5));
assert_eq!(plot_value(&output, "overflow"), &Value::Na);
assert_eq!(plot_value(&output, "order"), &Value::Bool(true));
assert_eq!(plot_value(&output, "mixed"), &Value::Bool(true));
if index == 6 {
assert_eq!(plot_value(&output, "pivot"), &Value::Float(10.0));
}
}
assert!(matches!(
instance.evaluate_closed(frame(6, 6, 99.0)),
Err(RuntimeError::InvalidValue {
operation: "closed frame order"
})
));
assert_eq!(instance.committed_index(), Some(6));
instance.preview_open(frame(7, 7, 20.0)).unwrap();
instance.evaluate_closed(frame(7, 7, 20.0)).unwrap();
for (integer, float, order) in [
(i64::MAX, 9223372036854775808.0, std::cmp::Ordering::Less),
(i64::MIN, -9223372036854775808.0, std::cmp::Ordering::Equal),
(-1, -1.5, std::cmp::Ordering::Greater),
(1, 1.5, std::cmp::Ordering::Less),
] {
assert_eq!(
Value::Int(integer).numeric_compare(&Value::Float(float)),
Some(order)
);
assert_eq!(
Value::Float(float).numeric_compare(&Value::Int(integer)),
Some(order.reverse())
);
}
for (left, operator, right, expected) in [
(i64::MIN, hir::BinaryOperator::Subtract, 1, Value::Na),
(i64::MAX, hir::BinaryOperator::Multiply, 2, Value::Na),
(i64::MIN, hir::BinaryOperator::Remainder, -1, Value::Na),
(9, hir::BinaryOperator::Remainder, 4, Value::Int(1)),
(9, hir::BinaryOperator::Divide, 0, Value::Na),
(9, hir::BinaryOperator::Remainder, 0, Value::Na),
] {
assert_eq!(
Value::Int(left).binary(operator, Value::Int(right)),
expected
);
}
for kind in ["int", "time"] {
let source = format!(
"language(1)\nindicator(\"Invalid\", id = \"test.invalid\")\nvalue = input.{kind}(2.9)\nplot(chart.close)"
);
assert!(
compile(&source, "invalid.ind")
.unwrap_err()
.iter()
.any(|error| error.message.contains("default has the wrong type"))
);
}
}
#[test]
fn dynamic_output_boundaries_reject_wrong_kinds() {
for body in [
"plot(array.get(array.new(array.new(1, 2)), 0))",
"plotrange(array.get(array.new(false), 0), chart.low)",
"plotcandle(chart.open, chart.high, chart.low, array.get(array.new(\"wrong\"), 0))",
"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)",
"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))",
"plot(chart.close, color = array.get(array.new(1), 0))",
] {
let source = format!(
"language(1)\nindicator(\"Boundary\", id = \"test.boundary\")\nformat(precision = \"fixed_step:1\")\n{body}"
);
let compiled = compile(&source, "boundary.ind").unwrap();
let error = compiled
.instantiate(IndicatorConfig::default())
.unwrap()
.evaluate_closed(frame(0, 0, 10.0))
.unwrap_err();
assert!(
matches!(error, RuntimeError::InvalidOutput { .. }),
"{body}: {error}"
);
}
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();
let output = compiled
.instantiate(IndicatorConfig::default())
.unwrap()
.evaluate_closed(frame(0, 0, 10.0))
.unwrap();
assert_eq!(plot_value(&output, "number"), &Value::Int(1));
assert_eq!(plot_value(&output, "missing"), &Value::Na);
}
#[test]
fn stateful_ta_requires_per_bar_execution_paths() {
for body in [
"plot(select(chart.close > 0, ta.sma(chart.close, 2), na))",
"value = na\nfor index = 0 to 1 { value := ta.sma(chart.close, 2) }\nplot(value)",
"plot(coalesce(chart.close, ta.ema(chart.close, 2)))",
"plot(chart.close > 0 && ta.ema(chart.close, 2) > 0)",
"state seed = ta.sma(chart.close, 2)\nplot(seed)",
"fn average(value) { return ta.ema(value, 2) }\nplot(select(chart.close > 0, average(chart.close), na))",
"fn inner(value) { return ta.ema(value, 2) }\nfn average(value) { return inner(value) }\nplot(select(chart.close > 0, average(chart.close), na))",
"fn average(value) { if value > 0 { return 0 } return ta.ema(value, 2) }\nplot(average(chart.close))",
"fn average(value = ta.ema(chart.close, 2)) { return value }\nplot(average())",
"plot(ta.sma(chart.close, 2)[1])",
"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)",
] {
let source = format!(
"language(1)\nindicator(\"Schedule\", id = \"test.schedule\")\nformat(precision = \"fixed_step:1\")\n{body}"
);
let errors = compile(&source, "schedule.ind").unwrap_err();
assert!(
errors.iter().any(|error| error
.message
.contains("stateful TA must execute once per bar")
&& error.span.end > error.span.start),
"{body}: {errors:?}"
);
}
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))";
let compiled = compile(source, "fixed.ind").unwrap();
let mut instance = compiled.instantiate(IndicatorConfig::default()).unwrap();
instance.evaluate_closed(frame(0, 0, 10.0)).unwrap();
instance.evaluate_closed(frame(1, 1, -100.0)).unwrap();
let output = instance.evaluate_closed(frame(2, 2, 20.0)).unwrap();
assert_eq!(plot_value(&output, "plot_0"), &Value::Float(-40.0));
}
#[test]
fn work_budget_charges_scans_and_collection_copies() {
let mut work = usize::MAX;
assert!(!runtime::charge_work(&mut work, usize::MAX, 1));
let compiled = compile("language(1)\nindicator(\"Work\", id = \"test.work\")\nformat(precision = \"fixed_step:1\")\nplot(ta.sma(chart.close, 100))", "work.ind").unwrap();
let limits = RuntimeLimits {
max_evaluated_nodes: 250,
..RuntimeLimits::untrusted()
};
let mut instance = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.unwrap();
assert!((0..100).any(|index| matches!(
instance.evaluate_closed(frame(index, index as i64, 10.0)),
Err(RuntimeError::ExecutionLimitExceeded)
)));
let destructure = format!(
"values = array.new(\"{}\", 0)\nfor index = 0 to 11 {{ (first, second) = values }}\nplot(chart.close)",
"x".repeat(1024)
);
for (body, expected) in [
(
"values = array.new()\nfor index = 0 to 127 { values := array.push(values, index) }\nplot(array.size(values))",
true,
),
(
"values = array.new()\nfor index = 0 to 127 { previous = values\n values := array.push(values, index) }\nplot(array.size(values))",
false,
),
(destructure.as_str(), false),
] {
let source = format!(
"language(1)\nindicator(\"Copy\", id = \"test.copy\")\nformat(precision = \"fixed_step:1\")\n{body}"
);
let compiled = compile(&source, "copy.ind").unwrap();
let limits = RuntimeLimits {
max_evaluated_nodes: 4000,
..RuntimeLimits::untrusted()
};
let result = compiled
.instantiate_with_limits(IndicatorConfig::default(), limits)
.unwrap()
.evaluate_closed(frame(0, 0, 10.0));
assert_eq!(result.is_ok(), expected, "{body}: {result:?}");
if !expected {
assert!(matches!(result, Err(RuntimeError::ExecutionLimitExceeded)));
}
}
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();
assert!(matches!(
compiled
.instantiate(IndicatorConfig::default())
.unwrap()
.evaluate_closed(frame(0, 0, 10.0)),
Err(RuntimeError::ResourceLimitExceeded("collection nesting"))
));
}
}