> ## Documentation Index
> Fetch the complete documentation index at: https://takeprofit.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Time Price Opportunity profiles

> Request TPO profiles for the current chart instrument in Indie. Plot Point of Control and compare it with the Initial Balance.

Time Price Opportunity (TPO) groups the prices traded during fixed time blocks into a market profile. Indie can request
these profiles as a typed `Series[TpoProfile]` and use their Point of Control, Initial Balance, price rows and block
metadata in an indicator.

TPO is platform-provided data for the instrument of the current chart. It needs no URL or user-defined schema and does
not make the indicator an external-data indicator.

## Plot the daily Point of Control

Request the profiles once in `__init__`, then read the most recent profile with `[0]` in `calc`:

```py Daily TPO Point of Control theme={null}
# indie:lang_version = 5
from indie import (
    indicator,
    MainContext,
    request_series,
    Series,
    TimeFrame,
    plot,
    color,
)
from indie.data import TpoProfile
from indie.data import sources


@indicator('Daily TPO Point of Control', overlay_main_pane=True)
@plot.line(title='TPO Point of Control', color=color.BLUE)
class Main(MainContext):
    def __init__(self):
        self._tpo: Series[TpoProfile] = request_series[TpoProfile](
            source=sources.Tpo(),
            time_frame=TimeFrame.from_str('1D'),
        )

    def calc(self):
        return self._tpo[0].point_of_control
```

`sources.Tpo()` includes the main session, pre-market and post-market by default and merges them into one profile for
each requested period. The `time_frame` belongs to `request_series`, not to the source: TPO supports day, week and
month units such as `1D`, `1W` and `1M`.

The series is aligned by profile end time. On each chart bar, `[0]` is the latest TPO profile available at that time;
larger offsets access earlier profiles.

<Note>
  TPO always uses the instrument of the chart where the indicator is running. It cannot be used to select another
  exchange or ticker.
</Note>

## Compare Point of Control with the Initial Balance

This example plots the distance between the Point of Control and the midpoint of the Initial Balance. Positive values
place the Point of Control above that midpoint; negative values place it below.

```py TPO Point of Control vs Initial Balance theme={null}
# indie:lang_version = 5
from indie import (
    indicator,
    MainContext,
    request_series,
    Series,
    TimeFrame,
    plot,
    color,
)
from indie.data import TpoProfile
from indie.data import sources
from indie.data.sources import tpo_merge_sessions


@indicator('TPO POC vs Initial Balance')
@plot.line(title='POC minus IB midpoint', color=color.BLUE)
class Main(MainContext):
    def __init__(self):
        self._tpo: Series[TpoProfile] = request_series[TpoProfile](
            source=sources.Tpo(
                merge_sessions=tpo_merge_sessions.MERGE_ALL,
                block_size='1h',
                row_size_ticks=1,
            ),
            time_frame=TimeFrame.from_str('1D'),
        )

    def calc(self):
        profile = self._tpo[0]
        initial_balance_midpoint = (
            profile.initial_balance_high
            + profile.initial_balance_low
        ) / 2.0
        return profile.point_of_control - initial_balance_midpoint
```

Here `block_size='1h'` makes each TPO block one hour long, while `row_size_ticks=1` fixes every profile row to one
instrument tick. Set `row_size_ticks=0` (the default) to let the service choose the row size automatically. Supported
block sizes are `30m`, `1h`, `2h` and `4h`.

Use `tpo_merge_sessions.MERGE_ALL` to combine the selected sessions into one profile, or
`tpo_merge_sessions.GROUP_BY_TYPE` to keep the session types in separate profiles.

## Value area

A profile does not carry its value area: `TpoProfile` exposes the Point of Control and the Initial Balance, but no
VAH, VAL or value-area ratio. The value area you see on the [TPO chart type](/docs/guide/platform/chart-widget/chart-types/TPO-charts)
is not part of the data either — the chart works it out itself, from the same rows and Point of Control you get here plus
its own **Value area** percentage, 70% by default. An indicator cannot read that chart setting, so whatever you build has
to name the percentage it wants.

You can derive it in the indicator instead, the same way the chart does. Every price row lists the blocks that traded
at it, so `len(row.block_indexes)` is the TPO count of that row. Start at the Point of Control and widen the range one
row at a time, always taking the richer of the two neighbours, until the accumulated count covers the share of the
profile you treat as value:

```py TPO value area high and low theme={null}
# indie:lang_version = 5
from math import nan
from indie import (
    indicator,
    param,
    MainContext,
    request_series,
    Series,
    TimeFrame,
    plot,
    color,
)
from indie.data import TpoProfile
from indie.data import sources


@indicator('TPO Value Area', overlay_main_pane=True)
@param.float('value_area_ratio', default=0.7, min=0.1, max=1.0, title='Value area')
@plot.line(title='VAH', color=color.GREEN)
@plot.line(title='VAL', color=color.RED)
class Main(MainContext):
    def __init__(self):
        self._tpo: Series[TpoProfile] = request_series[TpoProfile](
            source=sources.Tpo(),
            time_frame=TimeFrame.from_str('1D'),
        )

    def calc(self, value_area_ratio):
        rows = self._tpo[0].rows
        row_count = len(rows)
        if row_count == 0:  # no profile yet on this bar
            return plot.Line(nan), plot.Line(nan)

        # The Point of Control is the row that the most blocks traded at.
        poc_index = 0
        total_blocks = 0
        for i in range(row_count):
            blocks = len(rows[i].block_indexes)
            total_blocks += blocks
            if blocks > len(rows[poc_index].block_indexes):
                poc_index = i

        # Grow the range around it one row at a time, always taking the richer neighbour.
        low_index = poc_index
        high_index = poc_index
        covered = len(rows[poc_index].block_indexes)
        target = total_blocks * value_area_ratio
        while covered < target and (low_index > 0 or high_index < row_count - 1):
            can_go_down = low_index > 0
            can_go_up = high_index < row_count - 1
            below = 0
            above = 0
            if can_go_down:
                below = len(rows[low_index - 1].block_indexes)
            if can_go_up:
                above = len(rows[high_index + 1].block_indexes)

            take_upper = False
            if not can_go_down:
                take_upper = True
            elif can_go_up:
                if above > below:
                    take_upper = True
                elif above == below:
                    # A tie goes to the row closer to the Point of Control,
                    # and to the upper row when both are equally far from it.
                    take_upper = (high_index + 1 - poc_index) <= (poc_index - low_index + 1)

            if take_upper:
                high_index += 1
                covered += above
            else:
                low_index -= 1
                covered += below

        return plot.Line(rows[high_index].price), plot.Line(rows[low_index].price)
```

Two rules in that loop are what keep the result equal to the chart's:

* **The value area stays one continuous price range.** You cannot simply take the busiest rows in the profile — they
  have to be added outwards from the Point of Control, neighbour by neighbour.
* **Ties are broken by distance.** When the row above and the row below hold the same number of blocks, the chart takes
  the one closer to the Point of Control, and the upper row when both are equally far.

The empty-row check is not optional either: a profile arrives with no rows on the bars before the first one is complete,
and indexing into it there stops the indicator with a runtime error.

Two more details are worth knowing before you compare the result with the chart. `price` is the **lower** bound of a row, so
the top of the value area is `rows[high_index].price + profile.row_size` if you want the row's upper edge. And the
chart's own value area is built with its **Value area** setting, which defaults to 70% — keep `value_area_ratio`
in step with it if the two are meant to agree.

The same rows also carry `volume_buy` and `volume_sell`, so the identical walk over `volume_buy + volume_sell` instead
of the block count gives you a volume-weighted value area rather than a time-based one.

For the complete source signature and all read-only profile fields, see
[`sources.Tpo`](/docs/indie/Library-reference/package-indie-data-sources#class_TpoSource) and
[`TpoProfile`](/docs/indie/Library-reference/package-indie-data#class_TpoProfile) in the Library Reference.
