Ozakboy.Wpf.Charting
0.1.0
dotnet add package Ozakboy.Wpf.Charting --version 0.1.0
NuGet\Install-Package Ozakboy.Wpf.Charting -Version 0.1.0
<PackageReference Include="Ozakboy.Wpf.Charting" Version="0.1.0" />
<PackageVersion Include="Ozakboy.Wpf.Charting" Version="0.1.0" />
<PackageReference Include="Ozakboy.Wpf.Charting" />
paket add Ozakboy.Wpf.Charting --version 0.1.0
#r "nuget: Ozakboy.Wpf.Charting, 0.1.0"
#:package Ozakboy.Wpf.Charting@0.1.0
#addin nuget:?package=Ozakboy.Wpf.Charting&version=0.1.0
#tool nuget:?package=Ozakboy.Wpf.Charting&version=0.1.0
Ozakboy.Wpf.Charting
A high-performance financial chart control for WPF on .NET 10: candlesticks, indicator lines, trade markers, stop-loss / take-profit lines, a time axis and a linear or logarithmic price axis, with crosshair and tooltip. It pans and zooms over 10,000 candles within a 60 FPS frame budget.
繁體中文說明請見 README_zh-TW.md。
Design notes
- No UIElement per candle. The chart draws on five
DrawingVisuallayers. Ten thousand UIElements would spend the whole frame on layout alone. Mouse movement redraws only the crosshair layer. - Per-frame work is bounded by the screen, not the data. When candles get narrower than 4 DIPs, the candles in one pixel column merge into one, keeping the highest high and lowest low (see Downsampling).
- Prices stay
decimal. Candles, lines, markers and price lines aredecimalend to end. The only conversion todoublehappens at the moment a price becomes a screen coordinate, because WPF's drawing API accepts nothing else. The tooltip prints the exact digits you put in. - Direction never depends on colour alone. Rising candles are hollow and falling ones solid; buy markers point up, sell markers point down, and both carry text; the tooltip shows ▲/▼ and a signed change.
- UTC in, local time out. Data must be UTC. Only labels are converted to the display time zone (Taipei by default), and the axis corner shows the UTC offset.
- No third-party dependencies. BCL and the Windows Desktop SDK only.
Install
dotnet add package Ozakboy.Wpf.Charting
Target framework: net10.0-windows (WPF).
Quick start
XAML:
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:chart="clr-namespace:Ozakboy.Wpf.Charting;assembly=Ozakboy.Wpf.Charting">
<chart:ChartControl Theme="{x:Static chart:ChartTheme.Dark}">
<chart:CandlestickSeries Candles="{Binding Candles}" />
<chart:LineSeries Points="{Binding Ema20}" IsAlignedToCandles="True" Stroke="#F6C343" />
<chart:MarkerSeries Markers="{Binding Trades}" />
<chart:HorizontalLine Price="{Binding StopLoss}" Label="Stop" Stroke="#E53935" />
</chart:ChartControl>
</Window>
Feeding data from the view model:
using System.Collections.ObjectModel;
using Ozakboy.Wpf.Charting;
public sealed class ChartViewModel
{
public ObservableCollection<ChartCandle> Candles { get; } = [];
public ObservableCollection<ChartMarker> Trades { get; } = [];
public decimal StopLoss { get; set; } = 61250.0m;
// Call on the UI thread. Appending keeps the user's view and scrolls along only if they are watching the latest candle.
public void OnKlineClosed(DateTime openTimeUtc, decimal open, decimal high, decimal low, decimal close, decimal volume) =>
Candles.Add(new ChartCandle(openTimeUtc, open, high, low, close, volume));
public void OnFill(DateTime timeUtc, decimal price) =>
Trades.Add(new ChartMarker(timeUtc, price, MarkerKind.LongEntry));
}
Without XAML:
var chart = new ChartControl { Theme = ChartTheme.Light };
chart.AxisY.Scale = PriceScaleKind.Logarithmic;
chart.Series.Add(new CandlestickSeries { Candles = candles });
chart.Series.Add(new HorizontalLine { Price = 64100.0m, Label = "Target" });
Series
| Series | Data | Notes |
|---|---|---|
CandlestickSeries |
IReadOnlyList<ChartCandle> |
Strictly ascending by open time. Interval is estimated from the data unless set. |
LineSeries |
IReadOnlyList<LinePoint> |
Indicators and equity curves. IsAlignedToCandles draws candle-keyed values at candle centres. |
MarkerSeries |
IReadOnlyList<ChartMarker> |
No ordering required. Shape by buy/sell, fill by entry/exit, plus text. |
HorizontalLine |
Price (decimal) |
Dashed by default; tags its exact price on the axis. |
Every colour can be set per series (RisingBrush, Stroke, BuyBrush…); when left null, the theme's colour applies.
Trade markers
| Kind | Drawn as |
|---|---|
LongEntry (buy to open) |
▲ solid, below the price |
ShortExit (buy to close) |
▲ hollow, below the price |
ShortEntry (sell to open) |
▼ solid, above the price |
LongExit (sell to close) |
▼ hollow, above the price |
So: ▲ buy, ▼ sell; solid = entry, hollow = exit. Each marker also carries text.
Why the default colour is not a colour. Markers default to the theme's text ink (#D1D4DC dark, #131722 light) with a
2-DIP halo in the plot background. Candles and moving averages already take the main hues, and markers sit on top of all of
them, so any extra hue collides with one: a pairwise check with a colour-vision validator rejected purple #9085e9
(ΔE 1.9 against the blue EMA under protanopia) and magenta #d55181 (ΔE 7.8 against the falling colour under normal vision;
the floor is 15). BuyBrush / SellBrush (per series) and BuyMarkerBrush / SellMarkerBrush (per theme) remain available
if your palette has room.
Overlapping markers stack. A reversal (close a long, open a short on one candle) always puts two markers in one spot.
Markers are placed in time order; each is pushed away from its candle, below for buys and above for sells, until it clears
every marker already placed, with a 2-DIP gap. The earliest stays on the price and later ones stack outwards. A pushed marker is
tied back to its fill price by a dotted leader line. If a stack would leave the plot it is pulled back to the edge, but never
past the marker's own price (that would flip it to the candle's other side); staying inside the plot wins over not overlapping.
The algorithm is Calculation.MarkerStacker.
Updating data
- Replace the property value (
series.Candles = newList) orClear()the collection: a new data set, the chart returns to the full view. - Append to an
ObservableCollection: the chart redraws and keeps the user's viewport; it scrolls along only when the right edge was already at the latest candle. - Data must be strictly ascending in time (markers excepted). Unsorted data throws
InvalidOperationExceptioninstead of being drawn in the wrong place, because binary search on unsorted data fails silently. - Modify data on the UI thread only.
Interaction
| Input | Action |
|---|---|
| Mouse wheel | Zoom centred on the cursor (WheelZoomFactor, 1.2 per notch). |
| Left drag | Pan. |
| Mouse move | Crosshair snaps to the hovered candle; tooltip shows time, OHLC, change and volume. |
The same is available from code: ZoomAt, Pan, ResetView, SetVisibleRange, ShowCrosshair, HideCrosshair.
Zoom stops at 5 visible candles and at 1.5 times the data span, and the viewport's midpoint always stays inside the
data, so nobody drags the chart into an empty void.
Downsampling
When a candle gets less than 4 DIPs wide (too narrow for a hollow body with a gap), the visible candles are merged by pixel column in a single O(n) pass:
- Each candle's centre time (open plus half an interval) maps to an x coordinate; its integer part is the column.
- Candles are time-sorted, so candles sharing a column are adjacent; a candle with the same column as its predecessor joins the current bucket, otherwise a new bucket starts.
- A bucket takes the first open, the last close, the highest high, the lowest low and the summed volume.
A column is drawn as one vertical line from its high to its low. Keeping the extremes matters: averaging or sampling would erase wicks, and a wick is exactly where a stop gets swept. Lines use M4 (first, minimum, maximum and last point of each column), which is visually lossless. Either way the number of drawn items never exceeds the plot width.
Logarithmic axis: zero and negative values
A logarithm is undefined for zero and negative values, and any substitute (pinning to the bottom, taking the absolute value) would draw a plausible but wrong position. So, on a log axis:
- The range is computed from positive values only.
- A candle with any non-positive price is skipped whole; a line breaks at the point and resumes at the next positive one; markers and price lines at non-positive prices are skipped.
- If no positive value is visible at all, nothing price-related is drawn (no price axis, no candles) instead of a wrong chart. The linear axis handles negative values normally.
Time axis
Tick spacing switches with the visible span through 1/2/5/10/15/30 minutes, 1/2/3/4/6/12 hours, 1/2/5/10 days,
1/2/3/6 months and 1/2/5 × 10ⁿ years, and ticks align on round values in the display time zone (Taipei 09:00, not a
UTC hour converted). A tick that crosses a day, month or year boundary prints the date and is emphasised. Set
chart.AxisX.TimeZone to change the display zone.
Themes and text
ChartTheme.Dark and ChartTheme.Light are frozen presets; ChartTheme.CreateDark() / CreateLight() return editable
copies. Freeze a custom theme before sharing it between charts. The library ships English defaults for all text; set
chart.TooltipLabels and the MarkerSeries …Label properties from your own resources to localise.
Performance
Measured on the development machine with the samples/Ozakboy.Wpf.Charting.RenderTool benchmark (10,000 one-minute
candles plus an EMA line, 1600×900). The figure is ChartControl.LastRenderDuration: the UI-thread time from computing
the viewport to finishing every layer's drawing instructions. Rasterisation happens on WPF's render thread.
| Scenario | Median | P95 | Max |
|---|---|---|---|
| Full overview, 10,000 candles downsampled | 2.45 ms | 2.55 ms | 2.67 ms |
| Zoom in, 40 wheel steps (10,000 → 15 visible) | 0.96 ms | 2.17 ms | 2.64 ms |
| Zoom out, 40 wheel steps | 0.95 ms | 2.39 ms | 2.79 ms |
| Pan with ~1,500 visible (downsampled) | 1.40 ms | 1.48 ms | 1.64 ms |
| Pan with ~350 visible (one body per candle) | 1.04 ms | 1.14 ms | 2.58 ms |
For reference, rasterising the full overview in software with RenderTargetBitmap (everything on the CPU) takes
about 22 ms; on screen that work goes to the render thread and the GPU, not the UI thread.
Not in this version
Streaming animation (planned for 1.1), volume pane, drawing tools and a manually scaled price axis.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0-windows7.0 is compatible. |
-
net10.0-windows7.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 95 | 9/11/2026 |