Market Structure — BOS & CHoCH
Candle-Close Validation Method (Jonathan Jarvis / Norfolk FX)
Source: "Master Market Structure Anywhere (In 5 Seconds)" — Jonathan Jarvis (Mar 2026)
A fully mechanical system for identifying valid swing highs/lows that removes subjectivity. Works identically across any asset (Forex, crypto, indices) and any timeframe (Monthly to 1-minute).
Core Rules
Bullish structure — finding valid highs:
- After an upward move, find the first candle that closes below the previous candle's low
- The highest point before that close-down = valid high
- If no close-down occurs between swings, intermediate peaks are NOT valid highs
Bearish structure — finding valid lows:
- After a downward move, find the first candle that closes above the previous candle's high
- The lowest point before that close-up = valid low
- If no close-up occurs between swings, intermediate troughs are NOT valid lows
Identifying current swing points:
- Current low (bullish): The low of the last close-down candle before the most recent BOS upward
- Current high (bearish): The high of the last close-up candle before the most recent BOS downward
BOS vs CHoCH with Candle-Close Validation
| Event | Definition | Marking |
|---|---|---|
| BOS | Price breaks a valid high/low in the same trend direction (continuation) | Solid line |
| CHoCH | First BOS that reverses direction (bullish → bearish or vice versa) | Dashed line |
| 2nd BOS | Second break in the new direction after CHoCH — typically more profitable | Solid line |
After CHoCH: Switch your validation method:
- Was bullish (looking for close-downs) → Now bearish (look for close-ups)
- Was bearish (looking for close-ups) → Now bullish (look for close-downs)
Imbalance Zones
- Imbalance = aggressive move leaving unfilled price action (similar to FVG)
- When price pulls back into an imbalance and continues, the remaining unfilled portion = leftover imbalance
- These leftover zones act as high-probability areas of interest for entries
- Mark imbalance zones between the close-down/close-up candle and the BOS candle
Multi-Timeframe Application
The same candle-close logic applies identically on all timeframes:
- Monthly/Weekly → overall directional bias (cleaner, more reliable)
- 4H/1H → trading timeframe structure
- 5M/1M → scalping entries (more noise but same mechanical rules)
Use multi-TF confluence: align monthly bias → weekly structure → 4H entries.
Python Implementation (Candle-Close Method)
import pandas as pd, numpy as np
class CandleCloseStructure:
"""Mechanical structure mapping via candle-close validation (Jonathan Jarvis method).
A valid high requires a candle closing below the previous candle's low after an up-move.
A valid low requires a candle closing above the previous candle's high after a down-move.
"""
@staticmethod
def find_valid_swings(df: pd.DataFrame) -> dict:
closes = df["close"].values
highs = df["high"].values
lows = df["low"].values
valid_highs, valid_lows = [], []
trend = "bullish" # start assumption
for i in range(1, len(df)):
if trend == "bullish":
# Look for close below previous candle's low → creates valid high
if closes[i] < lows[i - 1]:
# Valid high = highest point before this close-down
lookback = highs[:i + 1]
peak_idx = int(np.argmax(lookback[max(0, i - 20):i]) + max(0, i - 20))
valid_highs.append({"idx": peak_idx, "price": float(highs[peak_idx]),
"confirmed_at": i})
else:
# Look for close above previous candle's high → creates valid low
if closes[i] > highs[i - 1]:
lookback = lows[:i + 1]
trough_idx = int(np.argmin(lookback[max(0, i - 20):i]) + max(0, i - 20))
valid_lows.append({"idx": trough_idx, "price": float(lows[trough_idx]),
"confirmed_at": i})
# Detect BOS/CHoCH to switch trend
if valid_highs and trend == "bullish":
if closes[i] < valid_lows[-1]["price"] if valid_lows else False:
trend = "bearish"
elif valid_lows and trend == "bearish":
if closes[i] > valid_highs[-1]["price"] if valid_highs else False:
trend = "bullish"
return {"valid_highs": valid_highs[-5:], "valid_lows": valid_lows[-5:], "current_trend": trend}
Alternative: Scipy-Based Swing Detection
import pandas as pd, numpy as np
from scipy.signal import argrelextrema
class MarketStructure:
@staticmethod
def analyze(df: pd.DataFrame, order: int = 5) -> dict:
highs = argrelextrema(df["high"].values, np.greater, order=order)[0]
lows = argrelextrema(df["low"].values, np.less, order=order)[0]
swing_highs = [(int(i), df["high"].iloc[i]) for i in highs]
swing_lows = [(int(i), df["low"].iloc[i]) for i in lows]
events = []
# Detect BOS and CHoCH
for i in range(1, len(swing_highs)):
if swing_highs[i][1] > swing_highs[i-1][1]:
events.append({"type": "BOS_BULLISH", "idx": swing_highs[i][0], "price": round(swing_highs[i][1], 5),
"meaning": "Break of Structure UP — bullish continuation"})
for i in range(1, len(swing_lows)):
if swing_lows[i][1] < swing_lows[i-1][1]:
events.append({"type": "BOS_BEARISH", "idx": swing_lows[i][0], "price": round(swing_lows[i][1], 5),
"meaning": "Break of Structure DOWN — bearish continuation"})
# CHoCH: trend change
for i in range(1, min(len(swing_highs), len(swing_lows))):
if i < len(swing_highs) and i < len(swing_lows):
prev_trend_up = swing_highs[i-1][1] > swing_highs[max(0,i-2)][1] if i >= 2 else True
curr_break_down = swing_lows[i][1] < swing_lows[i-1][1]
if prev_trend_up and curr_break_down:
events.append({"type": "CHoCH_BEARISH", "idx": swing_lows[i][0], "price": round(swing_lows[i][1], 5),
"meaning": "Change of Character — trend shifting bearish"})
# Current structure
if len(swing_highs) >= 2 and len(swing_lows) >= 2:
hh = swing_highs[-1][1] > swing_highs[-2][1]
hl = swing_lows[-1][1] > swing_lows[-2][1]
lh = swing_highs[-1][1] < swing_highs[-2][1]
ll = swing_lows[-1][1] < swing_lows[-2][1]
structure = "BULLISH (HH+HL)" if hh and hl else "BEARISH (LH+LL)" if lh and ll else "TRANSITIONING"
else:
structure = "INSUFFICIENT DATA"
return {
"current_structure": structure,
"swing_highs": swing_highs[-5:],
"swing_lows": swing_lows[-5:],
"recent_events": sorted(events, key=lambda e: e["idx"])[-5:],
"latest_event": events[-1] if events else None,
}