OhlcChart
OHLC bars with an open tick on the left and a close tick on the right, a volume pane, moving averages and the details on hover.
Demo
| Time | Open | High | Low | Close | Volume |
|---|---|---|---|---|---|
| May 1, 2026 | $73.98 | $74.21 | $73.85 | $73.88 | 2,098,090 |
| May 4, 2026 | $73.84 | $74.27 | $73.49 | $73.92 | 1,309,442 |
| May 5, 2026 | $73.80 | $74.49 | $73.52 | $74.39 | 1,909,759 |
| May 6, 2026 | $74.54 | $74.60 | $74.11 | $74.27 | 1,990,407 |
| May 7, 2026 | $74.31 | $74.85 | $74.14 | $74.49 | 1,380,980 |
| May 8, 2026 | $74.62 | $74.76 | $72.96 | $73.38 | 2,311,354 |
| May 11, 2026 | $73.17 | $73.82 | $72.53 | $72.91 | 1,126,757 |
| May 12, 2026 | $72.96 | $74.41 | $72.30 | $74.17 | 1,373,059 |
| May 13, 2026 | $74.27 | $75.82 | $74.06 | $75.11 | 1,430,423 |
| May 14, 2026 | $75.24 | $77.02 | $74.49 | $76.46 | 2,418,508 |
| May 15, 2026 | $76.43 | $76.90 | $75.79 | $75.81 | 2,404,109 |
| May 18, 2026 | $75.96 | $76.62 | $75.61 | $75.61 | 1,923,788 |
| May 19, 2026 | $75.81 | $76.50 | $75.42 | $75.98 | 2,112,181 |
| May 20, 2026 | $75.95 | $77.90 | $75.63 | $77.13 | 2,414,660 |
| May 21, 2026 | $77.13 | $77.17 | $76.39 | $76.43 | 2,261,790 |
| May 22, 2026 | $76.25 | $76.67 | $75.60 | $75.89 | 1,665,013 |
| May 25, 2026 | $75.71 | $75.84 | $75.00 | $75.43 | 1,743,325 |
| May 26, 2026 | $75.62 | $76.36 | $73.86 | $74.60 | 2,309,487 |
| May 27, 2026 | $74.63 | $75.27 | $73.85 | $74.33 | 1,686,572 |
| May 28, 2026 | $74.24 | $74.55 | $73.53 | $74.36 | 1,712,252 |
| May 29, 2026 | $74.54 | $74.66 | $72.95 | $73.66 | 2,151,497 |
| Jun 1, 2026 | $73.57 | $74.72 | $72.92 | $74.45 | 1,875,387 |
| Jun 2, 2026 | $74.45 | $74.52 | $73.20 | $73.79 | 2,318,367 |
| Jun 3, 2026 | $73.68 | $74.63 | $73.05 | $74.38 | 1,743,359 |
| Jun 4, 2026 | $74.16 | $74.18 | $72.54 | $72.95 | 1,739,647 |
| Jun 5, 2026 | $73.08 | $73.55 | $71.93 | $72.65 | 1,724,329 |
| Jun 8, 2026 | $72.76 | $73.24 | $71.60 | $71.96 | 1,526,215 |
| Jun 9, 2026 | $71.86 | $72.34 | $71.53 | $71.89 | 1,318,070 |
| Jun 10, 2026 | $71.89 | $72.03 | $71.44 | $71.95 | 2,024,615 |
| Jun 11, 2026 | $71.95 | $72.29 | $71.39 | $72.14 | 1,475,741 |
| Jun 12, 2026 | $71.97 | $72.47 | $71.83 | $72.19 | 1,673,392 |
| Jun 15, 2026 | $72.37 | $72.49 | $71.54 | $72.14 | 2,111,443 |
| Jun 16, 2026 | $72.24 | $72.44 | $71.45 | $71.62 | 1,553,099 |
| Jun 17, 2026 | $71.80 | $72.73 | $71.50 | $72.32 | 1,788,692 |
| Jun 18, 2026 | $72.30 | $72.96 | $71.73 | $72.95 | 2,198,033 |
| Jun 19, 2026 | $72.74 | $73.86 | $72.29 | $73.50 | 1,193,414 |
| Jun 22, 2026 | $73.65 | $75.58 | $73.47 | $74.91 | 1,588,858 |
| Jun 23, 2026 | $74.85 | $75.60 | $74.27 | $74.83 | 1,540,983 |
| Jun 24, 2026 | $74.69 | $75.35 | $73.95 | $74.90 | 1,400,190 |
| Jun 25, 2026 | $74.71 | $75.43 | $74.04 | $74.66 | 1,014,826 |
| Jun 26, 2026 | $74.70 | $75.98 | $74.33 | $75.33 | 1,619,065 |
| Jun 29, 2026 | $75.19 | $76.81 | $75.09 | $76.31 | 2,050,504 |
| Jun 30, 2026 | $76.24 | $77.30 | $76.22 | $76.66 | 1,807,307 |
| Jul 1, 2026 | $76.83 | $78.30 | $76.71 | $77.70 | 1,489,262 |
| Jul 2, 2026 | $77.81 | $78.68 | $77.25 | $78.60 | 2,318,451 |
| Jul 3, 2026 | $78.54 | $78.93 | $78.00 | $78.15 | 1,146,085 |
| Jul 6, 2026 | $78.16 | $79.77 | $77.74 | $78.99 | 2,474,379 |
| Jul 7, 2026 | $79.17 | $79.89 | $78.48 | $78.85 | 1,901,825 |
| Jul 8, 2026 | $78.94 | $80.02 | $78.25 | $79.91 | 1,254,368 |
| Jul 9, 2026 | $79.83 | $81.80 | $79.08 | $81.24 | 2,429,691 |
| Jul 10, 2026 | $81.00 | $81.41 | $79.65 | $79.65 | 2,039,787 |
| Jul 13, 2026 | $79.70 | $80.15 | $79.49 | $79.76 | 1,191,161 |
| Jul 14, 2026 | $79.90 | $79.93 | $78.16 | $78.87 | 1,793,532 |
| Jul 15, 2026 | $79.07 | $79.35 | $78.00 | $78.70 | 1,400,369 |
| Jul 16, 2026 | $78.69 | $78.92 | $77.77 | $78.52 | 1,787,039 |
| Jul 17, 2026 | $78.34 | $79.00 | $77.83 | $78.51 | 1,098,606 |
| Jul 20, 2026 | $78.34 | $78.43 | $76.72 | $77.35 | 2,165,411 |
| Jul 21, 2026 | $77.13 | $77.46 | $76.66 | $77.17 | 2,190,676 |
| Jul 22, 2026 | $76.99 | $78.33 | $76.35 | $77.66 | 1,903,914 |
| Jul 23, 2026 | $77.48 | $78.24 | $76.78 | $77.29 | 1,968,542 |
| Jul 24, 2026 | $77.36 | $77.64 | $76.07 | $76.81 | 1,930,104 |
| Jul 27, 2026 | $76.85 | $77.27 | $76.31 | $76.88 | 1,304,479 |
| Jul 28, 2026 | $76.83 | $77.71 | $76.47 | $77.64 | 2,030,200 |
| Jul 29, 2026 | $77.75 | $78.60 | $77.49 | $77.99 | 1,607,696 |
| Jul 30, 2026 | $78.20 | $78.30 | $76.85 | $77.22 | 1,898,047 |
| Jul 31, 2026 | $77.37 | $77.73 | $76.65 | $77.19 | 1,960,905 |
| Aug 3, 2026 | $77.35 | $77.60 | $75.53 | $76.23 | 1,608,009 |
| Aug 4, 2026 | $76.12 | $76.78 | $74.97 | $75.02 | 2,360,919 |
| Aug 5, 2026 | $74.97 | $75.46 | $74.84 | $74.85 | 2,196,619 |
| Aug 6, 2026 | $74.80 | $75.06 | $73.31 | $73.71 | 1,380,897 |
| Aug 7, 2026 | $73.79 | $74.10 | $72.77 | $73.25 | 2,247,152 |
| Aug 10, 2026 | $73.32 | $73.89 | $72.33 | $73.03 | 2,020,487 |
| Aug 11, 2026 | $72.86 | $72.87 | $71.99 | $72.04 | 1,213,999 |
| Aug 12, 2026 | $71.87 | $73.49 | $71.36 | $73.00 | 1,707,551 |
| Aug 13, 2026 | $73.06 | $73.78 | $71.68 | $71.85 | 2,384,288 |
| Aug 14, 2026 | $71.97 | $73.31 | $71.68 | $72.73 | 1,548,014 |
| Aug 17, 2026 | $72.91 | $73.39 | $72.70 | $73.34 | 2,251,245 |
| Aug 18, 2026 | $73.14 | $73.78 | $72.48 | $73.06 | 2,193,555 |
| Aug 19, 2026 | $73.11 | $73.70 | $72.37 | $72.42 | 1,738,911 |
| Aug 20, 2026 | $72.51 | $73.87 | $72.30 | $73.15 | 1,643,326 |
| Aug 21, 2026 | $73.04 | $74.27 | $72.45 | $74.09 | 2,537,264 |
| Aug 24, 2026 | $73.90 | $75.24 | $73.50 | $75.17 | 1,920,014 |
| Aug 25, 2026 | $74.99 | $75.54 | $73.69 | $74.09 | 1,801,218 |
| Aug 26, 2026 | $74.14 | $75.47 | $73.43 | $74.90 | 1,537,552 |
| Aug 27, 2026 | $74.93 | $74.95 | $73.79 | $74.33 | 2,346,572 |
| Aug 28, 2026 | $74.40 | $74.53 | $73.04 | $73.52 | 1,699,328 |
| Aug 31, 2026 | $73.52 | $74.40 | $72.89 | $73.93 | 2,355,110 |
| Sep 1, 2026 | $73.72 | $73.96 | $73.44 | $73.55 | 1,483,590 |
| Sep 2, 2026 | $73.48 | $73.83 | $71.91 | $72.56 | 1,766,529 |
| Sep 3, 2026 | $72.49 | $73.10 | $71.86 | $73.05 | 2,128,344 |
| Time | Open | High | Low | Close |
|---|---|---|---|---|
| Oct 12, 10:00 | 1.0809 | 1.0915 | 1.0777 | 1.0831 |
| Oct 12, 11:00 | 1.0805 | 1.0848 | 1.0730 | 1.0773 |
| Oct 12, 12:00 | 1.0800 | 1.0832 | 1.0781 | 1.0815 |
| Oct 12, 13:00 | 1.0825 | 1.0829 | 1.0709 | 1.0808 |
| Oct 12, 14:00 | 1.0781 | 1.0883 | 1.0704 | 1.0774 |
| Oct 12, 15:00 | 1.0783 | 1.0812 | 1.0771 | 1.0780 |
| Oct 12, 16:00 | 1.0783 | 1.0859 | 1.0757 | 1.0825 |
| Oct 13, 10:00 | 1.0834 | 1.0919 | 1.0689 | 1.0792 |
| Oct 13, 11:00 | 1.0775 | 1.0896 | 1.0692 | 1.0830 |
| Oct 13, 12:00 | 1.0821 | 1.0867 | 1.0740 | 1.0779 |
| Oct 13, 13:00 | 1.0801 | 1.0821 | 1.0749 | 1.0789 |
| Oct 13, 14:00 | 1.0811 | 1.0864 | 1.0745 | 1.0804 |
| Oct 13, 15:00 | 1.0806 | 1.0874 | 1.0742 | 1.0811 |
| Oct 13, 16:00 | 1.0809 | 1.0858 | 1.0709 | 1.0789 |
| Oct 14, 10:00 | 1.0789 | 1.0887 | 1.0728 | 1.0845 |
| Oct 14, 11:00 | 1.0833 | 1.0889 | 1.0746 | 1.0862 |
| Oct 14, 12:00 | 1.0838 | 1.0865 | 1.0790 | 1.0856 |
| Oct 14, 13:00 | 1.0833 | 1.0926 | 1.0725 | 1.0858 |
| Oct 14, 14:00 | 1.0836 | 1.0924 | 1.0775 | 1.0842 |
| Oct 14, 15:00 | 1.0824 | 1.0846 | 1.0732 | 1.0769 |
| Oct 14, 16:00 | 1.0781 | 1.0828 | 1.0666 | 1.0762 |
| Oct 15, 10:00 | 1.0785 | 1.0803 | 1.0652 | 1.0724 |
| Oct 15, 11:00 | 1.0742 | 1.0858 | 1.0693 | 1.0775 |
| Oct 15, 12:00 | 1.0756 | 1.0804 | 1.0694 | 1.0797 |
| Oct 15, 13:00 | 1.0794 | 1.0796 | 1.0771 | 1.0779 |
| Oct 15, 14:00 | 1.0782 | 1.0830 | 1.0684 | 1.0808 |
| Oct 15, 15:00 | 1.0804 | 1.0885 | 1.0731 | 1.0775 |
| Oct 15, 16:00 | 1.0803 | 1.0885 | 1.0702 | 1.0805 |
| Oct 16, 10:00 | 1.0834 | 1.0957 | 1.0754 | 1.0872 |
| Oct 16, 11:00 | 1.0842 | 1.0961 | 1.0814 | 1.0871 |
| Oct 16, 12:00 | 1.0859 | 1.0970 | 1.0751 | 1.0901 |
| Oct 16, 13:00 | 1.0878 | 1.0925 | 1.0866 | 1.0897 |
| Oct 16, 14:00 | 1.0880 | 1.0983 | 1.0865 | 1.0867 |
| Oct 16, 15:00 | 1.0868 | 1.0945 | 1.0742 | 1.0822 |
| Oct 16, 16:00 | 1.0832 | 1.0882 | 1.0737 | 1.0825 |
| Oct 19, 10:00 | 1.0846 | 1.0919 | 1.0718 | 1.0817 |
| Oct 19, 11:00 | 1.0833 | 1.0838 | 1.0733 | 1.0804 |
| Oct 19, 12:00 | 1.0777 | 1.0836 | 1.0739 | 1.0766 |
| Oct 19, 13:00 | 1.0757 | 1.0763 | 1.0748 | 1.0763 |
| Oct 19, 14:00 | 1.0751 | 1.0804 | 1.0730 | 1.0786 |
| Oct 19, 15:00 | 1.0782 | 1.0886 | 1.0699 | 1.0785 |
| Oct 19, 16:00 | 1.0782 | 1.0870 | 1.0726 | 1.0808 |
Hover a bar for its open (the tick on the left), high, low and close (the tick on the right).
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/ohlc_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/ohlc_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/ohlc_chart.qwe
// Prices from a seeded random walk (Park–Miller), the same on every render: `count`// candles from the day `from` on, skipping weekends, daily or hourly (10:00 to 16:00),// rounded to `places` decimals.fn ohlc_chart_demo_walk(seed: int, count: int, start: num, from: str, hourly: bool = false, places: int = 2) -> list { let s = seed let rnd = () => { s = s * 16807 % 2147483647 s / 2147483647 } let scale = pow(10, places) let cut = (v) => round(v * scale) / scale let p = start let day = from let hour = 10 let out = [] while len(out) < count { if date.weekday(day) <= 5 { let open = cut(p * (1 + (rnd() - 0.5) * 0.006)) let close = cut(open * (1 + (rnd() - 0.485) * (hourly ? 0.012 : 0.035))) let high = cut(max([open, close]) * (1 + rnd() * 0.01)) let low = cut(min([open, close]) * (1 - rnd() * 0.01)) let volume = round((hourly ? 90000 : 900000) * (1 + rnd() * 1.5 + abs(close - open) * 30 / open)) out = out | push({ time: hourly ? `${day}T${hour < 10 ? "0" : ""}${hour}:00:00Z` : day, open, high, low, close, volume }) p = close } if hourly && hour < 16 && date.weekday(day) <= 5 { hour += 1 } else { hour = 10 day = date.add_days(day, 1) } } out}let ohlc_chart_demo_daily = ohlc_chart_demo_walk(11, 90, 74.2, "2026-05-01")let ohlc_chart_demo_fx = ohlc_chart_demo_walk(5, 42, 1.0842, "2026-10-12", true, 4) | map((c) => { ...c, volume: null })view OhlcChartDemo() = <div.demo-stack> <div id="ohlc_chart-demo-daily"> <OhlcChart label="Northwind, daily" show_label={true} candles={ohlc_chart_demo_daily} sma={[10]} prefix="$"/> </div> <div id="ohlc_chart-demo-fx"> <OhlcChart label="EUR/USD, hourly" show_label={true} candles={ohlc_chart_demo_fx} volume={false} decimals={4} height={200}/> </div> <p.demo-note>Hover a bar for its open (the tick on the left), high, low and close (the tick on the right).</p> </div>Source
ui/ohlc_chart.qwe
// OhlcChart: a price chart of OHLC bars: a line from each period's low to its high,// a tick to the left at the open and one to the right at the close, green where the// close is up on the open and red where it is down. A volume pane, a date axis,// moving averages and the last close's line as CandlestickChart has. CSS only:// hovering a bar shows its date, open, high, low, close, change and volume, and// screen readers get the numbers as a table.//// <OhlcChart label="ACME, daily" candles={bars}/>// <OhlcChart label="EUR/USD, 4 hours" candles={fx} decimals={4} volume={false} sma={[30]}/>//// candles: a list of { time, open, high, low, close, volume }, oldest first. time is a// date ("2026-03-02") or a date-time ("2026-03-02T14:00:00Z", shown as written);// volume may be left out. The axis below is labelled at the starts of years, months// or days as the bars cross them.// sma and ema: periods of simple and exponential moving averages of the close, each// drawn as a line of its own colour and named in a legend ([20], [50, 200]).// volume: the volume pane under the bars (true); volume_height: its height in pixels// (64). height: the price pane's height in pixels (300). last_line: a dashed line at// the last close, with its price on the axis (true). prefix and suffix: around every// price shown ("$"); decimals: digits after the point of prices (2).// label: what the chart shows; it names it for screen readers and captions their// table. show_label also writes it above the chart.import ui/theme.qwe use ui_clslet ohlc_chart_css = css`.ui-ohlc { --ui-ohlc-1: light-dark(#2a78d6, #3987e5); --ui-ohlc-2: light-dark(#eb6834, #d95926); --ui-ohlc-3: light-dark(#1baf7a, #199e70); --ui-ohlc-4: light-dark(#eda100, #c98500); --ui-ohlc-5: light-dark(#e87ba4, #d55181); --ui-ohlc-6: light-dark(#008300, #008300); --ui-ohlc-7: light-dark(#6250d6, #9085e9); --ui-ohlc-8: light-dark(#e34948, #e66767); display: grid; gap: 10px; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-ohlc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-ohlc-s1 { --c: var(--ui-ohlc-1); } .ui-ohlc-s2 { --c: var(--ui-ohlc-2); } .ui-ohlc-s3 { --c: var(--ui-ohlc-3); }.ui-ohlc-s4 { --c: var(--ui-ohlc-4); } .ui-ohlc-s5 { --c: var(--ui-ohlc-5); } .ui-ohlc-s6 { --c: var(--ui-ohlc-6); }.ui-ohlc-s7 { --c: var(--ui-ohlc-7); } .ui-ohlc-s8 { --c: var(--ui-ohlc-8); }.ui-ohlc-up { --k: var(--ui-success); } .ui-ohlc-down { --k: var(--ui-danger); }.ui-ohlc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-ohlc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-ohlc-key { width: 14px; height: 3px; border-radius: 2px; background: var(--c); flex: none; }.ui-ohlc-frame { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; padding-top: 8px; }.ui-ohlc-plot { position: relative; }.ui-ohlc-pane, .ui-ohlc-cp { position: relative; height: var(--h); }.ui-ohlc-vpane, .ui-ohlc-cv { position: relative; height: var(--vh); margin-top: 16px; }.ui-ohlc-vpane { border-top: 1px solid color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-ohlc-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-ohlc-svg { position: absolute; z-index: 2; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }.ui-ohlc-ma { fill: none; stroke: var(--c); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }.ui-ohlc-last { position: absolute; z-index: 1; left: 0; right: 0; border-top: 1px dashed var(--k); opacity: .75; pointer-events: none; }.ui-ohlc-cols { position: absolute; inset: 0; display: flex; }.ui-ohlc-col { position: relative; flex: 1; min-width: 0; }.ui-ohlc-col::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; background: var(--ui-strong); opacity: 0; }.ui-ohlc-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-ohlc-col:hover::before { opacity: .8; }.ui-ohlc-bar { position: absolute; left: 50%; width: 2px; margin-left: -1px; background: var(--k); }.ui-ohlc-open, .ui-ohlc-close { position: absolute; height: 2px; margin-top: -1px; background: var(--k); }.ui-ohlc-open { right: 50%; left: 12%; }.ui-ohlc-close { left: 50%; right: 12%; }.ui-ohlc-vol { position: absolute; bottom: 0; left: 16%; right: 16%; min-height: 1px; border-radius: 1px 1px 0 0; background: var(--k); opacity: .35; }.ui-ohlc-col:hover .ui-ohlc-vol { opacity: .7; }.ui-ohlc-tip { position: absolute; z-index: 20; top: 0; left: calc(50% + 10px); display: none; min-width: 150px; padding: 7px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: var(--ui-shadow); color: var(--ui-fg); font-size: 12px; pointer-events: none; }.ui-ohlc-flip .ui-ohlc-tip { left: auto; right: calc(50% + 10px); }.ui-ohlc-col:hover .ui-ohlc-tip { display: block; }.ui-ohlc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-ohlc-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-ohlc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-ohlc-tip .ui-ohlc-chg b { color: var(--k); }.ui-ohlc-y { position: relative; }.ui-ohlc-y > div { position: relative; }.ui-ohlc-y span { position: absolute; left: 0; transform: translateY(-50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-ohlc-y .ui-ohlc-tag { z-index: 1; left: -4px; padding: 1px 4px; border-radius: 3px; background: var(--k); color: var(--ui-bg); font-weight: 600; }.ui-ohlc-x { position: relative; height: 16px; margin-top: 6px; }.ui-ohlc-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); white-space: nowrap; }.ui-ohlc-x .ui-ohlc-first { transform: none; }.ui-ohlc-x .ui-ohlc-end { transform: translateX(-100%); }`// n (a whole number, 0 or more) with at least `width` digits.fn ohlc_chart_pad(n: int, width: int) -> str { let s = str(n) len(s) < width ? repeat("0", width - len(s)) + s : s}// A whole number with commas: 1234567 is "1,234,567".fn ohlc_chart_group(n: int) -> str = n < 1000 ? str(n) : ohlc_chart_group(floor(n / 1000)) + "," + ohlc_chart_pad(n % 1000, 3)// A value as it reads, with commas and `places` digits after the point (up to two as// needed when that is null): 48210.5 is "48,210.50" with 2. Worked out in whole// numbers, so the server and the browser write the same text.fn ohlc_chart_num(v: num, places: int? = null) -> str { let digits = places == null ? 2 : (places < 0 ? 0 : (places > 6 ? 6 : places)) let scale = [1, 10, 100, 1000, 10000, 100000, 1000000][digits] let units = round(abs(v) * scale) let frac = units % scale let tail = digits == 0 || (places == null && frac == 0) ? "" : (places == null && frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${ohlc_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${ohlc_chart_group(floor(units / scale))}${tail}`}// A large number, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn ohlc_chart_short(v: num) -> str { let a = abs(v) let unit = a >= 1000000000 ? 1000000000 : (a >= 1000000 ? 1000000 : (a >= 1000 ? 1000 : 1)) let tenths = round(a * 10 / unit) let mark = unit == 1000000000 ? "B" : (unit == 1000000 ? "M" : "k") unit == 1 ? ohlc_chart_num(v) : `${v < 0 ? "-" : ""}${ohlc_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A change in percent, signed, to two decimals: "+1.25%", "-0.40%".fn ohlc_chart_signed(p: num) -> str = `${p > 0 ? "+" : (p < 0 && round(abs(p) * 100) > 0 ? "-" : "")}${ohlc_chart_num(abs(p), 2)}%`// A coordinate out of 100 (or a length), to two decimals.fn ohlc_chart_pct(n: num) -> str { let r = round(n * 100) / 100 r == floor(r) ? str(floor(r)) : str(r)}// The axis from lo to hi, widened to round ticks: about `steps` steps of 1, 2, 2.5 or 5 x 10^n.fn ohlc_chart_scale(lo: num, hi: num, steps: int = 5) -> map { let span = hi - lo == 0 ? (lo == 0 ? 1 : abs(lo) / 10) : hi - lo let raw = span / steps let mag = 1 while mag * 10 <= raw { mag = mag * 10 } while mag > raw { mag = mag / 10 } let r = raw / mag let step = (r <= 1 ? 1 : (r <= 2 ? 2 : (r <= 2.5 ? 2.5 : (r <= 5 ? 5 : 10)))) * mag let a = floor(lo / step + 0.000001) let b0 = ceil(hi / step - 0.000001) let b = b0 == a ? a + 1 : b0 { lo: a * step, hi: b * step, ticks: for i in range(b - a + 1) => (a + i) * step }}// A candle's time as it reads: "Mar 2, 2026" for a date, "Mar 2, 14:30" for a date-time.fn ohlc_chart_when(t) -> str = type_of(t) != "str" || !date.valid(t) ? str(t) : (len(t) > 10 ? date.format(t, "MMM D, HH:mm") : date.format(t, "MMM D, YYYY"))// Where the time axis is labelled: about `limit` of { i, text }, at the starts of years,// months or days when the times cross them ("2026", "Mar", "Mar 2"), else every few// candles ("14:30", "Mar 2").fn ohlc_chart_ticks(times: list, limit: int) -> list { let n = len(times) let thin = (idx: list) => { let step = ceil(len(idx) / limit) range(len(idx)) | filter((k) => k % step == 0) | map((k) => idx[k]) } let spaced = (fmt_at) => { let step = ceil(n / limit) range(n) | filter((i) => i % step == floor(step / 2)) | map((i) => { i, text: fmt_at(i) }) } if n == 0 { return [] } if !all(times, (t) => type_of(t) == "str" && date.valid(t)) { return spaced((i) => str(times[i])) } let days = times | map((t) => slice(t, 0, 10)) let changes = (keys: list) => range(1, n) | filter((i) => keys[i] != keys[i - 1]) let day_starts = changes(days) let month_starts = changes(days | map((d) => slice(d, 0, 7))) let year_starts = changes(days | map((d) => slice(d, 0, 4))) let picked = if any(times, (t) => len(t) > 10) { len(day_starts) >= 3 ? thin(day_starts) | map((i) => { i, text: date.format(days[i], "MMM D") }) : spaced((i) => index_of(day_starts, i) != null ? date.format(days[i], "MMM D") : date.format(times[i], "HH:mm")) } else if len(year_starts) >= 3 && len(month_starts) > limit * 2 { thin(year_starts) | map((i) => { i, text: slice(days[i], 0, 4) }) } else if len(month_starts) >= 2 { thin(month_starts) | map((i) => { i, text: index_of(year_starts, i) != null ? slice(days[i], 0, 4) : date.format(days[i], "MMM") }) } else { spaced((i) => date.format(days[i], "MMM D")) } // Starts that fall close together (a short month) keep the first. let gap = n / (limit * 1.5) reduce(picked, [], (kept, t) => len(kept) == 0 || t.i - kept[-1].i >= gap ? kept + [t] : kept)}// The simple moving average of `period` values, null until there are that many.fn ohlc_chart_sma(xs: list, period: int) -> list { let out = [] let total = 0 for i in range(len(xs)) { total += xs[i] if i >= period { total -= xs[i - period] } out = out | push(i + 1 >= period ? total / period : null) } out}// The exponential moving average of `period` values (k = 2 / (period + 1)), started// from the simple average of the first `period`; null before that.fn ohlc_chart_ema(xs: list, period: int) -> list { let k = 2 / (period + 1) let out = [] let prev = null for i in range(len(xs)) { if i + 1 == period { prev = sum(take(xs, period)) / period } else if i + 1 > period { prev = xs[i] * k + prev * (1 - k) } out = out | push(prev) } out}view OhlcChart(candles: list, sma: list = [], ema: list = [], volume: bool = true, height: int = 300, volume_height: int = 64, last_line: bool = true, prefix: str = "", suffix: str = "", decimals: int = 2, label: str = "OHLC chart", show_label: bool = false, class: str? = null) { style global ohlc_chart_css let n = len(candles) let closes = candles | map((c) => c.close) let lines = (sma | map((p) => { name: `SMA ${p}`, values: ohlc_chart_sma(closes, p) })) + (ema | map((p) => { name: `EMA ${p}`, values: ohlc_chart_ema(closes, p) })) let hues = [1, 2, 7, 4, 5, 3, 6, 8] let known = flatten(candles | map((c) => [c.low, c.high])) + flatten(lines | map((l) => l.values | filter((v) => v != null))) let sc = ohlc_chart_scale(n == 0 ? 0 : min(known), n == 0 ? 1 : max(known), 5) let y = (v) => (sc.hi - v) * 100 / (sc.hi - sc.lo) let x = (i) => (i + 0.5) * 100 / n let pct = (v) => ohlc_chart_pct(v) let vols = candles | map((c) => c.volume ?? 0) let vtop = max([0] + vols) let price = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${ohlc_chart_num(abs(v), decimals)}${suffix}` let tick = (t) => `${t < 0 ? "-" : ""}${prefix}${ohlc_chart_num(abs(t))}` let change = (i) => { let before = i > 0 ? candles[i - 1].close : candles[i].open before != 0 ? (candles[i].close - before) * 100 / before : 0 } let side = (up) => up ? "ui-ohlc-up" : "ui-ohlc-down" let last = n > 0 ? candles[-1] : null let axis_width = max([4] + (sc.ticks | map((t) => len(tick(t)))) + [last != null ? len(price(last.close)) + 1 : 0]) let line_path = (vals: list) => { let at = range(n) | filter((i) => vals[i] != null) len(at) == 0 ? "" : "M" + join(at | map((i) => `${pct(x(i))},${pct(y(vals[i]))}`), " L") } <figure class={ui_cls(["ui-ohlc", class])} aria-label={label} style={`--h: ${height}px; --vh: ${volume ? volume_height : 0}px`}> {show_label && <figcaption.ui-ohlc-title>{label}</figcaption>} {len(lines) > 0 && <ul.ui-ohlc-legend aria-hidden="true"> {for [j, l] in enumerate(lines) => <li><span class={`ui-ohlc-key ui-ohlc-s${hues[j % 8]}`}></span>{l.name}</li>} </ul>} <div.ui-ohlc-frame aria-hidden="true"> <div.ui-ohlc-plot> <div.ui-ohlc-pane> {for t in sc.ticks => <span.ui-ohlc-grid style={`top: ${pct(y(t))}%`}></span>} {last_line && last != null && <span class={`ui-ohlc-last ${side(last.close >= last.open)}`} style={`top: ${pct(y(last.close))}%`}></span>} {len(lines) > 0 && <svg.ui-ohlc-svg viewBox="0 0 100 100" preserveAspectRatio="none"> {for [j, l] in enumerate(lines) => <path class={`ui-ohlc-ma ui-ohlc-s${hues[j % 8]}`} d={line_path(l.values)} vector-effect="non-scaling-stroke"/>} </svg>} </div> {volume && <div.ui-ohlc-vpane></div>} <div.ui-ohlc-cols> {for [i, c] in enumerate(candles) => <div class={ui_cls(["ui-ohlc-col", side(c.close >= c.open), n > 1 && i >= n / 2 && "ui-ohlc-flip"])}> <div.ui-ohlc-cp> <span.ui-ohlc-bar style={`top: ${pct(y(c.high))}%; height: ${pct(y(c.low) - y(c.high))}%`}></span> <span.ui-ohlc-open style={`top: ${pct(y(c.open))}%`}></span> <span.ui-ohlc-close style={`top: ${pct(y(c.close))}%`}></span> </div> {volume && <div.ui-ohlc-cv> <span.ui-ohlc-vol style={`height: ${vtop > 0 ? pct(vols[i] * 100 / vtop) : 0}%`}></span> </div>} <span.ui-ohlc-tip> <strong>{ohlc_chart_when(c.time)}</strong> <p>Open<b>{price(c.open)}</b></p> <p>High<b>{price(c.high)}</b></p> <p>Low<b>{price(c.low)}</b></p> <p>Close<b>{price(c.close)}</b></p> <p class={`ui-ohlc-chg ${side(change(i) >= 0)}`}>Change<b>{ohlc_chart_signed(change(i))}</b></p> {volume && <p>Volume<b>{ohlc_chart_num(vols[i], 0)}</b></p>} {for [j, l] in enumerate(lines) => <p> <span class={`ui-ohlc-key ui-ohlc-s${hues[j % 8]}`}></span>{l.name}<b>{price(l.values[i])}</b> </p>} </span> </div>} </div> </div> <div.ui-ohlc-y style={`width: ${axis_width}ch`}> <div style="height: var(--h)"> {for t in sc.ticks => !(last_line && last != null && abs(y(t) - y(last.close)) * height < 1400) && <span style={`top: ${pct(y(t))}%`}>{tick(t)}</span>} {last_line && last != null && <span class={`ui-ohlc-tag ${side(last.close >= last.open)}`} style={`top: ${pct(y(last.close))}%`}>{price(last.close)}</span>} </div> {volume && <div style="height: var(--vh); margin-top: 16px"> {vtop > 0 && <span style="top: 2px; transform: none">{ohlc_chart_short(vtop)}</span>} </div>} </div> <div.ui-ohlc-x> {for t in ohlc_chart_ticks(candles | map((c) => c.time), 7) => <span class={ui_cls([x(t.i) < 4 && "ui-ohlc-first", x(t.i) > 96 && "ui-ohlc-end"])} style={`left: ${pct(x(t.i))}%`}>{t.text}</span>} </div> <span></span> </div> <table.ui-sr> <caption>{label}</caption> <thead><tr> <th scope="col">Time</th><th scope="col">Open</th><th scope="col">High</th> <th scope="col">Low</th><th scope="col">Close</th>{volume && <th scope="col">Volume</th>} </tr></thead> <tbody> {for [i, c] in enumerate(candles) => <tr> <th scope="row">{ohlc_chart_when(c.time)}</th> <td>{price(c.open)}</td><td>{price(c.high)}</td><td>{price(c.low)}</td><td>{price(c.close)}</td> {volume && <td>{ohlc_chart_num(vols[i], 0)}</td>} </tr>} </tbody> </table> </figure>}Related
BollingerChartPrices with Bollinger bands of a period and a number of deviations, closes outside them marked, as a line or candles.7 views · 5 downloadsCandlestickChartOHLC candles, green up and red down, with a volume pane, a date axis, moving averages and the details of each candle on hover.5 views · 7 downloadsDepthChartAn order book's bids and asks as stepped areas of the size waiting at each price or better, around the mid price, with the spread.5 views · 7 downloadsHeikinAshiChartHeikin-Ashi candles worked out from OHLC, which smooth the noise so trends show as runs of one colour, with the real close on hover.5 views · 6 downloadsLineBreakChartA three-line break chart from closing prices: lines go on while the close makes new ends, and turn only past the last three lines.5 views · 6 downloadsLiveCandlesCandles built from ticks as they come: the open candle grows, a new one starts each interval, with the last price and the time left.6 views · 6 downloads
Thread
Thread 0 posts
On OhlcChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.