For analysts and developers who know decimal log returns, sample variance, and consistently adjusted OHLC bars. The goal is to reproduce the mechanism, inspect its failure states, and decide what the output can legitimately tell you—not to fit or endorse a trading strategy.
Garman–Klass takes information from a candle's range and its open-to-close movement. The formula subtracts a body term from a range term. That subtraction deserves an explanation: it is not a penalty for a bullish candle, and it does not mean valid candles can freely produce negative variance contributions.
You will calculate both terms, prove a useful nonnegativity check, and learn when an opening gap makes this estimator the wrong summary. Start with positive OHLC prices and a working understanding of logarithms.
Open this figure at full size.
Two measurements from one candle
Write R=ln(H/L) for the full log range and B=ln(C/O) for the open-to-close log move. A candle contributes
Average g over w candles, then multiply by A to annualize variance. The Garman–Klass paper motivates combining the available price information; the TTR implementation reference documents this commonly used form.
The range and body are not two independent risks to add. They are overlapping observations of the same session. Under the estimator's classical model, the body correction accounts for part of that shared information. Neither the sign of B nor the candle's color survives squaring.
A worked candle, with both terms visible
Use synthetic log-price offsets relative to 100: open−0.005, high+0.010, low−0.015 and close 0. The range is 0.025 and the body is 0.005.
The range term is 0.5×0.025² = 0.0003125. The correction coefficient is approximately 0.38629436. Multiplying it by 0.005² gives approximately 0.00000965736. Subtracting gives 0.00030284264 of session variance contribution.
Two candles with identical relative geometry have the same average. At A=1, the square-root volatility is about 1.74024%. These are synthetic arithmetic checks, not an estimate for an actual security. The fixture uses different price levels with this geometry to show that changing the price unit does not change the estimate.
Open this figure at full size.
Download the exact worked input and expected values.
Can a valid candle make that contribution negative?
For correctly bracketed positive OHLC prices, H≥max(O,C) and L≤min(O,C). Consequently R≥|B|. Substitute that inequality:
The last coefficient is approximately 0.11370564, which is positive. This is more than a mathematical curiosity. It gives you a debugging invariant. A materially negative contribution indicates bad bracketing, an inconsistent adjustment basis, a formula error, or numerical trouble—not ordinary permissible candle geometry.
An earlier version of this package incorrectly suggested otherwise. The repaired implementation validates bracketing and does not hide a substantive negative estimate by taking its absolute value. A chart that silently clips bad calculations may look reassuring while concealing the defect you most need to find.
Hold the range fixed and change the body
If R stays 0.025 and |B| increases from 0.005 to 0.02, the correction gets larger and g falls. That does not mean a stronger trend made the market safer. It means this estimator assigns different weights to the information in that candle under its model.
At the extreme O=L and C=H, |B|=R and the contribution reaches the lower bound above. With O=C, the body correction vanishes and only half the squared range remains. Both cases are valid, and both can be reproduced without a random simulation.
| Candle change | Term affected | Expected result |
|---|---|---|
| Wider H/L, unchanged O/C | Range term | Higher contribution |
| Larger absolute O/C move, same H/L | Body correction | Lower contribution |
| Flip body direction with equal magnitude | Neither squared value changes | Same contribution |
| Rescale every price by the same factor | Neither ratio changes | Same contribution |
Open this figure at full size.
The gap the formula cannot see
Yesterday's close is absent. If the market opens 10% above it and then barely moves, the day's within-session range can be small. Garman–Klass does not separately account for the opening jump. Its classical assumptions also make material drift and discontinuities important caveats.
Do not patch this by feeding yesterday's close into today's open field. That destroys the definition of the candle. Use Yang–Zhang when you need an explicit overnight component. Use Rogers–Satchell as a different within-session treatment of drift.
Read the playground as an accounting statement
Step adds a real bar from the 64-observation synthetic path. The window's contributions and computed history move together. Inspect the wide-range observation, then shorten the window and predict when its influence will disappear. Compare this with a flat candle, where both terms are zero.
The failure scenario deliberately breaks an OHLC constraint and runs through the same validation as the reference implementation. A visible rejection is the correct lesson. It should not become a plausible number in the plot.
Open this figure at full size.
Open the standalone guided playground. The embedded playground and runnable code are available on this page. Download the 64-observation teaching input.
Use it responsibly in a pipeline
All OHLC fields need the same session and corporate-action treatment. An adjusted close paired with unadjusted extrema invalidates the ratios. A high created by an erroneous trade remains influential even when every bracket check passes; geometry validation is necessary, not sufficient.
The first output requires w complete bars. The direct reference costs O(nw), retains candle contributions, and returns variance and volatility separately. Annualization belongs after the mean contribution. Changing A scales variance linearly and volatility by its square root; it should not alter the relative contribution of any candle.
My preferred final check is simple: can you explain the result using its range term, its body correction, and the data clock? If you cannot, displaying more decimal places does not help. This is a historical model-based estimator, not a forecast or a universal replacement for the close-based baseline.
Reproduce and inspect the calculation
The Python and TypeScript tabs contain standalone implementations, not imports into an unseen runtime. Both expose calculate(input_data). Feed the worked JSON's input object into that entry point. For the longer experiment, use the teaching-path JSON directly.
import json
from pathlib import Path
from garman_klass_volatility import calculate
data = json.loads(Path("teaching-path.json").read_text())
result = calculate(data)
print(result["latest"])
import {calculate} from './garman_klass_volatility.ts';
const result = calculate(inputData); // inputData is the downloaded JSON object
console.log(result.latest);
Place the downloaded input beside your script and the standalone source on its import path. The Python reference uses the standard library; the TypeScript reference has no external runtime dependency. Shared tests include independent numeric anchors, valid boundaries, rejected inputs and cross-language output comparisons. They establish arithmetic, not forecasting performance.
Evidence and scope
This article uses authored synthetic calculations and primary technical references, reviewed 2026-09-10. Historical market examples are deferred until identity, adjustment basis, chronology and redistribution rights can be verified. No personal trading history or search-ranking superiority is asserted.
Same-session zero-drift/no-opening-jump model. A valid bracketed candle cannot give a negative contribution.
- TTR — volatility: close and range estimator conventions
- Garman and Klass — On the Estimation of Security Price Volatilities from Historical Data (1980)
Continue the investigation
- Close-to-Close Volatility: compare its assumptions and information boundary before comparing the numbers.
- Yang-Zhang Volatility: compare its assumptions and information boundary before comparing the numbers.
Rendered from the canonical Mermaid sources linked by this article.
Garman-Klass Volatility — calculation-flow
Garman-Klass Volatility — decision-boundary
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
Reviewed 2026-09-10. Primary technical documentation and papers; synthetic arithmetic is author-derived. This is a targeted source review, not a verified review of Google's top ten results and not a claim of ranking superiority.
- S1: TTR — volatility: close and range estimator conventions — accessed 2026-09-10. Rolling official documentation snapshot; exact package versions used for numerical comparisons are recorded in the repair numeric-evidence.json. Supports the definition and declared convention, not investment performance. Jurisdiction: not applicable to this mathematical reference.
- S2: Garman and Klass — On the Estimation of Security Price Volatilities from Historical Data (1980) — accessed 2026-09-10. Original research publication; not a current market observation. Supports the definition and declared convention, not investment performance. Jurisdiction: not applicable to this mathematical reference.
Scope of evidence
Same-session zero-drift/no-opening-jump model. A valid bracketed candle cannot give a negative contribution.
Historical case: deferred. No public provider dataset, historical performance claim, or personal trading anecdote is used. Synthetic examples demonstrate arithmetic, not market efficacy. Sources are not copied as article prose.
Accessed: 2026-09-10.
Supports: estimator definition and the explicitly declared variants.
Limitations: technical documentation does not verify a real market feed, author experience, forecast efficacy or search-result superiority. Original-paper access limitations are recorded in the repair report.
Full dependency-light reference implementations in both supported languages.
/** Standalone D10-F01-A03 reference. Generated from validated D10 v2 source. */
export class ContractError extends Error {}
type RecordValue=Record<string, any>;
type Matrix=number[][];
const sum=(x:number[]):number=>x.reduce((a,b)=>a+b,0);
function requireValue(ok: unknown, code: string, message: string): asserts ok {
if (!ok) throw new ContractError(`${code}: ${message}`);
}
function finite(x: unknown, name: string): number {
requireValue(typeof x === 'number' && Number.isFinite(x), 'NUMBER', `${name} must be a finite number`);
return x;
}
function integer(x: unknown, name: string, minimum = 0, maximum = 10000): number {
const v = finite(x, name);
requireValue(Number.isInteger(v) && v >= minimum && v <= maximum, 'INTEGER', `${name} must be an integer in [${minimum}, ${maximum}]`);
return v;
}
function param(p: RecordValue, key: string, fallback: number): number {
return finite(Object.hasOwn(p, key) ? p[key] : fallback, key);
}
function option(p: RecordValue, key: string, fallback: any): any {
return Object.hasOwn(p, key) ? p[key] : fallback;
}
function positive(p: RecordValue, key: string, fallback: number): number {
const v = param(p, key, fallback);
requireValue(v > 0, 'RANGE', `${key} must be positive`);
return v;
}
function timestamps(values: unknown, n: number, name: string, regular = false): number[] {
requireValue(Array.isArray(values) && values.length === n, 'TIME', `${name} must match observation count`);
const result: number[] = [];
for (const value of values) {
requireValue(typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value), 'TIME', 'use UTC ISO timestamps');
const instant = Date.parse(value);
const canonical = value.length === 20 ? value.replace('Z', '.000Z') : value;
requireValue(Number.isFinite(instant) && Number(value.slice(0, 4)) >= 1000 && new Date(instant).toISOString() === canonical, 'TIME', 'invalid calendar timestamp');
requireValue(!result.length || instant > result[result.length - 1], 'TIME', 'timestamps must strictly increase');
result.push(instant);
}
if (regular && n > 2) requireValue(result.slice(2).every((v, i) => v - result[i + 1] === result[1] - result[0]), 'ALIGNMENT', 'returns need a regular grid');
return result;
}
function bars(data: RecordValue): any[][] {
const values = data.bars;
requireValue(Array.isArray(values) && values.length >= 2 && values.every(b => b && typeof b === 'object' && !Array.isArray(b)), 'SHAPE', 'bars needs at least two OHLC objects');
timestamps(values.map(b => b.timestamp), values.length, 'bars.timestamp');
return values.map(b => {
const [o, h, l, c] = ['open', 'high', 'low', 'close'].map(k => finite(b[k], k));
requireValue(Math.min(o, h, l, c) > 0 && h >= Math.max(o, c) && l <= Math.min(o, c), 'OHLC', 'positive bracketed OHLC required');
return [b.timestamp, o, h, l, c];
});
}
function variance(x: number[]): number {
const mean = sum(x) / x.length;
return sum(x.map(v => (v - mean) ** 2)) / (x.length - 1);
}
function seriesResult(series: (RecordValue | null)[], diagnostics: RecordValue): RecordValue {
const at = series.findIndex(v => v !== null);
return {series, latest: series.at(-1) ?? null, ready: series.length > 0 && series.at(-1) !== null,
ready_at: at < 0 ? null : at, diagnostics};
}
function historical(data: RecordValue, p: RecordValue, kind: string): RecordValue {
const values = bars(data), w = integer(option(p, 'window', 12), 'window', 2), scale = positive(p, 'annualization_factor', 252);
const contributions = values.map(([, o, h, l, c], i) => {
const rs = Math.log(h / c) * Math.log(h / o) + Math.log(l / c) * Math.log(l / o);
const choices: RecordValue = {
close_to_close: i === 0 ? null : Math.log(c / values[i - 1][4]),
parkinson: Math.log(h / l) ** 2 / (4 * Math.log(2)),
garman_klass: .5 * Math.log(h / l) ** 2 - (2 * Math.log(2) - 1) * Math.log(c / o) ** 2,
rogers_satchell: rs,
yang_zhang: [i === 0 ? null : Math.log(o / values[i - 1][4]), Math.log(c / o), rs],
};
return choices[kind];
});
const series = values.map((bar, i) => {
const block = contributions.slice(Math.max(0, i - w + 1), i + 1);
if (block.length < w || block.some(v => v === null) || (kind === 'yang_zhang' && block[0][0] === null)) return null;
let estimate: number, components: RecordValue = {};
if (kind === 'yang_zhang') {
const overnight = variance(block.map(v => v[0])), intraday = variance(block.map(v => v[1]));
const rs = sum(block.map(v => v[2])) / w, k = .34 / (1.34 + (w + 1) / (w - 1));
estimate = scale * (overnight + k * intraday + (1 - k) * rs);
components = {overnight: scale * overnight, open_close: scale * k * intraday, range: scale * (1 - k) * rs, k};
} else estimate = scale * (kind === 'close_to_close' ? variance(block) : sum(block) / w);
requireValue(estimate >= 0, 'NUMERIC', 'negative variance: inspect inputs and precision');
return {timestamp: bar[0], variance: estimate, volatility: Math.sqrt(estimate), contributions: block, components, window_start: i - w + 1, window_end: i};
});
return seriesResult(series, {input_count: values.length, annualization_factor: scale, causal: true});
}
export function calculate(data: RecordValue): RecordValue {
requireValue(data && typeof data === 'object' && !Array.isArray(data),'SHAPE','input must be an object');
const p=Object.hasOwn(data,'parameters')?data.parameters:{};
requireValue(p && typeof p === 'object' && !Array.isArray(p),'SHAPE','parameters must be an object');
const result=historical(data,p,"garman_klass");
function check(v:any):void {
if(typeof v==='number')requireValue(Number.isFinite(v),'NUMERIC','nonfinite computed output');
else if(Array.isArray(v))v.forEach(check);
else if(v && typeof v==='object')Object.values(v).forEach(check);
}
check(result);
return {topic_id:"D10-F01-A03",title:"Garman-Klass Volatility",parameters:p,...result};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
