Skip to content

Strategy reports

A host produces a model::StrategyReport with strategies::Backtest (the strategies feature), or supplies one from its own strategy execution or analytics. The report serializes without egui and its performance metrics work with default-features = false. The host supplies the market data. examples/backtesting.rs demonstrates both a headless run and, with ui, a report window.

With the strategies feature, configure a run through Backtest::settings:

use egui_charts::model::{Bar, OrderSide, OrderType, Timeframe};
use egui_charts::strategies::{Backtest, Commission, OrderSize, Slippage, Strategy, StrategyContext};
struct Long;
impl Strategy for Long {
fn inputs(&self) -> Vec<(String, String)> {
vec![("Direction".into(), "Long".into())]
}
fn on_bar(&mut self, ctx: &mut StrategyContext<'_>) {
if ctx.position() == 0.0 {
ctx.entry("Long", OrderSide::Buy, None, OrderType::Market);
}
}
}
let mut engine = Backtest::new("Long", "SAMPLE", Timeframe::Hour1, 10_000.0);
engine.settings.currency = Some("USD".into());
engine.settings.risk_free_rate = 0.02;
engine.settings.order_size = OrderSize::EquityFraction(0.1);
engine.settings.commission = Commission::PerOrder(1.0);
engine.settings.slippage = Slippage::Ticks { ticks: 1, tick_size: 0.01 };
engine.settings.leverage = 2.0;
let bars: Vec<Bar> = vec![]; // The host supplies history independently of the visible chart.
let lower: Vec<Bar> = vec![]; // Optional finer bars for the same instrument.
let report = engine.run(&mut Long, &bars, &lower);

An entry’s explicit positive quantity overrides the default size. None uses fixed units, a fraction of current marked equity, or cash divided by the absolute execution price. Cash and equity sizing reject a zero execution price. The default pyramiding limit is one open entry; zero disables entries. A partial exit retains its entry slot until that lot is fully closed. An opposite entry reverses the position and starts a new entry count.

Commission is an absolute fill-value fraction, an amount per unit, or an amount per order. Each fill pays once, even when reversing or closing several FIFO lots; trade fees and net profits include their quantity shares of entry and exit commission. Open trades retain their unallocated entry fees, already deducted from equity. Market and stop orders slip against their side once per fill, by ticks times tick size or an absolute-price fraction. Limits, including the limit execution of a stop-limit order, never slip.

Margin is absolute position market value divided by leverage, as on the paper account. An entry is rejected if its resulting margin plus commission exceeds equity after slippage. The report counts rejected entries. Along every segment of the price path, equity below margin closes the whole position at the crossing, labelled Margin call, with exit fees and market slippage. An order at the boundary can avert the deficit; a gap already below margin closes at its open. For example, capital 1,000 and 15 long units entered at 100 with leverage 2 give equity 15p - 500 and margin 7.5p at positive price p; a decline crosses at p = 200/3, leaving equity 500 before exit costs, even if the bar later recovers.

recalculate_after_fills calls the strategy again after every fill, with price() equal to the execution price and equity() marked there after commission. is_after_fill() distinguishes it from a closed-bar decision. During this call, bars() contains only previously closed chart bars, excluding the unfinished bar. Newly created orders wait for a subsequent price point; they cannot repeatedly fill on the same tick. fill_on_close fills market orders on the closing tick. All settings and Strategy::inputs() appear in the report’s Properties tab and summary CSV. Capital, currency and risk-free rate use the report’s existing metadata fields, so each appears once.

Backtest::run(strategy, bars, lower) uses valid lower-interval bars in timestamp order within each chart bar’s time span, with the end excluded. Duplicate finer timestamps use the first valid bar. A chart bar without valid finer coverage uses its own path; gaps between supplied finer bars fill at the next finer open. Monthly spans use calendar months; execution bars end at the next chart open, with the final one covering remaining finer bars. Only fills use finer timestamps; decisions and equity stay on the chart interval. Without finer bars the existing nearest-extreme-first chart path is unchanged.

settings.start_time selects the first eligible chart-bar open. Earlier chart bars cannot place or fill orders, but still reach Strategy::prepare and Strategy::on_bar to warm both precomputed studies and incremental strategy state. The host loads both histories and can supply more data than its chart has loaded; no account or market data service is required by the engine. With a start time, equity starts at that time and subsequent values are stamped at chart-bar closes, preserving the starting capital point. The final execution bar has no known closing time; its equity mark is provisionally labelled one nanosecond after its last supplied price timestamp so the curve stays ordered. Without a start time, the existing equity timestamps are retained. When comparing a bounded run in the panel, supply only its eligible chart bars as the comparison source.

Migration: Backtest::initial_capital and Backtest::fill_on_close move to Backtest::settings. Backtest::new keeps its starting-capital argument. Add &[] as the third argument to existing run calls, or supply finer bars. Runs that intentionally stack entries must set settings.pyramiding to their limit; the default is now one. Entries also need enough margin and commission in the account, with leverage one by default. Starting capital must be finite.

With the ui feature, keep a ui::strategy_report::StrategyReportPanel in your host:

use egui_charts::model::{Bar, StrategyReport};
use egui_charts::ui::strategy_report::{StrategyReportAction, StrategyReportPanel};
fn replace(panel: &mut StrategyReportPanel, run: StrategyReport, source: &[Bar]) {
// Source bars must cover exactly this run, for its symbol and interval.
// Passing [] keeps trade/equity metrics available without comparison or excursions.
panel.set_report(Some(run), source);
}
fn render(ui: &mut egui::Ui, panel: &mut StrategyReportPanel) {
match panel.show(ui) {
StrategyReportAction::SelectTrade(selection) => {
// The host switches to selection.symbol and selection.timeframe, scrolls to
// selection.entry_time and highlights through selection.exit_time.
// trade_index addresses the original report, including skipped input rows.
let _ = selection;
}
StrategyReportAction::ExportCsv(csv) => {
// The host may save or download this string. This example copies it.
ui.ctx().copy_text(csv);
}
_ => {}
}
}

Call set_report when a finished report or its source bars change. It computes and caches metrics, clears selection and opens Summary. Mount show in your panel below the chart, with a separate egui id scope for each report component. set_report(None, &[]) clears it; show then takes no space and shows no controls. report() reads the current run. Selecting a trade returns its symbol, interval, original index and entry/exit timestamps. An open trade has no exit boundary. The host owns navigation, highlighting, report panel placement, data loading and CSV storage. Chart entry/exit markers use the signals service. The assembled terminal and its demo mounting remain separate integration work.

The Summary tab shows all performance metrics, equity and drawdown in account units, and an optional fee-free buy-and-hold comparison on the same equity scale. Curve positions and hover values retain submillisecond timestamps. Hover a curve for timestamped values. The virtualized trade list uses entry-time order, keeps equal-time trades in input order, and shows fill prices, net profit, fees, labels and excursions. Fill prices retain their precision and fill times retain fractional seconds. Properties includes run metadata and the host’s ordered settings. Invalid inputs are counted visibly. An undefined or non-finite metric displays as N/A, including when converting a fraction to a displayed percentage overflows. CSV exports only valid trades using StrategyReport::trades_csv, retaining its existing columns, quoting and UTC timestamps. Export summary CSV returns StrategyReportAction::ExportSummaryCsv with a suggested file name and the metrics and properties shown, as StrategyReport::summary_csv writes them: a dot as the decimal separator, no thousands separator, and an empty cell for an unavailable value. Percent values keep 15 significant decimal digits, so a rate of 0.07 reads 7, and a zero is always 0. Both exports prefix a text cell that starts with =, +, -, @, a tab or a carriage return with an apostrophe, so a spreadsheet shows it as text; numeric cells, including negative numbers, are unchanged.

StrategyReport::performance retains the base definitions on StrategyPerformance: net and gross profit/loss use the host’s net closed-trade profits; drawdown uses running equity peaks. Sharpe uses sample standard deviation, Sortino uses the root mean square of downside excess returns over all steps, and both use the curve’s actual steps per 365.25-day year and require at least 13 weeks. Non-finite equity points are skipped; unordered finite points invalidate the entire curve. equity_curve() exposes those same validated points for rendering.

The largest winner is the greatest positive net profit, and the largest loser is the most negative net profit. Either is unavailable without a trade of that sign. Consecutive wins and losses use exit-time order, ties retaining input order. Breakeven trades break both runs; invalid and open trades do not participate. For profits 5, 3, -1, -7, -2, 0, 4, 4, 0, 2, net profit is 8, gross profit 18, gross loss 10, largest winner 5, largest loser -7, and the longest winning and losing runs are 2 and 3.

StrategyReport::comparison(source) takes the complete source bars of this run’s symbol and interval, with timestamps denoting bar opens. The host must supply exactly the run’s bars, including every held bar; the library cannot detect a missing session or a wrong symbol from OHLC alone. Non-finite or inverted OHLC bars are skipped and counted; unordered or duplicate timestamps invalidate the whole source. Volume is not used.

Buy-and-hold invests all initial capital in fractional units at the first valid open and marks them at each valid close, without fees or dividends. Fractional return is last_close / first_open - 1; the open must be positive. Its curve additionally requires positive finite initial capital. For capital 1,000, first open 100, and closes 110, 90, 120, the benchmark owns 10 units, equity is 1,100, 900, 1,200, and return is 20%. Curve timestamps are the bars’ timestamps, as in a price chart. This independent benchmark is not sized to the strategy’s exposure.

Excursions use entry price E, source low L and high H over the held bars, and quantity Q, including entry and exit fills in the extrema. Long MAE is max(E-L, 0)*Q and MFE is max(H-E, 0)*Q; short MAE is max(H-E, 0)*Q and MFE is max(E-L, 0)*Q. Values are nonnegative, before fees, in price times quantity units. They require host conversion for point values, inverse contracts or different account currencies. With low 85 and high 115, a long entry at 100 of 2 units has MAE/MFE 30/30; a short entry at 110 of 3 units has 15/75.

The bar containing entry and subsequent bars opening strictly before exit participate, so an exit at a bar’s open does not include that bar’s later range. Time bars end after their interval, using calendar months for monthly bars. Execution bars end at the next source open; the final execution bar is assumed to cover the run’s remaining fills. Open trades use all remaining source bars. At least one held bar is required, and an invalid held bar makes the excursion unavailable. OHLC cannot resolve movement on either side of an intrabar fill: these are bar-resolution estimates in that case. Use fills at bar boundaries for exact spans, or finer source data in host analytics when exact intrabar excursions are required.