Custom horizontal scales
The horizontal axis can represent option strikes, bond maturities or another
ordered key. The chart still stores a timestamp in each
Bar::time
and retains logical row indices for data access. A
HorizontalScale
chooses tick marks and formats horizontal labels. Numeric scales space samples
in proportion to their keys; chronological charts retain ordinal spacing. The
vertical price scale is independent.
Encode numeric keys
Section titled “Encode numeric keys”key_time(key)
encodes a number as seconds from the Unix epoch, rounded to nanoseconds.
time_key(time)
decodes it. For example, strike 4500.0 becomes epoch plus 4,500 seconds;
30.0 months becomes epoch plus 30 seconds. These are ordering keys, not market
timestamps. Check the returned Option: non-finite keys and values outside
roughly ±9.2 billion are rejected. Choose units and precision that keep distinct
keys distinct after nanosecond rounding, and supply bars in ascending key order.
Use the same encoding for every series, whitespace bar and
Marker::new
on that chart. A
PriceLine
still refers to a vertical value, such as implied volatility in percent.
Do not synchronize encoded keys with a chronological chart as if they were UTC
market times; share a domain key only with charts using the same encoding.
Option strikes
Section titled “Option strikes”Install a scale with
Chart::set_horizontal_scale(Some(Arc::new(scale))).
PriceHorizontalScale::precision
controls decimals on the axis and crosshair. The scale prioritizes round strikes
when labels would overlap. Passing None restores the chart’s time formatter.
The generated smiles have strikes from 3,800 through 5,200, spaced 25 apart near the forward and 100 apart in the wings. The wider gaps occupy four times the horizontal distance without placeholder rows. The ATM marker is anchored to strike 4,500; its horizontal price line marks the corresponding 30-day volatility. The curves are illustrative formulas, not an option pricing model or a source of market data.
Hover the two expiries: the bottom label is a strike and the vertical values are IV in percent.
The interactive example needs JavaScript and WebAssembly. You can still use the chart setup alongside it.
/// Compare two generated volatility smiles by option strike, rather than by date.pub fn volatility_smile() -> egui_charts::Chart { use eframe::egui; use egui_charts::ChartType; use egui_charts::model::{Bar, BarData, Marker, MarkerPos, MarkerShape, PriceLine}; use egui_charts::scales::{PriceHorizontalScale, key_time}; use egui_charts::widget::{Chart, SeriesId, SeriesOptions}; use std::sync::Arc;
const FORWARD: f64 = 4500.0;
/// Strikes from 3,800 to 5,200, 25 apart near the forward and 100 apart in the wings. fn smile(days: f64) -> Vec<Bar> { (0..=56) .filter(|step| (16..=40).contains(step) || step % 4 == 0) .map(|step| 3800.0 + 25.0 * f64::from(step)) .filter_map(|strike| { Some(Bar::from_value( key_time(strike)?, implied_volatility(strike, days), )) }) .collect() }
/// A sample smile in percent: a skew falling towards higher strikes and a curvature in /// log-moneyness, both flattening with time to expiry. fn implied_volatility(strike: f64, days: f64) -> f64 { let moneyness = (strike / FORWARD).ln(); let flattening = (30.0 / days).sqrt(); 16.0 + 4.0 * (days / 365.0) - 60.0 * moneyness * flattening + 500.0 * moneyness * moneyness * flattening }
let near = smile(30.0); let strikes = near.len(); let mut chart = Chart::new(BarData::from_bars(near)); chart.set_chart_type(ChartType::Line); // Three more bars than strikes leave room for the right offset. chart.set_visible_bars(strikes + 3); chart.set_horizontal_scale(Some(Arc::new(PriceHorizontalScale { precision: 0 }))); chart.set_symbol("30-day IV, %"); chart.config_mut().show_volume = false; let later = chart.add_series(SeriesOptions { name: "90-day IV, %".into(), color: egui::Color32::from_rgb(255, 152, 0), ..Default::default() }); chart.set_data(later, smile(90.0)); let at_the_money = key_time(FORWARD).expect("finite strike"); chart.set_markers( SeriesId::MAIN, vec![ Marker::new(at_the_money) .with_pos(MarkerPos::BelowBar) .with_shape(MarkerShape::ArrowUp) .with_color(egui::Color32::GRAY) .with_text("ATM"), ], ); chart.set_price_lines( SeriesId::MAIN, vec![ PriceLine::new(implied_volatility(FORWARD, 30.0), egui::Color32::GRAY) .with_title("ATM"), ], ); chart}Maturities and optional whitespace
Section titled “Maturities and optional whitespace”MaturityScale
uses months as its numeric unit. It formats 3 as 3M, 30 as 2Y6M,
and 360 as 30Y. Supply only the observed maturities in the main series and
use the same keys for any added curves. Samples at 1, 12 and 360 are
spaced by their actual differences, so the second gap is much wider than the
first. No monthly grid is required.
Zoom into the short end, then zoom out: one year and thirty years retain their relative horizontal distances.
The interactive example needs JavaScript and WebAssembly. You can still use the chart setup alongside it.
/// Space two generated yield curves by their actual maturity samples.pub fn yield_curve() -> egui_charts::Chart { use eframe::egui; use egui_charts::model::{Bar, BarData}; use egui_charts::scales::{MaturityScale, key_time}; use egui_charts::widget::{Chart, SeriesOptions}; use std::sync::Arc;
const MATURITIES: [f64; 13] = [ 1.0, 2.0, 3.0, 4.0, 6.0, 12.0, 24.0, 36.0, 60.0, 84.0, 120.0, 240.0, 360.0, ];
/// An inverted short end that rises again beyond five years. fn today(months: f64) -> f64 { let years = months / 12.0; 3.9 + 1.4 * (-years / 1.5).exp() + 0.012 * years }
/// A normal, upward-sloping curve. fn a_year_ago(months: f64) -> f64 { let years = months / 12.0; 3.2 + 1.1 * (1.0 - (-years / 4.0).exp()) }
let bars = |curve: fn(f64) -> f64| { MATURITIES .iter() .filter_map(|&months| Some(Bar::from_value(key_time(months)?, curve(months)))) .collect() }; let mut chart = Chart::new(BarData::from_bars(bars(today))); chart.set_chart_type(egui_charts::ChartType::Line); chart.set_horizontal_scale(Some(Arc::new(MaturityScale::default()))); chart.set_symbol("Today, yield %"); chart.config_mut().show_volume = false; let previous = chart.add_series(SeriesOptions { name: "A year ago".into(), color: egui::Color32::from_rgb(242, 54, 69), ..Default::default() }); chart.set_data(previous, bars(a_year_ago)); // Three spacing units beyond the samples leave room for the right offset. chart.set_visible_bars(MATURITIES.len() + 3); chart}MaturityScale::whitespace(last)
remains available when the data model needs explicit empty samples. Its
base_resolution
is the months between generated samples (zero is treated as one),
start
is the first maturity, and
minimum_range
is the minimum endpoint in months. The defaults generate monthly samples from
zero over at least 120 months. For example, start: 0, base_resolution: 3 and
minimum_range: 12 generate 0, 3, 6, 9, 12; whitespace(20.0) ends at 18.
A finite requested endpoint is capped at 12,000 months before comparison with
minimum_range. A non-finite request uses the minimum. The minimum itself is
not capped, so keep host ranges bounded.
Logical indices still address rows, including fractional positions between samples. Bar spacing and scroll offsets use the mean key gap as one unit, or one key unit for a single sample. See the time-scale guide for logical coordinates, fitting and screen conversions.
Implement another label policy
Section titled “Implement another label policy”Implement HorizontalScale and pass it in an Arc:
is_numericreturnstruefor proportional spacing of keys encoded withkey_time. Its default isfalse, retaining ordinal slots.tick_marksreceives ordered(key, index)points and aTickMarkLayout. For numeric scales,layout.bar_spacingis points per key unit. ReturnTickMarkvalues at keys to label. Points include extrapolated whitespace; before-history points can shift every supplied index, so use their order and differences, rather than treating an index as a stable position in stored data. The renderer places marks at their keys and skips overlapping labels.format_crosshairformats a key for the horizontal crosshair label.bar_secondsis useful for clock precision; a numeric domain can ignore it.
The trait requires Send + Sync. It supplies labels, while your application
supplies keys and values. The scene setups are adapted from the native
volatility smile
and yield curve
examples; their native window startup stays in those examples.