Skip to content
Back to App

Understanding OHLCV Bars

Every trading strategy you build in TestMax is driven by OHLCV bars — the fundamental unit of market data. Before building complex indicators and strategies, you need to understand exactly what this data represents and how to work with it in code.

What Is an OHLCV Bar?

Each bar represents price activity over a fixed time period. When you call get_next_bar() in the Algo Playground, you receive a dictionary with these fields:

bar = get_next_bar()
# Returns:
# {
# "t": "2025-01-15T14:30:00", # Timestamp (UTC)
# "o": 21500.25, # Open price
# "h": 21505.50, # High price
# "l": 21498.00, # Low price
# "c": 21503.75, # Close price
# "v": 1234 # Volume (number of contracts traded)
# }
FieldNameMeaning
tTimestampWhen this bar started, in UTC. Format: YYYY-MM-DDTHH:MM:SS
oOpenThe first price traded during this bar’s time period
hHighThe highest price reached during this bar
lLowThe lowest price reached during this bar
cCloseThe last price traded during this bar (most important for most strategies)
vVolumeHow many contracts changed hands during this bar

How Bars Are Formed

A bar does not appear instantly. It forms over the duration of its timeframe:

1-minute bar (1m): Aggregates all trades from 14:30:00 to 14:30:59. The open is the first trade, the close is the last trade, the high is the highest trade, and the low is the lowest trade during that minute.

5-minute bar (5m): Aggregates trades over 5 minutes. Fewer bars, smoother data, less noise.

1-second bar (1s): Every single second gets its own bar. Very noisy but very granular.

The timeframe you select in the Playground determines the bar size. The same market data looks completely different at different timeframes:

1-second: Noisy, thousands of bars per day, tiny moves
1-minute: Standard for intraday, ~390 bars per US session
5-minute: Smoother, good for swing entries, ~78 bars per session
15-minute: Even smoother, fewer false signals, ~26 bars per session
1-hour: Best for trend identification, ~7 bars per session

Reading Candle Shapes in Code

Traders look at candlestick charts visually. As an algorithm builder, you analyze the same patterns with math:

Bullish vs. Bearish Bars

# Bullish bar: price went up (close > open)
is_bullish = bar["c"] > bar["o"]
# Bearish bar: price went down (close < open)
is_bearish = bar["c"] < bar["o"]
# Doji: open and close are nearly equal (indecision)
is_doji = abs(bar["c"] - bar["o"]) < 0.5 # Adjust threshold per instrument

Body and Wick Size

The body is the distance between open and close. The wicks (or shadows) are the distances from the body to the high/low:

body = abs(bar["c"] - bar["o"])
full_range = bar["h"] - bar["l"]
# Upper wick
upper_wick = bar["h"] - max(bar["o"], bar["c"])
# Lower wick
lower_wick = min(bar["o"], bar["c"]) - bar["l"]

Common Candle Patterns

def classify_candle(bar):
body = abs(bar["c"] - bar["o"])
full_range = bar["h"] - bar["l"]
if full_range == 0:
return "doji"
body_ratio = body / full_range
upper_wick = bar["h"] - max(bar["o"], bar["c"])
lower_wick = min(bar["o"], bar["c"]) - bar["l"]
# Strong directional bar: big body, small wicks
if body_ratio > 0.7:
return "strong_bullish" if bar["c"] > bar["o"] else "strong_bearish"
# Hammer: small body at top, long lower wick (bullish reversal signal)
if lower_wick > body * 2 and upper_wick < body:
return "hammer"
# Shooting star: small body at bottom, long upper wick (bearish reversal signal)
if upper_wick > body * 2 and lower_wick < body:
return "shooting_star"
# Doji: very small body relative to range
if body_ratio < 0.1:
return "doji"
return "normal_bullish" if bar["c"] > bar["o"] else "normal_bearish"

Working with Bar Data Programmatically

Most strategies need to look back at previous bars to calculate indicators or detect patterns. Build arrays as bars come in:

opens, highs, lows, closes, volumes, timestamps = [], [], [], [], [], []
for i in range(TOTAL_BARS):
bar = get_next_bar()
if bar is None:
break
opens.append(bar["o"])
highs.append(bar["h"])
lows.append(bar["l"])
closes.append(bar["c"])
volumes.append(bar["v"])
timestamps.append(bar["t"])
idx = len(closes) - 1 # Current index
# Now you can look back:
if idx >= 10:
last_10_closes = closes[-10:] # Last 10 closing prices
highest_high = max(highs[-10:]) # Highest high in last 10 bars
lowest_low = min(lows[-10:]) # Lowest low in last 10 bars
avg_close = sum(closes[-10:]) / 10 # Simple moving average
avg_volume = sum(volumes[-10:]) / 10 # Average volume

Extracting Time Information

The timestamp field is useful for session-based logic:

def parse_bar_time(bar):
"""Extract date, hour, minute from bar timestamp."""
t = bar["t"] # "2025-01-15T14:30:00"
date = t[:10] # "2025-01-15"
hour = int(t[11:13]) # 14
minute = int(t[14:16]) # 30
return date, hour, minute
# Example: only trade during NY morning session (14:30-17:00 UTC)
date, hour, minute = parse_bar_time(bar)
in_session = (hour == 14 and minute >= 30) or (15 <= hour < 17)

Detecting New Days

Many strategies reset counters daily — trade counts, daily P&L, loss streaks:

current_day = ""
for i in range(TOTAL_BARS):
bar = get_next_bar()
if bar is None:
break
bar_day = bar["t"][:10] # "2025-01-15"
if bar_day != current_day:
if current_day:
print(f"[INFO] New day: {bar_day} (previous: {current_day})")
# Reset daily counters here
current_day = bar_day

Bar Ranges and Volatility

The range of a bar (high minus low) tells you about volatility:

bar_range = bar["h"] - bar["l"]
# Track average range over last 20 bars
if len(highs) >= 20:
ranges = [highs[j] - lows[j] for j in range(-20, 0)]
avg_range = sum(ranges) / len(ranges)
# High volatility: current bar range is 2x the average
is_volatile = bar_range > avg_range * 2
# Low volatility: current bar range is less than half the average
is_quiet = bar_range < avg_range * 0.5

This is the foundation of the Average True Range (ATR) indicator, which you will use in the Breakout Trading tutorial.

Volume Analysis

Volume tells you how much conviction is behind a price move:

if len(volumes) >= 20:
avg_vol = sum(volumes[-20:]) / 20
# High volume bar: more participation = more significant
high_volume = bar["v"] > avg_vol * 1.5
# Low volume bar: less conviction behind the move
low_volume = bar["v"] < avg_vol * 0.5
# Volume spike + big move = strong signal
if high_volume and abs(bar["c"] - bar["o"]) > avg_range:
print(f"[SIGNAL] High-volume directional move at bar {i}")

Practical Example: Bar Stats Logger

Here is a complete, runnable strategy that does not trade — it just analyzes bar data and prints statistics. Useful for understanding a new instrument or timeframe:

#!/usr/bin/env python3
"""Bar Statistics Logger — Analyze OHLCV data without trading."""
import os, time
ACCOUNT_ID = int(os.environ.get("ACCOUNT_ID", "0"))
CONTRACT_ID = os.environ.get("CONTRACT_ID", "")
TOTAL_BARS = int(os.environ.get("TOTAL_BARS", "5000"))
STEP_DELAY = float(os.environ.get("STEP_DELAY", "0.02"))
SPEED_FILE = os.environ.get("SPEED_FILE", "")
closes, volumes, ranges = [], [], []
bullish_count = 0
bearish_count = 0
current_day = ""
daily_high = 0
daily_low = float("inf")
for i in range(TOTAL_BARS):
bar = get_next_bar()
if bar is None:
break
read_speed()
if STEP_DELAY > 0:
time.sleep(STEP_DELAY)
closes.append(bar["c"])
volumes.append(bar["v"])
ranges.append(bar["h"] - bar["l"])
if bar["c"] > bar["o"]:
bullish_count += 1
else:
bearish_count += 1
# Track daily range
bar_day = bar["t"][:10]
if bar_day != current_day:
if current_day:
print(f"[DAY] {current_day} | Range: {daily_high - daily_low:.2f} pts")
current_day = bar_day
daily_high = bar["h"]
daily_low = bar["l"]
else:
daily_high = max(daily_high, bar["h"])
daily_low = min(daily_low, bar["l"])
# Print summary every 500 bars
if i > 0 and i % 500 == 0:
avg_range = sum(ranges[-100:]) / min(len(ranges), 100)
avg_vol = sum(volumes[-100:]) / min(len(volumes), 100)
print(f"[STATS] Bar {i} | Price: {bar['c']:.2f} | Avg Range: {avg_range:.2f} | Avg Vol: {avg_vol:.0f}")
# Final summary
total = bullish_count + bearish_count
print("-" * 50)
print(f"[DONE] Total Bars: {total}")
print(f"[DONE] Bullish: {bullish_count} ({bullish_count/total*100:.1f}%)")
print(f"[DONE] Bearish: {bearish_count} ({bearish_count/total*100:.1f}%)")
print(f"[DONE] Avg Bar Range: {sum(ranges)/len(ranges):.2f} pts")
print(f"[DONE] Max Bar Range: {max(ranges):.2f} pts")
print(f"[DONE] Avg Volume: {sum(volumes)/len(volumes):.0f}")
print(f"[DONE] Price Range: {min(closes):.2f} - {max(closes):.2f}")

What’s Next

Now that you understand bar data, you are ready to build your first indicator-based strategy: