Skip to content

TPO Profile

The Time-Price-Opportunity (market-profile "letter") distribution: a volume-agnostic count of how many periods traded at each price level.

Quick reference

FieldValue
FamilyMarket Profile
Input typeCandle (uses high, low)
Output typeTpoProfileOutput { price_low, price_high, counts }
Output rangecounts[i] is a non-negative integer count (as f64)
Default parametersTpoProfile::classic() = 30-bar window, 50 bins
Warmup periodperiod
InterpretationWhere the market spent the most time over the window.

Formula

Over the last period candles, the price domain spans the window's lowest low to its highest high, split into bin_count equal buckets of width w = (price_high - price_low) / bin_count:

counts[i] = number of periods whose [low, high] range touches bucket i

Every period that trades at a price level contributes exactly one TPO mark there — a full +1 per touched bin, with no volume weighting and no sharing. A wide-range bar marks every level it spans; a single-print bar marks one. This is the classic Market Profile letter count: it highlights the prices the market spent the most time at (the profile bell curve), in contrast to VolumeProfile, which distributes volume. See crates/wickra-core/src/indicators/tpo_profile.rs.

Parameters

NameTypeDefaultValid rangeDescription
periodusize30 (classic)>= 1Rolling window length in candles. 0 errors with Error::PeriodZero.
bin_countusize50 (classic)>= 1Number of price buckets. 0 errors with Error::PeriodZero.

Inputs / Outputs

From crates/wickra-core/src/indicators/tpo_profile.rs:

rust
use wickra::{Candle, Indicator, TpoProfile, TpoProfileOutput};
// TpoProfile: Input = Candle, Output = TpoProfileOutput
const _: fn(&mut TpoProfile, Candle) -> Option<TpoProfileOutput> =
    <TpoProfile as Indicator>::update;

TpoProfileOutput carries price_low: f64, price_high: f64 and counts: Vec<f64> (length bin_count, lowest bucket first).

Python update(candle) returns (price_low, price_high, counts_ndarray) or None; batch(high, low) returns a (n, bin_count + 2) array with columns [price_low, price_high, count_0, …] (NaN warmup rows). Node update(high, low) returns { priceLow, priceHigh, counts } or null; batch returns a flat Array<number> of n * (bin_count + 2). TPO ignores volume, so the binding signatures take only high and low.

Warmup

TpoProfile::new(period, bin_count).warmup_period() == period. No value is emitted until the rolling window holds period candles. The unit test warms_up_over_period pins this.

Edge cases

  • Volume independence. Identical ranges with wildly different volumes produce identical TPO counts — volume never enters the calculation. Pinned by volume_independent.
  • Single-print window. If every bar trades at one price the domain collapses and bin 0 holds one mark per period. Pinned by degenerate_single_price_window.
  • Single-print bar in a wider domain. A low == high bar marks exactly its own bin. Pinned by single_print_bar_marks_one_bin.
  • Reset. reset() clears the window and last output. Pinned by reset_clears_state.

Examples

Rust

rust
use wickra::{Candle, Indicator, TpoProfile};

fn c(o: f64, h: f64, l: f64, cl: f64) -> Candle {
    Candle::new(o, h, l, cl, 1.0, 0).unwrap()
}

fn main() {
    let mut tpo = TpoProfile::new(2, 4).unwrap();
    tpo.update(c(12.0, 14.0, 10.0, 12.0)); // seed: spans 10..14
    let out = tpo.update(c(11.5, 12.0, 11.0, 11.5)).unwrap(); // spans 11..12
    println!("[{}, {}] {:?}", out.price_low, out.price_high, out.counts);
}

Output:

[10, 14] [1.0, 2.0, 2.0, 1.0]

Bar 0 touches all four bins (+1 each); bar 1 spans only bins 1‑2 (+1 each) → [1, 2, 2, 1]. This matches the reference_counts unit test.

Python

python
import wickra as ta

tpo = ta.TpoProfile(2, 4)
print(tpo.update((12.0, 14.0, 10.0, 12.0, 5.0, 0)))   # None (warmup)
low, high, counts = tpo.update((11.5, 12.0, 11.0, 11.5, 999.0, 1))
print(low, high, counts)  # volume (999) is ignored

Output:

None
10.0 14.0 [1. 2. 2. 1.]

Node

javascript
const ta = require('wickra');
const tpo = new ta.TpoProfile(2, 4);
console.log(tpo.update(14, 10));     // null (warmup), args: high, low
const out = tpo.update(12, 11);
console.log(out.priceLow, out.priceHigh, out.counts);

Output:

null
10 14 [ 1, 2, 2, 1 ]

Streaming vs batch (Python)

python
import numpy as np, wickra as ta

high = np.array([12.0, 13.0, 14.0, 15.0])
low = np.array([9.0, 10.0, 11.0, 12.0])
counts = ta.TpoProfile(4, 8).batch(high, low)   # shape (4, 10)
assert counts.shape == (4, 8 + 2)

Interpretation

The TPO profile's mode (the longest row of letters) is the time-based Point of Control — the fairest price by acceptance, where the market lingered longest. Read alongside VolumeProfile: when the time-POC and volume-POC diverge, time was spent at one price while volume transacted at another, often flagging absorption or a developing imbalance. Because TPO ignores volume it is robust on instruments with unreliable or missing volume data.

Common pitfalls

  • Treating counts as volume. A tall TPO bin means many periods traded there, not high volume. For volume use VolumeProfile.
  • Comparing bin indices across time. As with Volume Profile, the price domain is recomputed each bar from the window low/high, so bin boundaries shift; use price_low / price_high to interpret a bin.
  • Period as TPO bracket length. period is the rolling lookback in candles, not a session/bracket definition — each candle contributes one TPO mark per level it touched.

References

Time-Price-Opportunity charts and the Market Profile are due to J. Peter Steidlmayer (Chicago Board of Trade, 1980s); the letter-per-time-bracket construction is the canonical Market Profile representation.

See also