Market Profile (TPO)
Market Profile organizes a session’s trade into Time Price Opportunities (TPOs): the session is split into fixed-length time periods (one letter each), and every price a period traded at earns that letter. Stacking the letters sideways draws a distribution that shows where the market spent its time - the fat middle is acceptance, the thin edges are rejection.
It is two pieces: computeMarketProfile (pure analytics, in the profile tier) and the
MarketProfile primitive that renders the result on the chart.
import { computeMarketProfile, MarketProfile } from 'openalgo-charts/profile';
const result = computeMarketProfile(bars, { tickSize: 0.05, session: 'day', blockMinutes: 30 });
chart.addPrimitive(new MarketProfile(result));Concepts
- Session - how bars are grouped into profiles:
day,week,month, orcomposite(one profile over everything). Grouping is by the IST calendar. - Period / letter - each
blockMinutesslice of a session is one TPO letter (A,B, …Z, thena..z). - POC - Point of Control, the price with the most TPOs (the longest row).
- Value Area (VAH / VAL) - the band around the POC holding
valueAreaPercentof all TPOs. - Initial Balance - the price range of the opening
initialBalancePeriodsperiods. - Single prints - lone TPOs away from the extremes (quick moves that left a gap in time).
- Poor high / low - a session extreme printed by more than one TPO (a weak, often revisited, edge).
A profile in a few lines
The renderer overlays the price series, so add a price series first (it provides the time axis). Here is a composite profile - one distribution over all the bars, letters packed from the left:
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 26, 1800); // 30-min bars
chart.addSeries('candlestick').setData(bars);
const mp = lib.computeMarketProfile(bars, { tickSize: 1, session: 'composite', blockMinutes: 30 });
chart.addPrimitive(new lib.MarketProfile(mp));
chart.timeScale.fitContent(bars.length);
return chart;Sessions
Switch session to split the same bars into one profile per day / week / month. Each
session’s profile is drawn under its own bars, so you can read structure across time:
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 120, 1800);
chart.addSeries('candlestick').setData(bars);
const mp = lib.computeMarketProfile(bars, { tickSize: 1, session: 'day', blockMinutes: 60, valueAreaPercent: 0.7 });
chart.addPrimitive(new lib.MarketProfile(mp)); // IB bracket on the left of each session
chart.timeScale.fitContent(bars.length);
return chart;Color modes & volume sub-profile
colorMode is valueArea (default), uniform, or heat (opacity scales with TPO count).
Turn letters off for a solid histogram, and overlay a volume-at-price sub-profile:
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 40, 1800);
chart.addSeries('candlestick').setData(bars);
const mp = lib.computeMarketProfile(bars, { tickSize: 1, session: 'composite', blockMinutes: 30 });
chart.addPrimitive(new lib.MarketProfile(mp, {
colorMode: 'heat',
showLetters: false, // solid blocks instead of letters
showVolumeProfile: true, // volume-at-price on the right edge
volumeProfileWidth: 70,
}));
chart.timeScale.fitContent(bars.length);
return chart;computeMarketProfile options
computeMarketProfile(bars, options): MarketProfileResult| Option | Default | Description |
|---|---|---|
tickSize | 0.05 | the instrument’s real tick |
rowTicks | 1 | ticks per TPO row — see Row size |
session | 'day' | 'day' / 'week' / 'month' / 'composite' |
blockMinutes | 30 | period length (one letter per period) |
valueAreaPercent | 0.7 | fraction of TPOs inside the value area (0..1) |
initialBalancePeriods | 2 | opening periods that form the Initial Balance |
window | none | restrict each session to a SessionWindow; see Sessions |
compositeSessions | 1 | merge N consecutive sessions into one profile |
tailEdges | 0 | min run of single prints that promotes a buying / selling tail |
Row size
TPO row height is tickSize * rowTicks. Keeping the two separate means widening rows never
means lying about the instrument’s tick — which matters, because the tick is what imbalance
and single-print logic are counted on.
rowTicks is the multiplier a trader already thinks in. Nifty trades in 0.1 and you want
2-point rows, so the multiplier is 2 / 0.1 = 20:
computeMarketProfile(bars, { tickSize: 0.1, rowTicks: 20 }); // 2-point rows
computeMarketProfile(bars, { tickSize: 0.1, rowTicks: rowTicksFor(2, 0.1) }); // same thingrowTicksFor(rowSize, tickSize) does the division for you and floors at 1.
The order flow / footprint side takes the same multiplier, so a chart’s bricks and its
profile rows can share one grid: computeFootprint(time, trades, 0.1, 20) and
new FootprintAggregator(tf, 0.1, 20). See Profiles & Order Flow.
Session windows
A window drops bars outside a trading session and anchors period A to the window’s open —
not to whatever bar happened to arrive first, which otherwise shifts every letter. Windows
that cross midnight are handled as one session, not two halves.
import { computeMarketProfile, TRADING_HOURS } from 'openalgo-charts/profile';
computeMarketProfile(bars, { window: TRADING_HOURS['india'] }); // 09:15–15:30 IST
computeMarketProfile(bars, { window: { startMinute: 9 * 60 + 15, endMinute: 15 * 60 + 30, name: 'RTH' } });Built-ins: all-hours, india, asia, london, new-york, us-regular — all in IST
minutes-from-midnight, since the library’s calendar is IST (§5.3).
MarketProfile primitive options
Pass in the constructor (new MarketProfile(result, options)) or update live with
profile.setOptions({ ... }).
| Option | Default | Description |
|---|---|---|
blockDisplay | 'auto' | auto / blocks+letters / letters / blocks — see Letters and bricks |
minLetterHeight / letterFade | 7 / 4 | row height (px) where letters give way to bricks, and the fade band |
letterWidth | 8 | width of one TPO column, px |
font | 10 | letter font size, px (auto-shrinks to fit short rows) |
colorMode | 'period' | period / valueArea / count / volume / uniform |
periodColors | 12 hues | palette for colorMode: 'period' |
opacity / outsideVaOpacity | 0.92 / 0.45 | block opacity, and dimming outside the value area |
split | false | give each period its own column slot instead of packing rows left |
profileSpacing | 4 | gap between session profiles, px |
showPoc / pocColor / pocThickness / showPocLabel | true / gold / 2 / true | Point of Control |
showValueArea / vahColor / valColor / showValueAreaLabels | true | VAH & VAL lines + labels |
fillValueArea / valueAreaFillColor / valueAreaFillOpacity | true / teal / 0.07 | shade the value-area band |
showInitialBalance / ibColor | true | opening-range bracket |
showSinglePrints / singlePrintColor | true | mark lone TPOs |
showTails / buyTailColor / sellTailColor | true | buying / selling tails (needs tailEdges) |
showPoorHighLow / poorColor | false | flag a session extreme that printed more than one TPO |
showNakedLevels / nakedColor | false | extend untested prior POC / VAH / VAL to the right edge |
showDevelopingPoc / showDevelopingVa | false | trace POC / value area as they developed |
showTpoCounts / countColor | false | per-level TPO count column |
showSessionLabel / showDayType / showOpenType | true / false / false | header labels |
showVolumeProfile / volumeProfileWidth / volumeProfileSide / showVolumeValues | false / 60 / right / false | volume-at-price sub-profile |
zOrder | top | draw over (top) or behind (bottom) the series |
Letters and bricks
A TPO row is only as tall as the price scale makes it, so at some zoom a letter stops fitting.
Rather than clip glyphs or make you toggle a setting, blockDisplay: 'auto' (the default)
crossfades: the block is always drawn, and the letter fades in over letterFade px above
minLetterHeight. Zooming through the threshold reads as one continuous change rather than a
jump, and the profile stays readable at every scale.
Pin the behaviour when you want it fixed — 'letters' for glyphs only, 'blocks' for a pure
brick profile, 'blocks+letters' to force both regardless of room.
The footprint renderer fades its cell numbers the same way, via minTextHeight / textFade.
Hit-testing
hitTest(x, y) and hoverAt(x, y) map a pointer back to the session and price row under it,
so a host can build a tooltip without the library owning any DOM. hoverAt returns
{ sessionIndex, price, level, session, isPoc, inValueArea, isSinglePrint }.
chart.on('crosshair:move', (e) => {
const hit = e.point ? mp.hoverAt(e.point.x, e.point.y) : null;
if (hit) tooltip.textContent = `${hit.level.letters} ${hit.level.count} TPO`;
});Data model
interface MarketProfileResult {
sessions: MarketProfileSessionResult[];
options: MarketProfileOptions;
}
interface MarketProfileSessionResult {
startTime: number; // UTC seconds (first bar)
endTime: number; // UTC seconds (last bar)
levels: { price: number; count: number; letters: string; volume: number }[]; // high -> low
poc: number;
vah: number; val: number;
periods: number;
initialBalance: { high: number; low: number };
singlePrints: number[];
poorHigh: boolean; poorLow: boolean;
totalVolume: number;
}Everything you need for your own annotations (naked POCs, day-type labels, alerts) is on the result - the primitive is just one way to draw it.
Live updates
Recompute on new bars and hand the fresh result to the same primitive; setData repaints:
const profile = new MarketProfile(computeMarketProfile(bars, opts));
chart.addPrimitive(profile);
// later, as bars grow:
profile.setData(computeMarketProfile(bars, opts));
profile.setOptions({ colorMode: 'heat' }); // restyle without recomputingVolume-at-price here comes from each bar’s volume spread across its range - a fast
approximation. For true bid/ask volume distribution use the Footprint.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Nothing renders | No price series, so the time axis has no bars | Add a series (addSeries) before the profile |
| Only one profile for many days | session: 'composite' | Use session: 'day' (or week / month) |
| Letters overlap vertically | tickSize too small for the price range | Increase tickSize (fewer, taller rows) |
| Letters run past the session | Many periods and a wide letterWidth | Lower letterWidth, or raise blockMinutes |
| Value area looks wrong | valueAreaPercent not in 0..1 | Pass a fraction (0.7), not a percent (70) |