D06-F01-A02 / Complete engineering topic

Scale-Aware Body Classification

A production-minded guide to Scale-Aware Body Classification.

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

The practical problem

Suppose two candles each have a real body of 2 price units. On an instrument whose recent bodies cluster around 0.5, that movement is exceptional. On another whose bodies cluster around 5, it is small. Calling both bodies “2 units long” is numerically correct but analytically incomplete.

Scale-aware body classification converts a raw body length into a dimensionless score relative to recent, comparable candles, then gives that score an explicit label: tiny, small, normal, or long. Those labels can become stable inputs to later candlestick-pattern rules.

The classification is descriptive. A long body does not imply that the next return will be positive, negative, or profitable to trade.

Start with the right measurement

Candle Anatomy defines the real body:

Bt=CtOtB_t=|C_t-O_t|

It also defines the full candle range Rt=HtLtR_t=H_t-L_t. The ratio Bt/RtB_t/R_t answers how much of the current candle is body. It does not answer whether that body is historically large. A quiet candle can be almost all body and still be small compared with recent bodies.

We therefore need a historical denominator. This package uses the median of the most recent prior closed bodies:

St=median(BtW,,Bt1)S_t=median(B_{t-W},\ldots,B_{t-1})

Only eligible bodies actually available before time tt are used. The current body is excluded from its own denominator, and an open candle never enters the history.

Why the median?

A maintained technical-analysis library, TA-Lib, uses configurable prior-candle arithmetic averages and factors in its candle settings. That is good evidence that contextual comparisons are an established implementation approach—but it does not make one average or threshold universal.

This package chooses a median because it is stable under occasional extreme body lengths. NIST’s statistical guidance notes that extreme tail observations distort the mean while leaving the rank-based median unaffected. That property is useful when one shock candle should not immediately redefine “normal.”

Robustness has a cost: after a genuine volatility regime shift, a rolling median can adapt more slowly. The choice is a design trade-off, not a theorem about markets.

Median body scale and class mapping

The canonical algorithm

Let the latest WW prior closed bodies form the reference window Wt\mathcal W_t. The defaults use W=20W=20 and require all 20 observations. Before enough history exists, the algorithm returns warmup.

After computing raw median scale StS_t, an optional tick-size floor τ\tau can prevent a structurally flat history from creating a zero denominator:

Et=max(St,τ)E_t=\max(S_t,\tau)

If no tick is supplied and the median is zero, the result is zero_scale. Otherwise calculate:

qt=BtEtq_t=\frac{B_t}{E_t}

The default class boundaries are:

ScoreClass
0qt0.250\le q_t\le0.25tiny
0.25<qt0.750.25<q_t\le0.75small
0.75<qt<1.500.75<q_t<1.50normal
qt1.50q_t\ge1.50long

The inequalities matter. A score of exactly 0.25 is tiny; exactly 0.75 is small; exactly 1.50 is long. These defaults are teaching choices and must be configured or calibrated for a real application.

The complete branch structure is captured in the classification flow. The interactive classifier lets you step through each stage and change the boundaries.

Worked example

Take five prior closed bodies:

Plain text
[1, 2, 2, 3, 12]

The current body is 4, current range is 6, and tick size is 0.01. The ordered window is already sorted, so its middle value is 2:

St=2,Et=max(2,0.01)=2S_t=2,\qquad E_t=\max(2,0.01)=2

The normalized body score is:

qt=4/2=2q_t=4/2=2

Since 21.502\ge1.50, the body is long. Its separate within-candle diagnostic is 4/6=0.6674/6=0.667.

The same history has arithmetic mean 4. A mean-based denominator would produce score 4/4=14/4=1, or normal under the same boundaries.

Mean and median denominators classify the same synthetic current body differently

The diagram makes the trade-off concrete. It does not claim the median is universally superior.

The same body can receive a different descriptive label

Keep a synthetic current body fixed at 2 units. Against a 20-body quiet baseline with median 0.5, it scores 2/0.5=42/0.5=4, which is long. Against a 20-body active baseline with median 5, it scores 2/5=0.42/5=0.4, which is small. The body is unchanged; the recent scale is different. Both cases are available in the interactive lab.

This comparison is synthetic by design and has no future-return label. A historical version would require licensed point-in-time open and close values, a declared session and adjustment basis, and a source note that separates provider observations from author-derived bodies, medians, scores, and classes.

A point-in-time data contract

Correct formulas can still produce misleading features when the data contract is vague. Each observation carries timestamp, instrument, interval, body, range, closed state, price basis, and session. A valid stream must:

  • contain one instrument, interval, session convention, and price basis;
  • use valid UTC timestamps ending in Z, in strictly increasing order without duplicates;
  • keep instrument, interval, session, and price basis constant within one batch;
  • use finite, non-negative bodies and ranges with body <= range;
  • supply a point-in-time snapshot when simulating history; ordering checks cannot reveal when a vendor correction became available;
  • classify open bodies provisionally without storing them in closed history.

This last rule is easy to miss. If an open body spikes to 100 and later closes at 1, adding the provisional 100 to the median window contaminates subsequent results. The lifecycle sequence shows that classification precedes history insertion.

Implementation

The Python reference uses frozen configuration dataclasses and statistics.median. The TypeScript reference uses a sorted copy and the same odd/even median convention. Both expose:

  • a single-observation function that accepts prior closed-body values;
  • a batch helper that manages causal closed history;
  • explicit ready, warmup, and zero_scale statuses;
  • the continuous score and scale diagnostics alongside the class.

The simple implementation costs O(WlogW)O(W\log W) time for one classification because it sorts a window copy. That is easy to audit. A production system can replace it with a rolling-median structure if it preserves the exact contract.

Both integration examples pass raw OHLC candles through A01 before calling A02: Python and TypeScript.

What the tests establish

One shared JSON fixture set drives both language suites. It covers all default boundaries, odd and even medians, warm-up, zero scale, tick-floor recovery, lookback truncation, provisional exclusion, invalid inputs, non-finite numbers, invalid UTC timestamps, duplicate or reversed time, and mixed stream identity. The canonical six-candle stream spends five observations in warm-up and classifies the sixth as long.

Passing these tests establishes rule correctness and cross-language parity. It does not show that the labels forecast returns. That requires a separate research design with point-in-time data, transaction costs, out-of-sample evaluation, and controls against parameter overfitting.

Failure modes worth designing for

A rolling median can lag regime changes. Missing candles alter the last-WW membership. Corporate actions and futures rolls can make older raw bodies incomparable. A tick floor can dominate an ultra-quiet market. Open candles can cross several class boundaries before close. Finally, four labels discard information, which is why the continuous score should always be retained.

Summary

A body length becomes meaningful only after its denominator is declared. This reference method uses prior closed bodies, a rolling median, an optional tick floor, and explicit thresholds. It keeps status and diagnostics visible, separates current-candle geometry from historical scale, and avoids predictive claims. You can now reproduce the score, test every boundary, and explain why another implementation may disagree.

Causal window lifecycle

Rendering system map…

The append happens only after classifying a closed candle, which prevents self-inclusion and keeps provisional updates out of future history.

Classification flow

Rendering system map…

The reference window is causal: the current observation is excluded.

References6 primary sources and evidence notes

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

The package distinguishes documented external conventions from its own configurable reference method. No source establishes universal “small” or “long” thresholds.

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 accessed 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: A candle body spans open and close; longer and shorter bodies represent different amounts of movement during the interval.
  • Limitations: Qualitative education; it does not define numerical classification thresholds or predictive validity.

R02 — TA-Lib default candle settings

  • Organization or authors: TA-Lib project
  • Source type: Official maintained reference implementation
  • Publication or effective date: Main branch inspected 2026-07-23
  • Version: Repository main branch at access time
  • URL or DOI: https://github.com/TA-Lib/ta-lib/blob/main/src/ta_common/ta_global.c
  • Accessed: 2026-07-23
  • Jurisdiction: Open-source global software project
  • Supports: BodyLong, BodyVeryLong, BodyShort, and BodyDoji use configurable range types, lookbacks, and factors; defaults compare body or high-low measurements with prior-candle averages.
  • Limitations: Vendor/library convention, not a universal market standard. This package does not claim TA-Lib parity and uses a median rather than its arithmetic mean.

R03 — TA-Lib candlestick utility macros

  • Organization or authors: TA-Lib project
  • Source type: Official maintained reference implementation
  • Publication or effective date: Main branch inspected 2026-07-23
  • Version: Repository main branch at access time
  • URL or DOI: https://github.com/TA-Lib/ta-lib/blob/main/src/ta_func/ta_utility.h
  • Accessed: 2026-07-23
  • Jurisdiction: Open-source global software project
  • Supports: Real-body definition, high-low range, candle range types, factor multiplication, and prior-window averaging mechanics.
  • Limitations: C macros and library internals; they document TA-Lib behavior rather than this package's canonical algorithm.

R04 — TA-Lib C/C++ Core API

  • Organization or authors: TA-Lib project
  • Source type: Official API documentation
  • Publication or effective date: Continuously maintained
  • Version: Page updated 2026-07-20
  • URL or DOI: https://ta-lib.org/api/
  • Accessed: 2026-07-23
  • Jurisdiction: Open-source global software project
  • Supports: Candle settings are configurable; lookback periods delay the first valid output; live and batch processing require explicit initialization and output alignment.
  • Limitations: General API documentation, not a mathematical candle-body specification.

R05 — Measures of Location

  • Organization or authors: National Institute of Standards and Technology
  • Source type: U.S. government Engineering Statistics Handbook
  • Publication or effective date: Continuously maintained handbook
  • Version: Web version accessed 2026-07-23
  • URL or DOI: https://itl.nist.gov/div898/handbook/eda/section3/eda351.htm
  • Accessed: 2026-07-23
  • Jurisdiction: United States; general statistical guidance
  • Supports: Median definition for odd and even sample sizes and the observation that extreme tail values distort the mean but not the rank-based median.
  • Limitations: General statistics source, not financial-market guidance.

R06 — Candle Anatomy package

  • Organization or authors: The Fintech Builder
  • Source type: Prerequisite local canonical topic
  • Publication or effective date: 2026-07-23
  • Version: D06-F01-A01 status video-ready
  • URL or DOI: ../D06-F01-A01-candle-anatomy/README.md
  • Accessed: 2026-07-23
  • Jurisdiction: Educational, not jurisdiction-specific
  • Supports: Validated body and range fields, metadata contract, closed-bar semantics, and the current-range diagnostic.
  • Limitations: Measures one candle but does not classify body size relative to history.

Evidence classification

Package statementClassificationEvidence or rationale
Candle bodies represent open-close distanceSourced factR01, R03, and R06
Prior-candle averages and configurable factors are used in a maintained pattern librarySourced implementation factR02–R04
A median is less distorted by extreme tail observations than a meanSourced statistical factR05
Use the median of prior closed bodies as the canonical scaleImplementation choiceRobust, causal alternative selected by this package
Use a 20-candle default windowPedagogical implementation choiceConfigurable; not presented as a market standard
Use thresholds 0.25, 0.75, and 1.50Pedagogical implementation choiceProduces four clear classes; must be calibrated for a real application
Classification predicts return or profitabilityNot claimedRequires separate empirical research, costs, and out-of-sample validation
bodyClassifier.ts
/** Reference implementation for D06-F01-A02 Scale-Aware Body Classification. */

export type BodyClass = "tiny" | "small" | "normal" | "long";
export type ClassificationStatus = "ready" | "warmup" | "zero_scale";

export interface BodyObservation {
  timestamp: string;
  instrument_id: string;
  interval: string;
  body: number;
  range: number;
  is_closed: boolean;
  price_basis: string;
  session: string;
}

export interface Thresholds {
  tiny_max: number;
  small_max: number;
  long_min: number;
}

export interface ClassifierConfig {
  window: number;
  min_periods: number | null;
  tick_size: number | null;
  thresholds: Thresholds;
}

export interface ClassificationResult extends BodyObservation {
  status: ClassificationStatus;
  body_class: BodyClass | null;
  body_score: number | null;
  raw_scale: number | null;
  effective_scale: number | null;
  scale_floor_applied: boolean;
  history_count: number;
  window: number;
  min_periods: number;
  tiny_max: number;
  small_max: number;
  long_min: number;
  body_to_current_range: number | null;
  is_provisional: boolean;
  scale_method: "rolling_median_prior_body";
}

export type ClassifierConfigInput = Partial<
  Omit<ClassifierConfig, "thresholds">
> & {
  thresholds?: Partial<Thresholds>;
};

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

export const DEFAULT_CONFIG: Readonly<ClassifierConfig> = Object.freeze({
  window: 20,
  min_periods: null,
  tick_size: null,
  thresholds: Object.freeze({
    tiny_max: 0.25,
    small_max: 0.75,
    long_min: 1.5,
  }),
});

const UTC_TIMESTAMP_PATTERN =
  /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/;

const SERIES_IDENTITY_FIELDS = [
  "instrument_id",
  "interval",
  "price_basis",
  "session",
] as const;

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

function parseUtcTimestamp(value: string): number {
  const match = UTC_TIMESTAMP_PATTERN.exec(value);
  if (match === null) {
    throw new BodyClassificationError(
      "timestamp must be an RFC 3339 UTC string ending in Z.",
    );
  }
  const parsed = Date.parse(value);
  const instant = new Date(parsed);
  const expected = match.slice(1, 7).map(Number);
  const actual = [
    instant.getUTCFullYear(),
    instant.getUTCMonth() + 1,
    instant.getUTCDate(),
    instant.getUTCHours(),
    instant.getUTCMinutes(),
    instant.getUTCSeconds(),
  ];
  if (
    !Number.isFinite(parsed) ||
    actual.some((component, index) => component !== expected[index])
  ) {
    throw new BodyClassificationError(
      "timestamp must identify a valid UTC instant.",
    );
  }
  return parsed;
}

function resolveConfig(input: ClassifierConfigInput = {}): {
  config: ClassifierConfig;
  minPeriods: number;
} {
  const thresholds = {
    ...DEFAULT_CONFIG.thresholds,
    ...(input.thresholds ?? {}),
  };
  const config: ClassifierConfig = {
    window: input.window ?? DEFAULT_CONFIG.window,
    min_periods: input.min_periods ?? DEFAULT_CONFIG.min_periods,
    tick_size: input.tick_size ?? DEFAULT_CONFIG.tick_size,
    thresholds,
  };

  if (!Number.isInteger(config.window) || config.window < 1) {
    throw new BodyClassificationError("window must be an integer of at least 1.");
  }
  const minPeriods = config.min_periods ?? config.window;
  if (
    !Number.isInteger(minPeriods) ||
    minPeriods < 1 ||
    minPeriods > config.window
  ) {
    throw new BodyClassificationError(
      "min_periods must be an integer between 1 and window.",
    );
  }
  if (config.tick_size !== null) {
    finiteNumber(config.tick_size, "tick_size");
    if (config.tick_size <= 0) {
      throw new BodyClassificationError(
        "tick_size must be positive when supplied.",
      );
    }
  }

  const tinyMax = finiteNumber(thresholds.tiny_max, "tiny_max");
  const smallMax = finiteNumber(thresholds.small_max, "small_max");
  const longMin = finiteNumber(thresholds.long_min, "long_min");
  if (tinyMax < 0 || !(tinyMax < smallMax && smallMax < longMin)) {
    throw new BodyClassificationError(
      "thresholds must satisfy 0 <= tiny_max < small_max < long_min.",
    );
  }
  config.thresholds = {
    tiny_max: tinyMax,
    small_max: smallMax,
    long_min: longMin,
  };
  return { config, minPeriods };
}

function validateObservation(observation: BodyObservation): {
  body: number;
  range: number;
} {
  if (observation === null || typeof observation !== "object") {
    throw new BodyClassificationError("observation must be an object.");
  }
  for (const field of [
    "timestamp",
    "instrument_id",
    "interval",
    "price_basis",
    "session",
  ] as const) {
    if (
      typeof observation[field] !== "string" ||
      observation[field].trim() === ""
    ) {
      throw new BodyClassificationError(`${field} must be a non-empty string.`);
    }
  }
  parseUtcTimestamp(observation.timestamp);
  if (typeof observation.is_closed !== "boolean") {
    throw new BodyClassificationError("is_closed must be a boolean.");
  }
  const body = finiteNumber(observation.body, "body");
  const range = finiteNumber(observation.range, "range");
  if (body < 0 || range < 0) {
    throw new BodyClassificationError("body and range must be non-negative.");
  }
  if (body > range) {
    throw new BodyClassificationError("body must not exceed range.");
  }
  return { body, range };
}

function median(values: readonly number[]): number {
  const sorted = [...values].sort((left, right) => left - right);
  const middle = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 1
    ? sorted[middle]
    : (sorted[middle - 1] + sorted[middle]) / 2;
}

export function classifyBody(
  observation: BodyObservation,
  priorClosedBodies: readonly number[],
  configInput: ClassifierConfigInput = {},
): ClassificationResult {
  const { body, range } = validateObservation(observation);
  const { config, minPeriods } = resolveConfig(configInput);
  const history = priorClosedBodies.map((value, index) =>
    finiteNumber(value, `priorClosedBodies[${index}]`),
  );
  if (history.some((value) => value < 0)) {
    throw new BodyClassificationError(
      "priorClosedBodies must contain non-negative values.",
    );
  }

  const reference = history.slice(-config.window);
  const base: ClassificationResult = {
    ...observation,
    body,
    range,
    status: "warmup",
    body_class: null,
    body_score: null,
    raw_scale: null,
    effective_scale: null,
    scale_floor_applied: false,
    history_count: reference.length,
    window: config.window,
    min_periods: minPeriods,
    tiny_max: config.thresholds.tiny_max,
    small_max: config.thresholds.small_max,
    long_min: config.thresholds.long_min,
    body_to_current_range: range > 0 ? body / range : null,
    is_provisional: !observation.is_closed,
    scale_method: "rolling_median_prior_body",
  };
  if (reference.length < minPeriods) return base;

  const rawScale = median(reference);
  const effectiveScale =
    config.tick_size === null
      ? rawScale
      : Math.max(rawScale, config.tick_size);
  base.raw_scale = rawScale;
  base.effective_scale = effectiveScale;
  base.scale_floor_applied =
    config.tick_size !== null && config.tick_size > rawScale;
  if (effectiveScale === 0) {
    base.status = "zero_scale";
    return base;
  }

  const score = body / effectiveScale;
  let bodyClass: BodyClass;
  if (score <= config.thresholds.tiny_max) {
    bodyClass = "tiny";
  } else if (score <= config.thresholds.small_max) {
    bodyClass = "small";
  } else if (score < config.thresholds.long_min) {
    bodyClass = "normal";
  } else {
    bodyClass = "long";
  }

  base.status = "ready";
  base.body_class = bodyClass;
  base.body_score = score;
  return base;
}

export function classifyBodySeries(
  observations: readonly BodyObservation[],
  configInput: ClassifierConfigInput = {},
): ClassificationResult[] {
  const closedHistory: number[] = [];
  let seriesIdentity: readonly string[] | null = null;
  let previousTimestamp: number | null = null;
  return observations.map((observation) => {
    const result = classifyBody(observation, closedHistory, configInput);
    const currentIdentity = SERIES_IDENTITY_FIELDS.map((field) => result[field]);
    if (seriesIdentity === null) {
      seriesIdentity = currentIdentity;
    } else if (
      currentIdentity.some((value, index) => value !== seriesIdentity![index])
    ) {
      throw new BodyClassificationError(
        "series observations must share instrument_id, interval, " +
          "price_basis, and session.",
      );
    }

    const currentTimestamp = parseUtcTimestamp(result.timestamp);
    if (previousTimestamp !== null && currentTimestamp <= previousTimestamp) {
      throw new BodyClassificationError(
        "series timestamps must be unique and strictly increasing.",
      );
    }

    if (observation.is_closed) closedHistory.push(result.body);
    previousTimestamp = currentTimestamp;
    return result;
  });
}
Full-height labbody classifierOpen full screen