Volume indicators

Volume indicators fold volume into the calculation to read buying and selling pressure, money flow, and participation. kScript (legacy) shipped mfi, obv, vwap…

Volume indicators fold volume into the calculation to read buying and selling pressure, money flow, and participation. kScript (legacy) shipped mfi, obv, vwap, and cum as builtins; all four ship as classes in src/sdk/ta.ts (Mfi, Obv, Vwap, Cum), each matching the kScript engine bar for bar. Volume itself is an ordinary input (input("volume", ohlcv.volume)), and the side split the kScript buy_sell_volume source gave you is two trades.volume inputs with a side.

TypeWhat it reads
Money flow index (Mfi)a volume-weighted RSI, 0..100; overbought above 80, oversold below 20
On-balance volume (Obv)a cumulative line that adds volume on up bars and subtracts it on down bars
Volume-weighted average price (Vwap)the fair price weighted by where volume traded, cumulative or anchored
Cumulative sum (Cum)the running total of any series, the primitive behind OBV-style accumulation

For volume-weighted moving averages see Vwma on the Moving averages page; for per-price buy and sell volume inside one bar see Volume profile.

Mfi

new Mfi(period = 14), .update(high, low, close, volume). Each bar's raw money flow is the typical price (high + low + close) / 3 times volume, counted as positive when the typical price rose against the previous bar, negative when it fell, and neither when equal; the index is 100 - 100 / (1 + positive / negative) over the last period bars, and 100 when the negative sum is 0. Like Rsi, but weighted by volume, so it reads pressure rather than price alone. The first value lands at bar period; a non-finite bar fails both comparisons and adds nothing, as in the engine.

Obv

new Obv(), .update(close, volume). A running cumulative line with no period: bar 0 returns 0, then the bar's volume is added when the close rose against the previous close, subtracted when it fell, and ignored when equal (or when either close is NaN). The whole state is the previous close and the running total, which reset() clears. Its level depends on how much history the host loaded; its slope does not, and the slope is what confirms or contradicts price.

Vwap

new Vwap(anchor = "", price = "hlc3"), .update(open, high, low, close, volume, tsMs). anchor is "" for one accumulation from the first bar (what vwap() computes), "day", "week", "month", "quarter", or "year" to restart the sums at each UTC calendar boundary (a week starts Monday 00:00 UTC), or a number of milliseconds as a string ("14400000") for a fixed bucket width floored from the epoch. price picks the bar price: hlc3, hl2, ohlc4 (the only mode that reads the open), hlcc4, or close. tsMs is the bar's open time in milliseconds since the epoch and is only read when an anchor is set: the time source delivers seconds, so pass in_bar_t() * 1000.0. Three properties worth knowing:

  1. Anchored VWAP is stable under history loading. Each bucket computes only from its own bars, so loading older history cannot change later buckets' values. The unanchored cumulative form does shift when history loads: its anchor is the data edge.
  2. There is no warm-up. The first bar of the series starts a bucket, even a partial one, so a load that begins mid-session shows a value from the first bar, computed from the bars it has; the value settles once a full bucket is in view. A bar whose high, low, close, or volume is not finite marks the current bucket NaN until the next bucket starts (forever, in the cumulative form), and a zero total volume gives NaN.
  3. Session calendars are not mirrored. The engine's session-calendar bucketing on venues with trading sessions, its regular-hours filter, and its deep-history lane for quarter and year anchors need data a per-bar class never sees; the calendar anchors above are computed on the UTC clock. The day anchor and numeric bucket widths are proven against the engine; the week, month, quarter, and year anchors are ported from the same arithmetic and left unproven.

The anchor modes and the calendar arithmetic are described on the Special indicators page; the module below carries the cumulative and daily forms.

Cum

new Cum(), .update(x). It is barely a class: a running total of whatever you feed it from the start of the data, bar 0 returning x itself. It is the primitive behind OBV-style accumulation and custom anchored math (feed it signed volume for a delta proxy).

const cumDelta = new Cum();
let deltaValue: f64 = NaN;

export function state(): i32 {
  const volume = in_volume();
  // Signed volume: the bar's volume counted toward the side of its close.
  deltaValue = cumDelta.update(in_close() >= in_open() ? volume : -volume);
  return isNaN(deltaValue) ? 0 : 1;
}

A non-finite sample sets the running total to NaN for the rest of the history, which is the engine's cum rule; a sparse input that must not poison it goes through Fixnan first, or through a missing: "zero" policy on the source (Series functions).

Putting them together

The four on one lower pane plus the two VWAP lines on the price pane: a money flow index, on-balance volume, a cumulative signed-volume delta proxy, and the cumulative and daily VWAPs.

import { input, line, lower, ohlcv, output, overlay, param, time } from "./sdk/declare";
import { in_bar_t, in_close, in_high, in_low, in_open, in_volume } from "./gen/inputs";
import { emitRow, out_cum_delta, out_mfi, out_obv, out_vwap_cum, out_vwap_day } from "./gen/outputs";
import { p_mfi_period } from "./gen/params";
import { Cum, Mfi, Obv, Vwap } from "./sdk/ta";

param("mfi_period", 14, { min: 2, max: 200 });
input("close", ohlcv.close);
input("open", ohlcv.open);
input("high", ohlcv.high);
input("low", ohlcv.low);
input("volume", ohlcv.volume);
input("bar_t", time.bar_open_sec);
output("vwap_cum", line, overlay, { color: "#2563eb", width: 1, description: "Cumulative VWAP from the first loaded bar" });
output("vwap_day", line, overlay, { color: "#eab308", width: 2, description: "VWAP reset at each UTC day boundary" });
output("mfi", line, lower, { color: "#16a34a", width: 2, description: "Money flow index, 0..100" });
output("obv", line, lower, { color: "#0f766e", width: 1, description: "On-balance volume" });
output("cum_delta", line, lower, { color: "#22d3ee", width: 1, description: "Cumulative signed volume: a delta proxy" });

let mfi = new Mfi(14);
const obv = new Obv();
const cumDelta = new Cum();
const vwapCum = new Vwap();
const vwapDay = new Vwap("day");
let mfiValue: f64 = NaN;
let obvValue: f64 = NaN;
let deltaValue: f64 = NaN;
let vwapCumValue: f64 = NaN;
let vwapDayValue: f64 = NaN;

export function init(): void {
  mfi = new Mfi(i32(p_mfi_period()));
}

export function state(): i32 {
  const close = in_close();
  const high = in_high();
  const low = in_low();
  const volume = in_volume();
  mfiValue = mfi.update(high, low, close, volume);
  obvValue = obv.update(close, volume);
  const open = in_open();
  // Signed volume: the bar's volume counted toward the side of its close.
  deltaValue = cumDelta.update(close >= open ? volume : -volume);
  const tsMs = in_bar_t() * 1000.0;
  vwapCumValue = vwapCum.update(open, high, low, close, volume, tsMs);
  vwapDayValue = vwapDay.update(open, high, low, close, volume, tsMs);
  return 1;
}

export function finalize(): void {
  out_vwap_cum(vwapCumValue);
  out_vwap_day(vwapDayValue);
  out_mfi(mfiValue);
  out_obv(obvValue);
  out_cum_delta(deltaValue);
  emitRow();
}

export function reset(): void {
  mfi.reset();
  obv.reset();
  cumDelta.reset();
  vwapCum.reset();
  vwapDay.reset();
  mfiValue = NaN;
  obvValue = NaN;
  deltaValue = NaN;
  vwapCumValue = NaN;
  vwapDayValue = NaN;
}

Buy and sell volume

The close-versus-open sign above is a proxy. The real side split is a source: trades serves per-period volume aggregated by aggressor side, one series per side, so the port of kScript's .buy and .sell columns is two inputs on the same feed with a side. A venue that skips bars on one side delivers nothing on that bar; missing: "zero" turns the gap into 0 so the delta stays finite. The chart lane serves trades volume with a side; on your machine the same declarations fetch it.

import { histogram, input, line, lower, none, ohlcv, output, trades } from "./sdk/declare";
import { in_buy, in_close, in_sell } from "./gen/inputs";
import { emitRow, out_cvd, out_delta, out_delta_sign } from "./gen/outputs";

input("close", ohlcv.close);
input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Volume traded by aggressive buyers" });
input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Volume traded by aggressive sellers" });
output("delta", histogram, lower, { color_by: "delta_sign", colors: ["#ef5350", "#26a69a"], description: "Buy minus sell volume per bar" });
output("delta_sign", none, lower, { description: "0 on a sell-dominant bar, 1 on a buy-dominant bar" });
output("cvd", line, lower, { color: "#22d3ee", width: 2, description: "Cumulative volume delta" });

let delta: f64 = NaN;
let cvd: f64 = 0.0;

export function init(): void {}

export function state(): i32 {
  in_close();
  delta = in_buy() - in_sell();
  cvd += delta;
  return 1;
}

export function finalize(): void {
  out_delta(delta);
  out_delta_sign(delta >= 0.0 ? 1.0 : 0.0);
  out_cvd(cvd);
  emitRow();
}

export function reset(): void {
  delta = NaN;
  cvd = 0.0;
}

The primary input stays the close so the package follows the chart's selector; the two side inputs align to its grid row for row. The aggregated CVD recipe extends this across venues with symbol + exchange pins.

Reading them

  • Volume quality. High volume on a breakout confirms the move; a low-volume breakout often reverses. delta and cvd say which side did the volume, not just how much.
  • Divergence. Price at a new high while obv or cvd is not is a warning; the same pattern on mfi above 80 is a classic exhaustion read.
  • Anchors. The daily VWAP is the intraday fair price; the cumulative line is context that drifts with the loaded window.