D06-F01-A01 / Complete engineering topic

Candle Anatomy: Turn OHLC Into Testable Geometry

A production-minded guide to Candle Anatomy.

D06 · PRICE ACTION AND CANDLESTICKS
D06-F01-A01Canonical / Tested / Open
D06 / D06-F01

A candlestick looks simple: a rectangle with a line above and below it. But reliable software needs more than a drawing. It needs exact definitions, input validation, a zero-range policy, tests, and a contract that produces the same result in every language.

By the end of this tutorial, you will be able to take one open-high-low-close record, calculate its body, upper and lower shadows, range, direction, and normalized proportions, and verify the result in Python or TypeScript.

This is geometry, not a trading signal. A correctly measured candle does not by itself predict a future return.

Why a precise candle matters

CME Group and Nasdaq both describe a candlestick as a representation of four prices for one period: open, high, low, and close. The body spans open to close, while the thin lines extend to the high and low (CME Group, Nasdaq).

That visual definition becomes the foundation for later rules such as doji, marubozu, hammer, and engulfing detection. If the foundation is ambiguous, every later classifier inherits the ambiguity.

There are also data-engineering traps hiding behind the picture:

  • a live candle can still change;
  • the provider's day boundary may use UTC, an exchange session, or another time zone;
  • an official futures settlement can differ from a last-trade close;
  • raw and adjusted prices must not be mixed;
  • a flat candle has no denominator for normalized proportions.

The correct response is a small, explicit contract.

Intuition: one range partitioned into three pieces

For one candle, imagine walking downward from the high to the low.

  1. First comes the upper shadow.
  2. Then comes the real body.
  3. Finally comes the lower shadow.

Those three non-overlapping distances must add to the full range.

Annotated bullish candle showing the range partition

The worked candle has:

  • open O=100O=100;
  • high H=108H=108;
  • low L=97L=97;
  • close C=105C=105.

Its upper shadow is 3, body is 5, lower shadow is 3, and range is 11. The most useful independent check is therefore:

3+5+3=113+5+3=11

Define the input before doing arithmetic

At minimum, the calculator needs finite numeric values for open, high, low, and close. Booleans, missing values, NaN, and infinity are invalid.

The geometry constraints are:

Hmax(O,C)H \ge \max(O,C) Lmin(O,C)L \le \min(O,C)

The full production record should also retain:

  • an RFC 3339 timestamp with an explicit offset, preferably UTC;
  • a source-qualified instrument identifier;
  • the interval or event-bucket definition;
  • the venue session and time-zone convention;
  • whether the bar is closed;
  • the price adjustment basis;
  • the vendor and license.

The reference calculator requires only OHLC, but it validates optional metadata when supplied and normalizes omitted metadata to null in both languages. It does not infer that a bar is closed from its timestamp or attempt to validate a venue calendar.

The IETF's RFC 3339 provides the timestamp interchange profile and recommends UTC for interoperability. A real exchange feed can also expose an interval, start and close times, and a closed-kline flag; Binance's official kline stream is one concrete example (RFC 3339, Binance Spot API).

Do not silently treat every close field as an exchange settlement. CME's rulebook defines settlement as an official price determined under exchange rules and used in clearing, which is a different provenance from “last eligible trade in this bar” (CME rulebook definitions).

The formulas

First find the body's price boundaries:

Bhi=max(O,C)B_{hi}=\max(O,C) Blo=min(O,C)B_{lo}=\min(O,C)

Then calculate the four distances:

B=COB=|C-O| U=HBhiU=H-B_{hi} D=BloLD=B_{lo}-L R=HLR=H-L

where BB is body, UU is upper shadow, DD is lower shadow, and RR is range.

For every valid candle:

U+B+D=RU+B+D=R

When the range is positive, normalize each component:

u=UR,b=BR,d=DRu=\frac{U}{R},\qquad b=\frac{B}{R},\qquad d=\frac{D}{R}

and check:

u+b+d=1u+b+d=1

Open and close positions within the range are:

pO=OLR,pC=CLRp_O=\frac{O-L}{R},\qquad p_C=\frac{C-L}{R}

All five normalized values are undefined when R=0R=0. This package returns null rather than silently returning zero.

Direction is a label, not a body rewrite

The raw body is always CO|C-O|. An optional tolerance ϵ\epsilon changes only the direction label:

direction={bullishCO>ϵbearishOC>ϵneutralCOϵdirection= \begin{cases} bullish & C-O>\epsilon\\ bearish & O-C>\epsilon\\ neutral & |C-O|\le\epsilon \end{cases}

A fixed epsilon is useful for absorbing a known tick-scale difference, but it is not a universal definition of a doji. Scale-aware classification belongs in the next topic.

From input to output

The whole algorithm has one meaningful branch: whether range normalization has a positive denominator.

Rendering system map…

The interactive candle build lets you move forward and backward through that flow one exact stage at a time. Compare the canonical, bearish, tolerance-edge provisional, and flat synthetic cases to see direction, closure state, and the null branch change without rewriting the geometry formulas.

Worked numerical example

With O=100O=100, H=108H=108, L=97L=97, and C=105C=105:

OutputCalculationValue
Body highmax(100,105)\max(100,105)105
Body lowmin(100,105)\min(100,105)100
Body$105-100
Upper shadow108105108-1053
Lower shadow10097100-973
Range10897108-9711
Body proportion5/115/110.454545…
Upper proportion3/113/110.272727…
Lower proportion3/113/110.272727…
Open position3/113/110.272727…
Close position8/118/110.727273…
Direction105>100105>100bullish

The machine-readable input and output are in worked-example.json.

Why not attach a famous market date?

For candle anatomy, a named historical event adds narrative but not mathematical evidence. Selecting a well-known reversal would also tempt readers to infer forecasting value from one chosen bar. This tutorial therefore uses a synthetic, freely redistributable example whose arithmetic and price basis are explicit. A real provider bar can be substituted only with its symbol, venue, interval, session, adjustment basis, retrieval time, and revision status recorded; the result is still a description of geometry, not a causal event explanation or predictive test.

Python implementation

The Python API accepts a mapping and returns a typed dictionary:

Python
from candle_anatomy import analyze_candle

result = analyze_candle({
    "timestamp": "2026-01-05T10:00:00Z",
    "instrument_id": "SYNTH:ABC",
    "interval": "5m",
    "open": 100,
    "high": 108,
    "low": 97,
    "close": 105,
    "is_closed": True,
})

assert result["body"] == 5
assert result["upper_shadow"] == 3
assert result["lower_shadow"] == 3
assert result["range"] == 11

See the complete Python source and runnable example.

TypeScript implementation

The TypeScript API exposes the same field names so one JSON fixture can verify both languages:

TypeScript
import { analyzeCandle } from "./candleAnatomy.ts";

const result = analyzeCandle({
  timestamp: "2026-01-05T10:00:00Z",
  instrument_id: "SYNTH:ABC",
  interval: "5m",
  open: 100,
  high: 108,
  low: 97,
  close: 105,
  is_closed: true,
});

console.assert(result.body === 5);
console.assert(result.range === 11);

See the complete TypeScript source and runnable example.

Both implementations run in constant time per candle. They intentionally use ordinary binary floating-point for cross-language parity. Systems requiring exact tick arithmetic can represent prices as integer ticks first.

Tests that matter

A canonical example alone is not enough. The shared fixture suite includes:

  • bullish and bearish candles;
  • an exact-neutral candle with positive range;
  • a flat candle;
  • valid negative prices;
  • a tiny body labeled neutral under epsilon;
  • invalid highs and lows;
  • missing, boolean, string, and non-finite inputs;
  • a negative direction tolerance;
  • malformed optional metadata and a non-object input;
  • metadata preservation and ordered batch output.

Every positive-range result must satisfy both:

U+B+D=RU+B+D=R u+b+d=1u+b+d=1

The Python test is test_candle_anatomy.py, and the TypeScript test is candleAnatomy.test.ts.

Edge cases in one view

Bullish, bearish, neutral, and flat candle comparison

Notice three distinctions:

  1. negative prices can still produce valid distances;
  2. a neutral body can have a positive range;
  3. a flat candle is valid even though its normalized ratios are null.

Incomplete candles and corrections

A live candle does not create look-ahead bias if you calculate only from values observed so far, but its geometry is provisional. High, low, and close can change until the interval ends.

Rendering system map…

Store the closure and revision state. Do not train on a final candle and pretend the final high and low were already known halfway through the interval.

What candle anatomy cannot tell you

A candle loses the intrabar path. One market may trade smoothly from open to close; another may oscillate violently and finish with the same OHLC.

Valid geometry also does not prove clean data. Bad ticks, stale feeds, corporate actions, roll adjustments, and wrong session boundaries can all produce a plausible-looking candle.

Most importantly, anatomy is not evidence of predictive power. Correct implementation answers “did we measure this shape correctly?” It does not answer “will this shape make money after costs and out-of-sample testing?”

Summary and next topic

You can now:

  • validate one OHLC record;
  • derive body, shadows, range, direction, and positions;
  • normalize only when the range is positive;
  • test the range partition and cross-language parity;
  • distinguish provisional candle state from final data.

The natural next step is D06-F01-A02 — Scale-Aware Body Classification, where body size is evaluated relative to an instrument-aware scale instead of a fixed absolute threshold.

Candle lifecycle and revision state

Purpose: distinguish causal calculation from finalized data.

Rendering system map…

Interpretation: candle anatomy can be recomputed at every update without future data, but a provisional result is not final. A provider correction can also revise a previously closed candle.

Takeaway: retain is_closed and source revision metadata instead of treating every observed candle as immutable.

Candle anatomy calculation flow

Purpose: show the validation boundary, deterministic geometry calculations, and the zero-range ratio branch.

Rendering system map…

Interpretation: validation is a hard boundary. A flat candle is valid, but its normalized ratios are undefined.

Takeaway: no special pattern rule or future observation is required.

References5 primary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

The canonical topic cites these sources beside the claims they support. Formulas for body, shadows, range, and proportions are transparent derivations from the sourced OHLC geometry; tolerance and null policies are documented implementation choices.

R01 — Chart Types: Candlestick, Line, Bar

  • Organization or authors: CME Group
  • Source type: Official exchange education
  • Publication or effective date: Undated, live page
  • Version: Web version rechecked 2026-07-23
  • URL or DOI: https://www.cmegroup.com/education/courses/technical-analysis/chart-types-candlestick-line-bar
  • Accessed: 2026-07-23
  • Jurisdiction: Global educational material from a U.S. derivatives exchange
  • Supports: Candles contain open, high, low, and close; the body spans open-close; wicks reach high-low; intervals can be time- or trade-based.
  • Limitations: Educational explanation, not a market-data specification; interpretive statements about buying or selling pressure are not used as algorithmic facts here.

R02 — Candlestick chart definition

  • Organization or authors: Nasdaq
  • Source type: Official exchange glossary
  • Publication or effective date: Undated, live page
  • Version: Web version rechecked 2026-07-23
  • URL or DOI: https://www.nasdaq.com/glossary/c/candlestick-chart
  • Accessed: 2026-07-23
  • Jurisdiction: U.S. exchange glossary
  • Supports: Independent confirmation of OHLC, real-body, and shadow geometry; illustrates that display conventions can differ.
  • Limitations: Short glossary entry; does not define data cleaning, tolerance, or bar-construction methodology.

R03 — Kline/Candlestick Streams

  • Organization or authors: Binance
  • Source type: Official exchange API documentation
  • Publication or effective date: Continuously maintained
  • Version: Spot WebSocket market-stream documentation rechecked 2026-07-23
  • URL or DOI: https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#klinecandlestick-streams-for-utc
  • Accessed: 2026-07-23
  • Jurisdiction: Global crypto-asset venue; availability varies by jurisdiction
  • Supports: Concrete OHLC payload fields, interval identifiers, start and close times, live update behavior, explicit closed-bar flag, and time-zone-dependent interval boundaries.
  • Limitations: Vendor-specific payload and crypto-market conventions; used as an implementation example, not a universal schema.

R04 — RFC 3339: Date and Time on the Internet: Timestamps

  • Organization or authors: G. Klyne and C. Newman, Internet Engineering Task Force
  • Source type: Internet standards-track RFC
  • Publication or effective date: July 2002
  • Version: RFC 3339 with errata status shown by the IETF
  • URL or DOI: https://datatracker.ietf.org/doc/html/rfc3339
  • Accessed: 2026-07-23
  • Jurisdiction: Internet standard
  • Supports: Timestamp interchange syntax, explicit UTC/offset semantics, and preference for UTC interoperability.
  • Limitations: Does not define financial sessions or bar boundaries.

R05 — NYMEX and COMEX Definitions: Settlement Price

  • Organization or authors: CME Group
  • Source type: Official exchange rulebook definitions
  • Publication or effective date: Live rulebook PDF
  • Version: Live rulebook PDF rechecked 2026-07-23
  • URL or DOI: https://www.cmegroup.com/content/dam/cmegroup/rulebook/NYMEX/1/NYMEX-COMEX_Definitions.pdf
  • Accessed: 2026-07-23
  • Jurisdiction: U.S. futures and options markets
  • Supports: A settlement price is an official daily closing price determined under exchange rules and used for clearing, motivating explicit separation of last-trade close and settlement.
  • Limitations: Product and exchange specific; it does not imply all OHLC feeds use settlement.

Evidence classification

Package statementClassificationEvidence or rationale
A candle displays OHLC using a body and shadowsSourced factR01 and R02
A live feed can expose interval, time zone, and closed-bar stateSourced fact and vendor exampleR03
UTC timestamps improve interchange claritySourced standards guidanceR04
Settlement may be a governed value rather than the last tradeSourced factR05
Component formulas partition the rangeMathematical derivationFollows directly from min, max, and valid OHLC ordering
Zero-range ratios return nullImplementation choiceAvoids assigning meaning to division by zero
Epsilon changes direction onlyImplementation choicePreserves exact geometry
Candle anatomy has no predictive guaranteeScope limitationNo empirical predictive claim is made or tested
candleAnatomy.ts
/** Dependency-free reference implementation for D06-F01-A01 Candle Anatomy. */

export type Direction = "bullish" | "bearish" | "neutral";

export interface CandleInput {
  open: number;
  high: number;
  low: number;
  close: number;
  timestamp?: string;
  instrument_id?: string;
  interval?: string;
  is_closed?: boolean;
  volume?: number;
  price_basis?: string;
  session?: string;
}

export interface CandleAnatomy {
  open: number;
  high: number;
  low: number;
  close: number;
  timestamp: string | null;
  instrument_id: string | null;
  interval: string | null;
  is_closed: boolean | null;
  volume: number | null;
  price_basis: string | null;
  session: string | null;
  body_high: number;
  body_low: number;
  body: number;
  upper_shadow: number;
  lower_shadow: number;
  range: number;
  direction: Direction;
  body_to_range: number | null;
  upper_shadow_to_range: number | null;
  lower_shadow_to_range: number | null;
  open_position: number | null;
  close_position: number | null;
}

export class CandleValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "CandleValidationError";
  }
}

function finiteNumber(value: unknown, field: string): number {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new CandleValidationError(`${field} must be a finite real number.`);
  }
  return value;
}

function optionalString(
  candle: Record<string, unknown>,
  field: string,
): string | null {
  const value = candle[field];
  if (value === undefined || value === null) return null;
  if (typeof value !== "string" || value.trim().length === 0) {
    throw new CandleValidationError(
      `${field} must be a non-empty string when provided.`,
    );
  }
  return value;
}

export function analyzeCandle(
  candle: CandleInput,
  directionEpsilon = 0,
): CandleAnatomy {
  const epsilon = finiteNumber(directionEpsilon, "direction_epsilon");
  if (epsilon < 0) {
    throw new CandleValidationError(
      "direction_epsilon must be non-negative.",
    );
  }

  if (candle === null || typeof candle !== "object" || Array.isArray(candle)) {
    throw new CandleValidationError("candle must be an object.");
  }

  const record = candle as unknown as Record<string, unknown>;

  const open = finiteNumber(candle.open, "open");
  const high = finiteNumber(candle.high, "high");
  const low = finiteNumber(candle.low, "low");
  const close = finiteNumber(candle.close, "close");
  const timestamp = optionalString(record, "timestamp");
  const instrumentId = optionalString(record, "instrument_id");
  const interval = optionalString(record, "interval");
  const priceBasis = optionalString(record, "price_basis");
  const session = optionalString(record, "session");
  if (
    candle.is_closed !== undefined &&
    candle.is_closed !== null &&
    typeof candle.is_closed !== "boolean"
  ) {
    throw new CandleValidationError(
      "is_closed must be a boolean when provided.",
    );
  }
  const volume =
    candle.volume === undefined || candle.volume === null
      ? null
      : finiteNumber(candle.volume, "volume");

  const bodyHigh = Math.max(open, close);
  const bodyLow = Math.min(open, close);
  if (high < bodyHigh) {
    throw new CandleValidationError(
      "high must be at least max(open, close).",
    );
  }
  if (low > bodyLow) {
    throw new CandleValidationError(
      "low must be at most min(open, close).",
    );
  }

  const body = Math.abs(close - open);
  const upperShadow = high - bodyHigh;
  const lowerShadow = bodyLow - low;
  const range = high - low;
  const difference = close - open;

  let direction: Direction;
  if (difference > epsilon) {
    direction = "bullish";
  } else if (-difference > epsilon) {
    direction = "bearish";
  } else {
    direction = "neutral";
  }

  const hasRange = range !== 0;
  return {
    open,
    high,
    low,
    close,
    timestamp,
    instrument_id: instrumentId,
    interval,
    is_closed: candle.is_closed ?? null,
    volume,
    price_basis: priceBasis,
    session,
    body_high: bodyHigh,
    body_low: bodyLow,
    body,
    upper_shadow: upperShadow,
    lower_shadow: lowerShadow,
    range,
    direction,
    body_to_range: hasRange ? body / range : null,
    upper_shadow_to_range: hasRange ? upperShadow / range : null,
    lower_shadow_to_range: hasRange ? lowerShadow / range : null,
    open_position: hasRange ? (open - low) / range : null,
    close_position: hasRange ? (close - low) / range : null,
  };
}

export function analyzeCandles(
  candles: readonly CandleInput[],
  directionEpsilon = 0,
): CandleAnatomy[] {
  return candles.map((candle) => analyzeCandle(candle, directionEpsilon));
}
Full-height labcandle buildOpen full screen