Skip to content

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.

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.

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.

Volatility smiles by option strikeOpen full size

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.

volatility_smile.rs
/// 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
}

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.

Yield curves by actual maturityOpen full size

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.

yield_curve.rs
/// 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 HorizontalScale and pass it in an Arc:

  • is_numeric returns true for proportional spacing of keys encoded with key_time. Its default is false, retaining ordinal slots.
  • tick_marks receives ordered (key, index) points and a TickMarkLayout. For numeric scales, layout.bar_spacing is points per key unit. Return TickMark values 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_crosshair formats a key for the horizontal crosshair label. bar_seconds is 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.