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)# }| Field | Name | Meaning |
|---|---|---|
t | Timestamp | When this bar started, in UTC. Format: YYYY-MM-DDTHH:MM:SS |
o | Open | The first price traded during this bar’s time period |
h | High | The highest price reached during this bar |
l | Low | The lowest price reached during this bar |
c | Close | The last price traded during this bar (most important for most strategies) |
v | Volume | How 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 moves1-minute: Standard for intraday, ~390 bars per US session5-minute: Smoother, good for swing entries, ~78 bars per session15-minute: Even smoother, fewer false signals, ~26 bars per session1-hour: Best for trend identification, ~7 bars per sessionReading 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 instrumentBody 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 wickupper_wick = bar["h"] - max(bar["o"], bar["c"])
# Lower wicklower_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 volumeExtracting 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_dayBar 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 barsif 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.5This 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 = 0bearish_count = 0current_day = ""daily_high = 0daily_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 summarytotal = bullish_count + bearish_countprint("-" * 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:
- Moving Average Strategies — calculate SMAs and EMAs from close prices, then build a crossover system