Key takeaways
- Instantly see whether current volume is above or below the historical norm for that specific time of day
- Bi-directional mode reveals session biases by splitting bullish and bearish volume, useful for timing entries around recurring patterns
- Switch to median analysis to prevent single outlier events from distorting your volume baseline for weeks
- Run it locally with PineTS on Node.js to build volume anomaly detection into automated trading systems
Loading chart…
What Does Volume by Time Actually Do?
Ever stare at a volume bar and wonder “is this actually high for 2 PM on a Tuesday, or is it just normal?” Regular volume indicators show you the raw number, but without context. A 500 BTC volume bar means nothing if you don’t know whether that time slot typically sees 200 or 2,000.
LuxAlgo’s Volume by Time solves exactly that problem. It tracks the volume at every point in the trading day, builds up a historical dataset, and then overlays the average (or median) for that specific time slot against the current bar’s volume. Hollow bars show you what volume usually looks like at this time of day. Solid columns show what’s happening right now. When the solid column towers over the hollow bar, something unusual is going on. When it’s tiny in comparison, the market is quieter than expected. Simple concept, surprisingly powerful for algo trading.
Running It Locally with PineTS
Running this off TradingView lets you build volume anomaly detection into your own systems.
Install PineTS
Grab it from npm:
npm install pinets
Loading the Pine Script Indicator
PineTS runs Pine Script v6 directly in Node.js, no browser required. Point it at the .pine source file and wire up a data provider. Internally it uses a map<int, vols> to bucket volume by time of day. PineTS supports maps and custom types, so the entire time-bucketing logic works as expected. You get 5 plots: the average volume candle, the current volume candle, and three status line outputs.
Batch Execution with pineTS.run()
Load the indicator and run it against historical data in one shot:
// PineTS batch execution example template
import { PineTS, Provider } from 'pinets'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const pineScript = readFileSync(join(__dirname, 'source.pine'), 'utf-8')
const pineTS = new PineTS(Provider.Binance, 'BTCUSDC', '1w', 500)
const ctx = await pineTS.run(pineScript)
console.log('Bars processed:', ctx.marketData.length)
console.log('Plots:', Object.keys(ctx.plots).join(', '))
for (const [name, plot] of Object.entries(ctx.plots)) {
const lastPoint = plot.data?.[plot.data.length - 1]
if (lastPoint) {
console.log(` ${name}: last value = ${lastPoint.value}`)
}
}
That processes 500 weekly BTCUSDC candles and gives you both the current volume and the time-bucketed historical average for each bar. Compare those two values to flag bars where volume significantly exceeds or falls short of the historical norm for that time slot.
Live Streaming with pineTS.stream()
For real-time volume monitoring, the stream API updates on every new candle:
// PineTS live streaming example template
import { PineTS, Provider } from 'pinets'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const pineScript = readFileSync(join(__dirname, 'source.pine'), 'utf-8')
const pineTS = new PineTS(Provider.Binance, 'BTCUSDC', '1w', 500)
let count = 0
const stream = pineTS.stream(pineScript)
stream.on('data', (ctx) => {
count++
console.log(`[Update ${count}] Bars: ${ctx.marketData.length}, Plots: ${Object.keys(ctx.plots).length}`)
for (const [name, plot] of Object.entries(ctx.plots)) {
const lastPoint = plot.data?.[plot.data.length - 1]
if (lastPoint) {
console.log(` ${name}: ${lastPoint.value}`)
}
}
if (count >= 5) {
console.log('Received 5 updates, stopping stream.')
process.exit(0)
}
})
stream.on('error', (err) => {
console.error('Stream error:', err.message)
process.exit(1)
})
Each callback gives you a fresh context with updated averages. As more bars accumulate, the time-bucketed averages become more reliable. Hook this into your bot and you can trigger alerts or adjust position sizes based on whether the current bar’s volume is abnormally high or low relative to its historical baseline at that specific time.
Indicator Properties
Inputs
| Input | Type | Default | Description |
|---|---|---|---|
| Analysis Type | string | Average | Choose between Average or Median for the historical volume calculation |
| Length (Days) | int | 0 | Number of days to include in the average. 0 means use all available history. |
| Bi-Directional | bool | false | When enabled, bullish volume is positive and bearish volume is negative, splitting the histogram |
| Bullish Color | color | #089981 | Color for the average volume bar when the historical average is bullish |
| Bearish Color | color | #f23645 | Color for the average volume bar when the historical average is bearish |
| Up Volume Color | color | gray (60% transparent) | Fill color for the current volume bar on bullish candles |
| Down Volume Color | color | black (60% transparent) | Fill color for the current volume bar on bearish candles |
Plots
| Plot Name | Type | Description |
|---|---|---|
| Average Volume | plotcandle (hollow) | Historical average volume for this time slot, displayed as an outline bar. Border color reflects bullish/bearish direction. |
| Volume | plotcandle (solid) | Current bar’s volume, displayed as a filled column. Color indicates whether the current bar closed up or down. |
| Volume (status line) | plot (columns) | Current volume shown in the status line for quick reference |
| Average Volume (status line) | plot (columns) | Average volume shown in the status line |
| Avg Length Readout | plot (status line) | Number of days currently in the dataset for the active time bucket |
Alerts
No built-in alerts. In PineTS, just check if current volume exceeds some multiple of the average and fire from there.
Understanding the Indicator
How the Time Bucketing Works
The core data structure is a map<int, vols> where the key is hour*10000 + minute*100 + second. So 2:30 PM becomes 143000. Each key maps to a vols type, which is just a wrapper around an array of floats. Every time a new bar comes in, the indicator checks if there’s already a bucket for that timestamp. If not, it creates one with the current volume. If the bucket exists, it appends the current volume to the array.
I love this design. Most volume-profile indicators pre-allocate a fixed grid of time slots and waste memory on hours the market is closed. LuxAlgo skips all that. The map grows organically based on whatever data actually comes through, so if you’re running it on a market that only trades 6 hours a day, you only get 6 hours of buckets. Zero wasted slots.
When the Length input is set to something other than 0, the indicator caps each bucket at that many entries. The if sz != 0 and data.get(hms).ary.size() == sz check calls shift() to remove the oldest entry before pushing the new one. This gives you a rolling window. Set it to 20 and you’re averaging the last 20 occurrences of, say, the 3 PM bar. Set it to 0 and you’re averaging everything the indicator has ever seen.
Bi-Directional Mode and Volume Signing
When Bi-Directional is toggled on, volume gets signed: v = close > open ? vol : -vol. Bars that closed up get positive volume, bars that closed down get negative. This means the average for a time bucket might be positive (historically more bullish volume at that time) or negative (historically more bearish). The display splits, with bullish averages plotted above zero and bearish averages below.
Crypto markets make this mode particularly revealing. If 3 AM UTC historically shows negative signed volume on BTCUSDC (more bearish closes at that hour), you’ve just identified a session bias. Not a guarantee, but a statistically grounded starting point for timing your entries.
Average vs. Median Analysis
The Analysis Type input lets you switch between data.get(hms).ary.avg() and data.get(hms).ary.median(). Average is the default and works well for most cases. But volume distributions tend to be heavily right-skewed. A single massive candle (news event, liquidation cascade) can pull the average way up and make normal bars look tiny by comparison for weeks afterward.
Median fixes that. It gives you the middle value of the distribution, which is much more resistant to those outlier events. If you’re building alert logic around “volume above 2x average,” I’d strongly recommend switching to Median. Otherwise one whale candle at 4 PM last month will keep suppressing your signals for that time slot.
Using It for Algorithmic Signal Generation
Alright, so the obvious play here is volume anomaly detection. Compare the current bar’s volume against the time-bucketed average and flag anything above, say, 2x the average. That’s your “unusual activity” signal. In my experience, volume spikes at unusual times (volume that’s 3x+ the norm for that specific hour) often precede directional moves. The market doesn’t get loud for no reason.
Pair this with a trend indicator. Volume above average in the direction of the trend = confirmation. Volume above average against the trend = potential reversal or stop hunt. The bi-directional mode makes this trivial because you can see whether the excess volume is bullish or bearish without checking candle direction separately.
There’s a subtler use case too. Low volume at a time that usually has high volume can signal a holiday, a pre-event lull, or a lack of conviction. If you normally see 800 BTC traded at 2 PM UTC on BTCUSDC and today it’s only 200, that’s a signal to tighten your stops or reduce position size. The absence of expected volume is as informative as its presence.
For execution timing, use the historical averages to figure out when liquidity is deepest. Place your larger orders during the time buckets with the highest average volume. That’s where you’ll get the tightest fills and the least slippage. Simple, but it makes a real difference on bigger positions.
Performance Notes
The map-based storage means memory usage grows with the number of unique time buckets. On a 1-minute chart, you’ll have roughly 1,440 buckets per 24-hour day (one per minute). Each bucket stores an array of floats, one per day of history. With the default Length of 0, that array grows indefinitely. On 500 bars of weekly data this is trivial, but on 1-minute charts with years of history, keep the Length input to something reasonable (50-100 days) to avoid excessive memory use.
PineTS handles the map.new<int, vols> and array operations correctly. The custom vols type compiles and executes as expected. One thing worth noting: the hms key uses the bar’s hour/minute/second, which on weekly or daily timeframes will be the same for every bar (typically the session open time). The indicator is really designed for intraday timeframes where each bar has a unique time-of-day signature. On weekly BTCUSDC, every bar maps to the same bucket, so the “average” becomes just the running average of all weekly bars. Still functional, but the time-bucketing feature only shines on 1H and below.
Important Notes
Tested clean on Pine v6, all 5 plots rendering correctly. The map-based time bucketing with the custom vols type works in PineTS without issues, which is worth noting since maps and UDTs are newer Pine features.
If your data source has spotty volume (common on smaller exchanges or low-cap pairs), the historical averages will skew fast. Stick with high-liquidity pairs where the feed is clean. Binance works well for BTCUSDC.
Intraday traders are the natural audience here. On daily and weekly timeframes it still runs fine, but the time-bucketing collapses into a single bucket per bar since every bar has the same session-open timestamp. Use it on 1H, 15M, or 5M charts to get the most out of LuxAlgo’s time-of-day profiling.
// This work is licensed under a Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) https://creativecommons.org/licenses/by-nc-sa/4.0/
// © LuxAlgo
//@version=6
indicator("Volume by Time [LuxAlgo]", "LuxAlgo - Volume by Time", format = format.volume)
//---------------------------------------------------------------------------------------------------------------------}
//User Inputs
//---------------------------------------------------------------------------------------------------------------------{
avgType = input.string("Average", title = "Analysis Type", options = ["Average","Median"])
sz = input.int(0, title = "Length (Days)", tooltip = "Averaging Length\nSet Value to 0 for Max Analysis Length")
bidi = input.bool(false, title = "Bi-Directional", tooltip = "Enable Bi-Directional Display\nBearish Volume will be negative, Bullish Volume will be positive.")
upCol = input.color(#089981, title = "Bullish Color", group = "Style")
downCol = input.color(#f23645, title = "Bearish Color", group = "Style")
upVolCol = input.color(color.new(color.gray,60), title = "Up Volume Color", group = "Style")
downVolCol = input.color(color.new(color.black,60), title = "Down Volume Color", group = "Style")
invis = color.rgb(0,0,0,100)
//---------------------------------------------------------------------------------------------------------------------}
//UDTs
//---------------------------------------------------------------------------------------------------------------------{
type vols
array<float> ary
//---------------------------------------------------------------------------------------------------------------------}
//Variables
//---------------------------------------------------------------------------------------------------------------------{
var data = map.new<int,vols>()
vol = volume
v = close > open ? vol : -vol
hms = hour*10000 + minute*100 + second
//---------------------------------------------------------------------------------------------------------------------}
//Calculations
//---------------------------------------------------------------------------------------------------------------------{
if na(data.get(hms))
data.put(hms,vols.new(array.from(float(vol))))
else
if sz != 0 and data.get(hms).ary.size() == sz
data.get(hms).ary.shift()
data.get(hms).ary.push(v)
raw_avg = avgType == "Average" ? data.get(hms).ary.avg() : data.get(hms).ary.median()
avg = avgType == "Average" ? data.get(hms).ary.abs().avg() : data.get(hms).ary.abs().median()
avg_col = raw_avg > 0 ? upCol : downCol
vol_col = close > open ? upVolCol : downVolCol
dir_avg = raw_avg > 0 ? avg : -avg
//---------------------------------------------------------------------------------------------------------------------}
//Display
//---------------------------------------------------------------------------------------------------------------------{
plotcandle(0,bidi?dir_avg:avg,0,bidi?dir_avg:avg, bordercolor = avg_col, color = invis, wickcolor = invis, title = "Average Volume", display = display.pane, editable = false)
plotcandle(0,bidi?v:vol,0,bidi?v:vol, bordercolor = vol_col, color = vol_col, wickcolor = invis, title = "Volume", display = display.pane, editable = false)
plot(vol, style = plot.style_columns, color = vol_col, title = "Volume", display = display.status_line, editable = false)
plot(avg, style = plot.style_columns, color = avg_col, title = "Average Volume", display = display.status_line, editable = false)
plot(data.get(hms).ary.size(), display = display.status_line, color = chart.fg_color, format = format.volume, title = "Avg Length Readout")
//---------------------------------------------------------------------------------------------------------------------}
Disclaimer
QuantForge publishes technical material about indicators: what they do, how they are built, and how you can experiment with them in code and on charts. All of this is offered for education and illustration only. It is not financial, investment, or trading advice, and it should not be read as a recommendation to buy, sell, or hold any asset. Past or simulated performance does not guarantee future results. You are responsible for your own decisions; seek independent professional advice where appropriate.

![Volume by Time [LuxAlgo] – Intraday Volume Profile with Historical Average](https://quantforge.org/wp-content/uploads/2026/04/screenshot.webp)