Charts

VolumeProfile

Volume traded at each price level as bars beside the price, split into buying and selling, with the point of control and the value area marked.

#chart#data#trading#no-js

Demo

Preview
Hooli, six months

Point of control $92.50 · value area (70%) $87.00 to $94.00

Hooli, six months: volume by price, highest first
PriceVolumeBoughtSold
$97.00 to $98.00370,357167,209203,149
$96.00 to $97.001,368,980522,527846,453
$95.00 to $96.005,957,2103,052,9392,904,272
$94.00 to $95.0013,769,5036,749,4937,020,010
$93.00 to $94.0026,189,30612,702,78713,486,519
$92.00 to $93.0034,516,40816,595,13417,921,274
$91.00 to $92.0032,003,52318,490,40113,513,122
$90.00 to $91.0024,176,74712,379,35211,797,395
$89.00 to $90.0019,659,5147,966,20611,693,308
$88.00 to $89.0015,990,1807,950,7148,039,466
$87.00 to $88.0018,654,2959,068,6509,585,646
$86.00 to $87.0013,261,8143,767,8539,493,961
$85.00 to $86.0013,133,2447,654,8075,478,437
$84.00 to $85.009,454,9275,927,9833,526,943
$83.00 to $84.003,225,2402,831,921393,318
BTC-USD, a week of hours

Point of control 59,300 · value area (68%) 59,000 to 60,200

BTC-USD, a week of hours: volume by price, highest first
PriceVolumeBoughtSold
61,000 to 61,20044,158044,158
60,800 to 61,000128,5600128,560
60,600 to 60,800128,9560128,956
60,400 to 60,600166,14662,780103,367
60,200 to 60,400364,224192,007172,216
60,000 to 60,200516,972275,464241,507
59,800 to 60,000617,903315,912301,991
59,600 to 59,800713,784302,369411,415
59,400 to 59,600774,898294,407480,491
59,200 to 59,4001,121,926413,425708,501
59,000 to 59,200758,330249,582508,748
58,800 to 59,000373,966161,226212,740
58,600 to 58,800208,53077,286131,244
58,400 to 58,60051,58517,52334,062
58,200 to 58,40020,35317,5232,830
58,000 to 58,2002,8912,8910

Hover a level for its volume, and how much of it closed up or down.

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/volume_profile.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/volume_profile.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/volume_profile.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 volume_profile_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 volume_profile_demo_daily = volume_profile_demo_walk(31, 126, 88.4, "2026-04-01")let volume_profile_demo_hourly = volume_profile_demo_walk(8, 35, 61250, "2026-10-19", true, 0)view VolumeProfileDemo() =    <div.demo-stack>        <div id="volume_profile-demo-daily">            <VolumeProfile label="Hooli, six months" show_label={true} candles={volume_profile_demo_daily} prefix="$"/>        </div>        <div id="volume_profile-demo-hourly">            <VolumeProfile label="BTC-USD, a week of hours" show_label={true} candles={volume_profile_demo_hourly}                           rows={16} value_area={68} decimals={0} height={220}/>        </div>        <p.demo-note>Hover a level for its volume, and how much of it closed up or down.</p>    </div>

Source

ui/volume_profile.qwe
// VolumeProfile: how much was traded at each price level, as horizontal bars beside// the price, split into buying (candles that closed up) and selling (closed down).// The point of control, the level that traded most, is marked across the chart, and// the value area, the levels around it holding 70% of the volume, is shaded with its// high and low on the axis. CSS only: hovering a level shows its range, volume and// split, and screen readers get the levels as a table.////   <VolumeProfile label="ACME, 6 months" candles={bars} prefix="$"/>//   <VolumeProfile label="BTC-USD, 5 days" candles={hourly} rows={32} value_area={68} decimals={0}/>//// candles: a list of { time, open, high, low, close, volume }, oldest first (time a// date or a date-time). Each candle's volume is spread evenly over the levels its low// to high crosses. rows: about how many price levels (24); they start and end on round// prices, so there may be a few more or fewer. value_area: the share of the// volume, in percent, the value area holds (70). height: the plot's height in pixels// (300). 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 volume_profile_css = css`.ui-vp { --ui-vp-line: light-dark(#2a78d6, #3987e5); --ui-vp-poc: light-dark(#eda100, #c98500);  display: grid; gap: 10px; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); container-type: inline-size; }.ui-vp-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-vp-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-vp-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-vp-key { width: 10px; height: 10px; border-radius: 3px; flex: none; background: var(--c); }.ui-vp-buy { --c: var(--ui-success); } .ui-vp-sell { --c: var(--ui-danger); }.ui-vp-pockey { --c: var(--ui-vp-poc); height: 3px; width: 14px; }.ui-vp-frame { display: grid; grid-template-columns: minmax(0, 1fr) minmax(90px, 32%) auto; column-gap: 8px; padding-top: 8px; }.ui-vp-plot, .ui-vp-prof, .ui-vp-y { position: relative; height: var(--h); }.ui-vp-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-vp-va { position: absolute; left: 0; right: 0; background: color-mix(in srgb, var(--ui-vp-poc) 7%, transparent);  border-block: 1px dashed color-mix(in srgb, var(--ui-vp-poc) 45%, transparent); }.ui-vp-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-vp-line { fill: none; stroke: var(--ui-vp-line); stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; }.ui-vp-pocline { position: absolute; z-index: 1; left: 0; right: 0; border-top: 2px solid var(--ui-vp-poc); pointer-events: none; }.ui-vp-row { position: absolute; left: 0; right: 0; display: flex; align-items: stretch; padding: 1px 0; }.ui-vp-row:hover { background: color-mix(in srgb, var(--ui-subtle) 80%, transparent); }.ui-vp-bar { display: flex; height: 100%; min-width: 1px; border-radius: 0 2px 2px 0; overflow: hidden; opacity: .5; }.ui-vp-in .ui-vp-bar { opacity: .85; }.ui-vp-row:hover .ui-vp-bar { opacity: 1; }.ui-vp-bar span { height: 100%; background: var(--c); }.ui-vp-poc .ui-vp-bar { box-shadow: 0 0 0 2px var(--ui-vp-poc); opacity: 1; }.ui-vp-tip { position: absolute; z-index: 20; right: calc(100% + 8px); top: 50%; display: none; min-width: 160px; 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; transform: translateY(-50%); pointer-events: none; }.ui-vp-row:hover .ui-vp-tip { display: block; }.ui-vp-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-vp-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-vp-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-vp-y span { position: absolute; left: 0; transform: translateY(-50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-vp-y .ui-vp-tag { z-index: 1; left: -4px; padding: 1px 4px; border-radius: 3px; background: var(--ui-vp-poc); color: var(--ui-bg); font-weight: 600; }.ui-vp-y .ui-vp-edge { color: var(--ui-fg); font-weight: 500; }.ui-vp-x { position: relative; height: 16px; margin-top: 6px; }.ui-vp-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); white-space: nowrap; }.ui-vp-x .ui-vp-first { transform: none; }.ui-vp-x .ui-vp-end { transform: translateX(-100%); }.ui-vp-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-vp-note b { color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }`// n (a whole number, 0 or more) with at least `width` digits.fn volume_profile_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 volume_profile_group(n: int) -> str =    n < 1000 ? str(n) : volume_profile_group(floor(n / 1000)) + "," + volume_profile_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 volume_profile_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)}` : `.${volume_profile_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${volume_profile_group(floor(units / scale))}${tail}`}// A large number, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn volume_profile_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 ? volume_profile_num(v)        : `${v < 0 ? "-" : ""}${volume_profile_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A change in percent, signed, to two decimals: "+1.25%", "-0.40%".fn volume_profile_signed(p: num) -> str = `${p > 0 ? "+" : (p < 0 && round(abs(p) * 100) > 0 ? "-" : "")}${volume_profile_num(abs(p), 2)}%`// A coordinate out of 100 (or a length), to two decimals.fn volume_profile_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 volume_profile_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 volume_profile_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 volume_profile_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 levels: `rows` of { lo, hi, buy, sell, total } from lo to hi, each candle's volume// spread over the levels its low to high crosses, as buying when it closed up.fn volume_profile_levels(candles: list, lo: num, hi: num, rows: int) -> list {    let size = (hi - lo) / rows    let buy = for _ in range(rows) => 0    let sell = for _ in range(rows) => 0    for c in candles {        let v = c.volume ?? 0        let a = c.low < c.high ? c.low : c.close        let b = c.low < c.high ? c.high : c.close        let first = floor((a - lo) / size)        let last = floor((b - lo) / size)        let from = first < 0 ? 0 : (first >= rows ? rows - 1 : first)        let to = last < 0 ? 0 : (last >= rows ? rows - 1 : last)        for k in range(from, to + 1) {            let bot = lo + k * size            let top = bot + size            let part = b > a ? (min([b, top]) - max([a, bot])) / (b - a) : 1            let share = v * (part > 0 ? part : (from == to ? 1 : 0))            if c.close >= c.open { buy = set(buy, k, buy[k] + share) } else { sell = set(sell, k, sell[k] + share) }        }    }    for k in range(rows) => { lo: lo + k * size, hi: lo + (k + 1) * size, buy: buy[k], sell: sell[k], total: buy[k] + sell[k] }}// The value area: the indexes of the levels around the point of control (`poc`) that// hold `share` percent of the volume, grown each step toward the busier side.fn volume_profile_area(levels: list, poc: int, share: num) -> map {    let total = sum(levels | map((l) => l.total))    let a = poc    let b = poc    let held = levels[poc].total    while held < total * share / 100 && (a > 0 || b < len(levels) - 1) {        let below = a > 0 ? levels[a - 1].total : -1        let above = b < len(levels) - 1 ? levels[b + 1].total : -1        if above >= below {            b += 1            held += above        } else {            a -= 1            held += below        }    }    { from: a, to: b }}view VolumeProfile(candles: list, rows: int = 24, value_area: num = 70, height: int = 300, prefix: str = "",                   suffix: str = "", decimals: int = 2, label: str = "Volume profile", show_label: bool = false,                   class: str? = null) {    style global volume_profile_css    let n = len(candles)    let count = rows < 1 ? 1 : rows    let lo = n == 0 ? 0 : min(candles | map((c) => c.low))    let hi0 = n == 0 ? 1 : max(candles | map((c) => c.high))    let hi = hi0 > lo ? hi0 : lo + 1    let grid = volume_profile_scale(lo, hi, count)    let levels = volume_profile_levels(candles, grid.lo, grid.hi, len(grid.ticks) - 1)    let top_volume = max(levels | map((l) => l.total))    let poc = index_of(levels | map((l) => l.total), top_volume) ?? 0    let va = volume_profile_area(levels, poc, value_area)    let total = sum(levels | map((l) => l.total))    let sc = volume_profile_scale(lo, hi, 5)    let y = (v) => (sc.hi - v) * 100 / (sc.hi - sc.lo)    let x = (i) => (i + 0.5) * 100 / n    let pct = (v) => volume_profile_pct(v)    let price = (v) => `${v < 0 ? "-" : ""}${prefix}${volume_profile_num(abs(v), decimals)}${suffix}`    let tick = (t) => `${t < 0 ? "-" : ""}${prefix}${volume_profile_num(abs(t))}`    let vol = (v) => volume_profile_num(v, 0)    let mid = (l) => (l.lo + l.hi) / 2    let poc_price = mid(levels[poc])    let vah = levels[va.to].hi    let val = levels[va.from].lo    let marks = [{ v: poc_price, cls: "ui-vp-tag", text: price(poc_price) }, { v: vah, cls: "ui-vp-edge", text: price(vah) },                 { v: val, cls: "ui-vp-edge", text: price(val) }]    let near = (t) => any(marks, (m) => abs(y(t) - y(m.v)) * height < 1400)    let line = n == 0 ? "" : "M" + join(range(n) | map((i) => `${pct(x(i))},${pct(y(candles[i].close))}`), " L")    let axis_width = max([4] + (sc.ticks | map((t) => len(tick(t)))) + (marks | map((m) => len(m.text) + 1)))    <figure class={ui_cls(["ui-vp", class])} aria-label={label} style={`--h: ${height}px`}>        {show_label && <figcaption.ui-vp-title>{label}</figcaption>}        <ul.ui-vp-legend aria-hidden="true">            <li><span.ui-vp-key.ui-vp-buy></span>Bought (closed up)</li>            <li><span.ui-vp-key.ui-vp-sell></span>Sold (closed down)</li>            <li><span.ui-vp-key.ui-vp-pockey></span>Point of control</li>        </ul>        <div.ui-vp-frame aria-hidden="true">            <div.ui-vp-plot>                {for t in sc.ticks => <span.ui-vp-grid style={`top: ${pct(y(t))}%`}></span>}                <span.ui-vp-va style={`top: ${pct(y(vah))}%; height: ${pct(y(val) - y(vah))}%`}></span>                <svg.ui-vp-svg viewBox="0 0 100 100" preserveAspectRatio="none">                    <path.ui-vp-line d={line} vector-effect="non-scaling-stroke"/>                </svg>                <span.ui-vp-pocline style={`top: ${pct(y(poc_price))}%`}></span>            </div>            <div.ui-vp-prof>                <span.ui-vp-va style={`top: ${pct(y(vah))}%; height: ${pct(y(val) - y(vah))}%`}></span>                <span.ui-vp-pocline style={`top: ${pct(y(poc_price))}%`}></span>                {for [k, l] in enumerate(levels) => <div class={ui_cls(["ui-vp-row", k >= va.from && k <= va.to && "ui-vp-in", k == poc && "ui-vp-poc"])}                        style={`top: ${pct(y(l.hi))}%; height: ${pct(y(l.lo) - y(l.hi))}%`}>                    <span.ui-vp-bar style={`width: ${top_volume > 0 ? pct(l.total * 100 / top_volume) : 0}%`}>                        <span.ui-vp-buy style={`width: ${l.total > 0 ? pct(l.buy * 100 / l.total) : 0}%`}></span>                        <span.ui-vp-sell style={`width: ${l.total > 0 ? pct(l.sell * 100 / l.total) : 0}%`}></span>                    </span>                    <span.ui-vp-tip>                        <strong>{price(l.lo)} – {price(l.hi)}{k == poc ? " · point of control" : ""}</strong>                        <p>Volume<b>{vol(l.total)}</b></p>                        <p>Bought<b>{vol(l.buy)}</b></p>                        <p>Sold<b>{vol(l.sell)}</b></p>                        <p>Share<b>{total > 0 ? volume_profile_num(l.total * 100 / total, 1) : "0"}%</b></p>                    </span>                </div>}            </div>            <div.ui-vp-y style={`width: ${axis_width}ch`}>                {for t in sc.ticks => !near(t) && <span style={`top: ${pct(y(t))}%`}>{tick(t)}</span>}                {for m in marks => <span class={m.cls} style={`top: ${pct(y(m.v))}%`}>{m.text}</span>}            </div>            <div.ui-vp-x>                {for t in volume_profile_ticks(candles | map((c) => c.time), 6) => <span                    class={ui_cls([x(t.i) < 4 && "ui-vp-first", x(t.i) > 96 && "ui-vp-end"])} style={`left: ${pct(x(t.i))}%`}>{t.text}</span>}            </div>        </div>        <p.ui-vp-note>Point of control <b>{price(poc_price)}</b> · value area ({volume_profile_num(value_area)}%) <b>{price(val)}</b> to <b>{price(vah)}</b></p>        <table.ui-sr>            <caption>{label}: volume by price, highest first</caption>            <thead><tr><th scope="col">Price</th><th scope="col">Volume</th><th scope="col">Bought</th><th scope="col">Sold</th></tr></thead>            <tbody>                {for l in reverse(levels) => <tr>                    <th scope="row">{price(l.lo)} to {price(l.hi)}</th><td>{vol(l.total)}</td><td>{vol(l.buy)}</td><td>{vol(l.sell)}</td>                </tr>}            </tbody>        </table>    </figure>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On VolumeProfile: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.