use alloc::vec::Vec;
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::render::RenderContext;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::special_widgets::finance::indicators;
use crate::widget::special_widgets::finance::layout::{IndexAxis, PlotArea, PriceAxis};
use crate::widget::special_widgets::finance::types::Bar;
use crate::widget::special_widgets::finance::types::PriceSeries;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IndicatorMode {
#[default]
Macd,
Rsi,
Stochastic,
MoneyFlowIndex,
Atr,
OnBalanceVolume,
}
impl IndicatorMode {
pub fn as_str(self) -> &'static str {
match self {
IndicatorMode::Macd => "macd",
IndicatorMode::Rsi => "rsi",
IndicatorMode::Stochastic => "stochastic",
IndicatorMode::MoneyFlowIndex => "money_flow_index",
IndicatorMode::Atr => "atr",
IndicatorMode::OnBalanceVolume => "on_balance_volume",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"macd" => IndicatorMode::Macd,
"rsi" => IndicatorMode::Rsi,
"stochastic" | "kdj" => IndicatorMode::Stochastic,
"money_flow_index" | "mfi" => IndicatorMode::MoneyFlowIndex,
"atr" => IndicatorMode::Atr,
"on_balance_volume" | "obv" => IndicatorMode::OnBalanceVolume,
_ => return None,
})
}
pub fn requires_volume(self) -> bool {
matches!(self, IndicatorMode::MoneyFlowIndex | IndicatorMode::OnBalanceVolume)
}
pub fn fixed_range(self) -> Option<(f64, f64)> {
match self {
IndicatorMode::Rsi | IndicatorMode::Stochastic | IndicatorMode::MoneyFlowIndex => {
Some((0.0, 100.0))
}
IndicatorMode::Macd | IndicatorMode::Atr | IndicatorMode::OnBalanceVolume => None,
}
}
pub fn reference_levels(self) -> &'static [f64] {
match self {
IndicatorMode::Rsi | IndicatorMode::Stochastic => &[30.0, 70.0],
IndicatorMode::MoneyFlowIndex => &[20.0, 80.0],
IndicatorMode::Macd => &[0.0],
IndicatorMode::Atr | IndicatorMode::OnBalanceVolume => &[],
}
}
}
struct PaneSeries {
values: Vec<f64>,
color: Color,
width: u32,
}
pub struct IndicatorChart {
base: BaseWidget,
series: PriceSeries,
mode: IndicatorMode,
macd_periods: (usize, usize, usize),
period: usize,
stochastic_periods: (usize, usize),
show_reference_levels: bool,
hovered_index: Option<usize>,
last_values: Vec<Vec<f64>>,
pub bar_clicked: crate::signal::Signal1<usize>,
pub bar_hovered: crate::signal::Signal1<usize>,
}
impl IndicatorChart {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::IndicatorChart, geometry, "IndicatorChart"),
series: PriceSeries::new(),
mode: IndicatorMode::default(),
macd_periods: (12, 26, 9),
period: 14,
stochastic_periods: (3, 3),
show_reference_levels: true,
hovered_index: None,
last_values: Vec::new(),
bar_clicked: crate::signal::Signal1::new(),
bar_hovered: crate::signal::Signal1::new(),
}
}
pub fn series(&self) -> &PriceSeries {
&self.series
}
pub fn set_series(&mut self, series: PriceSeries) {
self.series = series;
if self.hovered_index.is_some_and(|index| index >= self.series.len()) {
self.hovered_index = None;
}
self.base.request_redraw();
}
pub fn mode(&self) -> IndicatorMode {
self.mode
}
pub fn set_mode(&mut self, mode: IndicatorMode) {
self.mode = mode;
self.base.request_redraw();
}
pub fn macd_periods(&self) -> (usize, usize, usize) {
self.macd_periods
}
pub fn set_macd_periods(&mut self, fast: usize, slow: usize, signal: usize) {
self.macd_periods = (
if fast == 0 { 12 } else { fast },
if slow == 0 { 26 } else { slow },
if signal == 0 { 9 } else { signal },
);
self.base.request_redraw();
}
pub fn period(&self) -> usize {
self.period
}
pub fn set_period(&mut self, period: usize) {
self.period = period.max(1);
self.base.request_redraw();
}
pub fn stochastic_periods(&self) -> (usize, usize) {
self.stochastic_periods
}
pub fn set_stochastic_periods(&mut self, k_smoothing: usize, d_period: usize) {
self.stochastic_periods = (k_smoothing.max(1), d_period.max(1));
self.base.request_redraw();
}
pub fn shows_reference_levels(&self) -> bool {
self.show_reference_levels
}
pub fn set_show_reference_levels(&mut self, show: bool) {
self.show_reference_levels = show;
self.base.request_redraw();
}
pub fn hovered_index(&self) -> Option<usize> {
self.hovered_index
}
pub fn last_values(&self) -> &[Vec<f64>] {
&self.last_values
}
pub fn compute(&self) -> Vec<Vec<f64>> {
let closes = self.series.closes();
let highs = self.series.highs();
let lows = self.series.lows();
let volumes = self.series.volumes();
match self.mode {
IndicatorMode::Macd => {
let (fast, slow, signal) = self.macd_periods;
let (line, signal_line, histogram) = indicators::macd(&closes, fast, slow, signal);
alloc::vec![line, signal_line, histogram]
}
IndicatorMode::Rsi => alloc::vec![indicators::rsi(&closes, self.period)],
IndicatorMode::Stochastic => {
let (k_smoothing, d_period) = self.stochastic_periods;
let (k, d) = indicators::stochastic(
&highs,
&lows,
&closes,
self.period,
k_smoothing,
d_period,
);
alloc::vec![k, d]
}
IndicatorMode::MoneyFlowIndex => {
alloc::vec![indicators::money_flow_index(
&highs,
&lows,
&closes,
&volumes,
self.period
)]
}
IndicatorMode::Atr => alloc::vec![indicators::atr(&highs, &lows, &closes, self.period)],
IndicatorMode::OnBalanceVolume => {
alloc::vec![indicators::on_balance_volume(&closes, &volumes)]
}
}
}
fn pane_series(&self) -> Vec<PaneSeries> {
let values = self.compute();
let palette = match self.mode {
IndicatorMode::Macd => alloc::vec![
Color::rgb(33, 150, 243),
Color::rgb(255, 193, 7),
Color::rgb(120, 144, 156),
],
IndicatorMode::Rsi | IndicatorMode::MoneyFlowIndex | IndicatorMode::Atr => {
alloc::vec![Color::rgb(33, 150, 243)]
}
IndicatorMode::Stochastic => {
alloc::vec![Color::rgb(33, 150, 243), Color::rgb(255, 193, 7)]
}
IndicatorMode::OnBalanceVolume => alloc::vec![Color::rgb(156, 39, 176)],
};
values
.into_iter()
.enumerate()
.map(|(index, series)| PaneSeries {
values: series,
color: palette[index % palette.len()],
width: 1,
})
.collect()
}
fn axis_range(&self, series: &[PaneSeries]) -> (f64, f64) {
if let Some(fixed) = self.mode.fixed_range() {
return fixed;
}
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
for pane in series {
let (series_low, series_high) = indicators::series_extent(&pane.values);
if series_low.is_finite() {
low = low.min(series_low);
}
if series_high.is_finite() {
high = high.max(series_high);
}
}
if !low.is_finite() || !high.is_finite() {
return (0.0, 1.0);
}
if self.mode == IndicatorMode::Macd {
let magnitude = low.abs().max(high.abs());
if magnitude > 0.0 {
return (-magnitude, magnitude);
}
}
let span = high - low;
let pad = if span > 0.0 { span * 0.05 } else { high.abs().max(1.0) * 0.01 };
(low - pad, high + pad)
}
fn draw_reference_line(
context: &mut RenderContext,
area: &PlotArea,
price_axis: &PriceAxis,
level: f64,
) {
let y = price_axis.y_for(level);
let mut x = area.rect.x;
while x < area.right() {
let dash_end = (x + 4).min(area.right());
context.draw_line_stroke(
Point { x, y },
Point { x: dash_end, y },
Color::rgb(88, 96, 108),
1,
);
x += 8;
}
}
fn draw_series(
context: &mut RenderContext,
pane: &PaneSeries,
index_axis: &IndexAxis,
price_axis: &PriceAxis,
) {
let mut previous: Option<(i32, i32)> = None;
for (index, value) in pane.values.iter().enumerate() {
if !value.is_finite() || index >= index_axis.count {
previous = None;
continue;
}
let point = (index_axis.center_for(index), price_axis.y_for(*value));
if let Some(from) = previous {
context.draw_line_stroke(
Point { x: from.0, y: from.1 },
Point { x: point.0, y: point.1 },
pane.color,
pane.width,
);
}
previous = Some(point);
}
}
fn draw_histogram(
&self,
context: &mut RenderContext,
series: &[PaneSeries],
index_axis: &IndexAxis,
price_axis: &PriceAxis,
) {
if self.mode != IndicatorMode::Macd {
return;
}
let Some(histogram) = series.get(2) else {
return;
};
let zero_y = price_axis.y_for(0.0);
let body_width = index_axis.body_width();
for (index, value) in histogram.values.iter().enumerate() {
if !value.is_finite() || index >= index_axis.count {
continue;
}
let y = price_axis.y_for(*value);
let top = y.min(zero_y);
let height = (y - zero_y).unsigned_abs().max(1);
context.fill_rect(
Rect::new(index_axis.x_for(index), top, body_width as u32, height),
if *value >= 0.0 { Color::rgb(38, 166, 91) } else { Color::rgb(220, 68, 70) },
);
}
}
fn draw_labels(&self, context: &mut RenderContext, area: &PlotArea, price_axis: &PriceAxis) {
let (low, high) = (price_axis.low, price_axis.high);
for step in 0..=3 {
let fraction = step as f64 / 3.0;
let value = low + fraction * (high - low);
let y = price_axis.y_for(value);
let text = alloc::format!("{value:.2}");
let width = text.chars().count() as i32 * 7;
context.draw_text(
Point { x: area.rect.x - 6 - width, y: y - 6 },
&text,
&Font::simple("Sans", 10.0),
Color::rgb(150, 160, 172),
HorizontalAlignment::Left,
);
}
}
fn plot_area(&self) -> PlotArea {
PlotArea::with_margins(self.base.geometry(), 52, 8, 8, 8)
}
}
impl Widget for IndicatorChart {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn as_draw_mut(&mut self) -> Option<&mut dyn Draw> {
Some(self)
}
impl_widget_property_hooks!();
}
impl crate::event::EventHandler for IndicatorChart {
fn handle_event(&mut self, event: &crate::event::Event) {
use crate::event::Event;
match event {
Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
let area = self.plot_area();
let index_axis = area.index_axis(self.series.len());
let next = index_axis.index_at(pos.x).filter(|index| *index < self.series.len());
if next != self.hovered_index {
if let Some(index) = next {
self.bar_hovered.emit(index);
}
self.hovered_index = next;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } if self.hovered_index.take().is_some() => {
self.base.request_redraw();
}
Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
let area = self.plot_area();
let index_axis = area.index_axis(self.series.len());
if let Some(index) =
index_axis.index_at(pos.x).filter(|index| *index < self.series.len())
{
self.bar_clicked.emit(index);
}
}
_ => {}
}
self.base.handle_event(event);
}
}
impl Draw for IndicatorChart {
fn draw(&mut self, context: &mut RenderContext) {
let area = self.plot_area();
if area.rect.width == 0 || area.rect.height == 0 {
return;
}
context.fill_rect(area.rect, Color::rgb(18, 22, 28));
if self.series.is_empty() {
return;
}
let series = self.pane_series();
let (low, high) = self.axis_range(&series);
let price_axis = area.price_axis(low, high);
let index_axis = area.index_axis(self.series.len());
self.draw_histogram(context, &series, &index_axis, &price_axis);
if self.show_reference_levels {
for level in self.mode.reference_levels() {
Self::draw_reference_line(context, &area, &price_axis, *level);
}
}
let line_count = if self.mode == IndicatorMode::Macd { 2 } else { series.len() };
for pane in series.iter().take(line_count) {
Self::draw_series(context, pane, &index_axis, &price_axis);
}
self.draw_labels(context, &area, &price_axis);
if let Some(index) = self.hovered_index.filter(|index| *index < self.series.len()) {
let x = index_axis.center_for(index);
context.draw_line_stroke(
Point { x, y: area.rect.y },
Point { x, y: area.bottom() },
Color::rgb(120, 120, 120),
1,
);
let mut text = alloc::string::String::new();
for (position, pane) in series.iter().enumerate() {
if let Some(value) = pane.values.get(index).filter(|value| value.is_finite()) {
if !text.is_empty() {
text.push_str(" ");
}
text.push_str(&alloc::format!("{:.2}", value));
let _ = position;
}
}
if !text.is_empty() {
let width = text.chars().count() as i32 * 7;
let label_x = if x + 8 + width > area.right() { x - 8 - width } else { x + 8 };
context.draw_text(
Point { x: label_x.max(area.rect.x), y: area.rect.y + 2 },
&text,
&Font::simple("Sans", 11.0),
Color::rgb(230, 230, 230),
HorizontalAlignment::Left,
);
}
}
self.last_values = series.into_iter().map(|pane| pane.values).collect();
}
}
impl WidgetProperties for IndicatorChart {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"mode" => Ok(CapabilityValue::String(alloc::string::String::from(self.mode.as_str()))),
"period" => Ok(CapabilityValue::UInt(self.period as u64)),
"show_reference_levels" => Ok(CapabilityValue::Bool(self.show_reference_levels)),
"series" => {
let mut text = alloc::string::String::new();
for (index, bar) in self.series.bars().iter().enumerate() {
if index > 0 {
text.push(',');
}
text.push_str(&alloc::format!("{}", bar.close));
}
Ok(CapabilityValue::String(text))
}
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"mode" => {
let CapabilityValue::String(text) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
let mode =
IndicatorMode::from_name(&text).ok_or(CapabilityAccessError::OutOfRange)?;
self.set_mode(mode);
Ok(())
}
"period" => {
let CapabilityValue::UInt(period) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_period(period as usize);
Ok(())
}
"show_reference_levels" => {
let CapabilityValue::Bool(show) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_show_reference_levels(show);
Ok(())
}
"series" => {
let CapabilityValue::String(text) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
if self.mode.requires_volume() {
return Err(CapabilityAccessError::UnsupportedOnWidget);
}
let mut series = PriceSeries::new();
for token in text.split([',', ' ', ';']).filter(|token| !token.is_empty()) {
let price: f64 =
token.trim().parse().map_err(|_| CapabilityAccessError::OutOfRange)?;
series.push(Bar::flat(price, 0.0));
}
self.set_series(series);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["mode", "period", "show_reference_levels", "series", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"add_overlay" => Err(CapabilityAccessError::UnsupportedOnWidget),
_ if name.starts_with("set_") => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::special_widgets::finance::types::fixtures;
#[test]
fn an_indicator_pane_carries_its_series() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
assert!(chart.series().is_empty());
chart.set_series(fixtures::sample_series(60));
assert_eq!(chart.series().len(), 60);
}
#[test]
fn macd_is_the_default_mode() {
let chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
assert_eq!(chart.mode(), IndicatorMode::Macd);
assert_eq!(chart.macd_periods(), (12, 26, 9), "the standard periods");
assert_eq!(chart.period(), 14);
assert_eq!(chart.stochastic_periods(), (3, 3));
}
#[test]
fn mode_names_round_trip() {
for mode in [
IndicatorMode::Macd,
IndicatorMode::Rsi,
IndicatorMode::Stochastic,
IndicatorMode::MoneyFlowIndex,
IndicatorMode::Atr,
IndicatorMode::OnBalanceVolume,
] {
let name = mode.as_str();
assert_eq!(IndicatorMode::from_name(name), Some(mode), "{name} must round-trip");
}
assert_eq!(IndicatorMode::from_name("kdj"), Some(IndicatorMode::Stochastic));
assert_eq!(IndicatorMode::from_name("mfi"), Some(IndicatorMode::MoneyFlowIndex));
assert_eq!(IndicatorMode::from_name("obv"), Some(IndicatorMode::OnBalanceVolume));
assert_eq!(IndicatorMode::from_name("nonsense"), None);
}
#[test]
fn bounded_modes_have_a_fixed_range() {
for mode in [IndicatorMode::Rsi, IndicatorMode::Stochastic, IndicatorMode::MoneyFlowIndex] {
assert_eq!(mode.fixed_range(), Some((0.0, 100.0)), "{mode:?} is bounded");
}
for mode in [IndicatorMode::Macd, IndicatorMode::Atr, IndicatorMode::OnBalanceVolume] {
assert_eq!(mode.fixed_range(), None, "{mode:?} scales to its data");
}
}
#[test]
fn reference_levels_follow_the_mode() {
assert_eq!(IndicatorMode::Rsi.reference_levels(), &[30.0, 70.0]);
assert_eq!(IndicatorMode::Stochastic.reference_levels(), &[30.0, 70.0]);
assert_eq!(IndicatorMode::MoneyFlowIndex.reference_levels(), &[20.0, 80.0]);
assert_eq!(IndicatorMode::Macd.reference_levels(), &[0.0]);
assert!(IndicatorMode::Atr.reference_levels().is_empty());
}
#[test]
fn macd_computes_three_series() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
chart.set_series(fixtures::sample_series(120));
let values = chart.compute();
assert_eq!(values.len(), 3, "the line, the signal and the histogram");
for series in &values {
assert_eq!(series.len(), 120, "every series must align with the bars");
}
}
#[test]
fn every_mode_computes_aligned_series() {
for mode in [
IndicatorMode::Macd,
IndicatorMode::Rsi,
IndicatorMode::Stochastic,
IndicatorMode::MoneyFlowIndex,
IndicatorMode::Atr,
IndicatorMode::OnBalanceVolume,
] {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
chart.set_series(fixtures::sample_series(90));
chart.set_mode(mode);
let values = chart.compute();
assert!(!values.is_empty(), "{mode:?} must compute something");
for series in &values {
assert_eq!(series.len(), 90, "{mode:?} must align with the bars");
}
}
}
#[test]
fn rsi_output_fits_its_declared_range() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
chart.set_series(fixtures::sample_series(120));
chart.set_mode(IndicatorMode::Rsi);
let values = chart.compute();
for value in values[0].iter().filter(|value| value.is_finite()) {
assert!((0.0..=100.0).contains(value), "RSI out of its declared range: {value}");
}
}
#[test]
fn a_zero_period_is_floored() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
chart.set_period(0);
assert_eq!(chart.period(), 1, "a zero period would make every indicator blank");
chart.set_stochastic_periods(0, 0);
assert_eq!(chart.stochastic_periods().1, 1, "a zero %D period is not a period");
}
#[test]
fn a_zero_macd_period_falls_back() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
chart.set_macd_periods(0, 0, 0);
assert_eq!(chart.macd_periods(), (12, 26, 9), "a zero-period EMA is the identity");
}
#[test]
fn reference_levels_can_be_hidden() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 480, 160));
assert!(chart.shows_reference_levels(), "shown by default");
chart.set_show_reference_levels(false);
assert!(!chart.shows_reference_levels());
}
#[test]
fn an_empty_series_draws_without_panicking() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 320, 160));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn every_mode_draws() {
for mode in [
IndicatorMode::Macd,
IndicatorMode::Rsi,
IndicatorMode::Stochastic,
IndicatorMode::MoneyFlowIndex,
IndicatorMode::Atr,
IndicatorMode::OnBalanceVolume,
] {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 400, 160));
chart.set_series(fixtures::sample_series(80));
chart.set_mode(mode);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(400, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
}
#[test]
fn a_malformed_series_draws_in_every_mode() {
let mut series = crate::widget::special_widgets::finance::types::PriceSeries::new();
for index in 0..30 {
if index % 7 == 0 {
series.push(crate::widget::special_widgets::finance::types::Bar::new(
f64::NAN,
f64::NAN,
f64::NAN,
f64::NAN,
f64::NAN,
));
} else {
series.push(crate::widget::special_widgets::finance::types::Bar::flat(
100.0 + index as f64,
0.0,
));
}
}
for mode in [
IndicatorMode::Macd,
IndicatorMode::Rsi,
IndicatorMode::Stochastic,
IndicatorMode::MoneyFlowIndex,
IndicatorMode::Atr,
IndicatorMode::OnBalanceVolume,
] {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 400, 160));
chart.set_series(series.clone());
chart.set_mode(mode);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(400, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
}
#[test]
fn the_reported_values_match_the_computed_ones() {
let mut chart = IndicatorChart::new(Rect::new(0, 0, 400, 160));
chart.set_series(fixtures::sample_series(60));
chart.set_mode(IndicatorMode::Rsi);
let expected = chart.compute();
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(400, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
let reported = chart.last_values();
assert_eq!(reported.len(), expected.len(), "the series count must match");
for (reported_series, expected_series) in reported.iter().zip(expected.iter()) {
assert_eq!(reported_series.len(), expected_series.len());
for (index, (left, right)) in
reported_series.iter().zip(expected_series.iter()).enumerate()
{
if left.is_nan() && right.is_nan() {
continue;
}
assert!(
(left - right).abs() < 1e-9,
"index {index}: reported {left} but computed {right}"
);
}
}
assert!(
reported[0].iter().any(|value| value.is_finite()),
"the fixture must produce at least one real reading, or this proves nothing"
);
}
}