This article breaks down a highly effective, Long/Short Intraday Momentum Strategy that uses the 15-minute Marubozu candle to capitalize on rapid, high-conviction moves in volatile assets like Natural Gas
Defining the High-Conviction Marubozu
The term Marubozu is Japanese for shaven head or bald, perfectly describing a candlestick that has no, or extremely small, shadows (wicks). It represents a trading period where buyers (or sellers) maintained control from the first price to the last.
The Candlestick Rules:
The strategy relies on a simple mechanical definition to identify conviction:
- Long Body: The body must be large relative to the wicks.
- Minimal Wicks: The sum of the Upper Wick and Lower Wick must be less than a small fraction (e.g., 5%) of the candle's total price range (High to Low).

The Intraday Strategy: Capturing Continuation
The core assumption of this strategy is momentum continuation: if conviction was overwhelming in one 15-minute period, that conviction will likely carry forward into the immediate following period.
🟢 LONG Entry
- Signal: A Bullish Marubozu forms on the 15-minute chart (Close > Open, minimal wicks).
- Action: Enter a Long position at the open of the very next 15-minute candle. The expectation is that the bullish pressure from the prior candle will continue the upward trend.
🔴 SHORT Entry
- Signal: A Bearish Marubozu forms on the 15-minute chart (Close < Open, minimal wicks).
- Action: Enter a Short position at the open of the very next 15-minute candle. The expectation is that the bearish pressure will continue the downward trend.
The Python Code with Backtest Results
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import date, timedelta
import math
# ---------------- CONFIG ----------------
# TICKER choices: 'NG=F' (natural gas futures on Yahoo) or 'UNG' (natural gas ETF)
TICKER = 'NG=F'
INTRADAY_INTERVAL = '15m' # intraday interval (Yahoo limits intraday history to ~60 days)
INTRADAY_PERIOD = '60d' # for intraday fetch
BENCHMARK_DAYS = 90 # 3-month benchmark (daily)
MARUBOZU_WICK_RATIO = 0.05
RISK_FREE_RATE = 0.02
TRADING_DAYS = 252
print(f"Running intraday Marubozu test for {TICKER} | intraday: {INTRADAY_PERIOD} @ {INTRADAY_INTERVAL} | benchmark: last {BENCHMARK_DAYS} days")
# ---------------- FETCH INTRADAY DATA ----------------
data = yf.download(TICKER, period=INTRADAY_PERIOD, interval=INTRADAY_INTERVAL, auto_adjust=False)
if data is None or data.empty:
raise SystemExit("No intraday data returned. Check ticker or interval.")
# Flatten MultiIndex columns if present
if isinstance(data.columns, pd.MultiIndex):
data.columns = [c[0] if isinstance(c, tuple) else c for c in data.columns]
# Ensure required OHLC present
required = {'Open','High','Low','Close'}
missing = required - set(map(str, data.columns))
if missing:
raise SystemExit(f"Missing required columns in intraday data: {missing}")
open_s = data['Open'].astype(float)
high_s = data['High'].astype(float)
low_s = data['Low'].astype(float)
close_s = data['Close'].astype(float)
# ---------------- MARUBOZU SIGNALS (intraday candles) ----------------
df = data.copy()
df['Range'] = high_s - low_s
df['Body'] = (close_s - open_s).abs()
max_oc = pd.concat([close_s, open_s], axis=1).max(axis=1)
min_oc = pd.concat([close_s, open_s], axis=1).min(axis=1)
df['Upper_Wick'] = high_s - max_oc
df['Lower_Wick'] = min_oc - low_s
df['Total_Wick'] = df['Upper_Wick'] + df['Lower_Wick']
eps = 1e-9
safe_range = df['Range'].replace(0, eps)
df['Wick_Ratio'] = df['Total_Wick'] / safe_range
df['Signal'] = 0
df.loc[(df['Wick_Ratio'] < MARUBOZU_WICK_RATIO) & (close_s > open_s), 'Signal'] = 1
df.loc[(df['Wick_Ratio'] < MARUBOZU_WICK_RATIO) & (close_s < open_s), 'Signal'] = -1
# ---------------- INTRADAY TRADING (enter next candle open, exit same candle close) ----------------
df['Next_Open'] = open_s.shift(-1)
df['Next_Close'] = close_s.shift(-1)
df['Position'] = df['Signal'].shift(1).fillna(0)
df['Intraday_Return'] = 0.0
valid = (df['Position'] != 0) & df['Next_Open'].notna() & df['Next_Close'].notna()
raw_ret = (df.loc[valid, 'Next_Close'] / df.loc[valid, 'Next_Open']) - 1
df.loc[valid, 'Intraday_Return'] = raw_ret * df.loc[valid, 'Position']
df['Strategy_Returns'] = df['Intraday_Return']
# ---------------- DAILY AGGREGATION (strategy) ----------------
dates = pd.to_datetime(df.index.date)
daily_strategy_returns = df['Strategy_Returns'].groupby(dates).sum().sort_index()
if not daily_strategy_returns.empty:
full_days = pd.date_range(daily_strategy_returns.index.min(), daily_strategy_returns.index.max(), freq='D')
daily_strategy_returns = daily_strategy_returns.reindex(full_days, fill_value=0)
cumulative_strategy = (1 + daily_strategy_returns).cumprod()
# ---------------- BENCHMARK (3-month daily buy & hold) ----------------
today = date.today()
bench_start = today - timedelta(days=BENCHMARK_DAYS)
bench_end = today
bench = yf.download(TICKER, start=bench_start, end=bench_end, interval='1d', auto_adjust=True)
if bench is None or bench.empty:
raise SystemExit("No daily benchmark data returned. Check ticker or date range.")
# Flatten bench columns if MultiIndex
if isinstance(bench.columns, pd.MultiIndex):
bench.columns = [c[0] if isinstance(c, tuple) else c for c in bench.columns]
bench_close = bench['Close']
if isinstance(bench_close, pd.DataFrame):
bench_close = bench_close.iloc[:, 0]
bench_close = bench_close.astype(float)
daily_market_returns = bench_close.pct_change().fillna(0)
cumulative_market = (1 + daily_market_returns).cumprod()
# ---------------- METRICS ----------------
annual_ret_strategy = daily_strategy_returns.mean() * TRADING_DAYS if len(daily_strategy_returns) else np.nan
annual_vol_strategy = daily_strategy_returns.std() * np.sqrt(TRADING_DAYS) if len(daily_strategy_returns) else np.nan
annual_ret_market = daily_market_returns.mean() * TRADING_DAYS
annual_vol_market = daily_market_returns.std() * np.sqrt(TRADING_DAYS)
def safe_sharpe(ret, vol, rf=RISK_FREE_RATE):
if ret is None or vol is None or np.isnan(vol) or vol == 0:
return np.nan
return (ret - rf) / vol
sharpe_strategy = safe_sharpe(annual_ret_strategy, annual_vol_strategy)
sharpe_market = safe_sharpe(float(annual_ret_market), float(annual_vol_market))
num_trades = int((df['Signal'] != 0).sum())
results = pd.DataFrame({
'Metric': ['Annualized Return', 'Annualized Volatility', 'Sharpe Ratio', 'Total Marubozu Signals'],
f'Benchmark (3M {TICKER} Buy & Hold)': [
f'{annual_ret_market*100:.2f}%' if not np.isnan(annual_ret_market) else 'nan',
f'{annual_vol_market*100:.2f}%' if not np.isnan(annual_vol_market) else 'nan',
f'{sharpe_market:.3f}' if not np.isnan(sharpe_market) else 'nan',
'-'
],
'Marubozu Strategy (Intraday)': [
f'{annual_ret_strategy*100:.2f}%' if not np.isnan(annual_ret_strategy) else 'nan',
f'{annual_vol_strategy*100:.2f}%' if not np.isnan(annual_vol_strategy) else 'nan',
f'{sharpe_strategy:.3f}' if not np.isnan(sharpe_strategy) else 'nan',
num_trades
]
})
print("\n--- Intraday Backtest Performance Metrics (Natural Gas) ---")
print(results.to_markdown(index=False))
# ---------------- PLOT ----------------
plt.figure(figsize=(12,6))
cumulative_market.index = pd.to_datetime(cumulative_market.index)
cumulative_strategy.index = pd.to_datetime(cumulative_strategy.index)
cumulative_market.plot(label=f'3M {TICKER} Buy & Hold (Daily)', linewidth=2, alpha=0.7)
cumulative_strategy.plot(label='Marubozu Intraday Strategy (Daily aggregated)', linewidth=2)
plt.title(f'{TICKER} - Intraday Marubozu vs 3M Buy & Hold Benchmark')
plt.xlabel('Date')
plt.ylabel('Cumulative Returns')
plt.legend()
plt.grid(True, linestyle='--', alpha=0.6)
plt.show()
# ---------------- SAMPLE TRADES ----------------
print("\nSample trades (last 10):")
print(df.loc[valid, ['Next_Open','Next_Close','Position','Intraday_Return']].tail(10).to_string())
# ---------------- OPTIONAL: save trades to CSV ----------------
# df.loc[valid, ['Next_Open','Next_Close','Position','Intraday_Return']].to_csv('natural_gas_trades.csv', index=True)
# print("Saved trades to natural_gas_trades.csv")The Backtest Results Summary

The Backtest Results Plot

The Intraday Exit Rule
Since this is an intraday strategy, risk is managed by forcing all positions to be closed by the end of the trading day. This avoids overnight risk (gap risk) and is essential for high-frequency trading:
- Exit: All active Long or Short positions must be closed at the end of the final 15-minute candle of the trading day.
- Candle-by-Candle: Profits or losses are measured and realized on a candle-by-candle basis, capturing the momentum from the moment the trade is entered until the position is reversed or the market closes.
3. Why the 15-Minute Chart and Natural Gas?
This strategy is specifically tuned for short timeframes and volatile assets:
- 15-Minute Interval: This timeframe is short enough to isolate pure momentum swings but long enough to filter out the noise of 1-minute or 5-minute charts. The 15-minute Marubozu is a powerful signal of institutional-level buying or selling pressure.
- Natural Gas: One of the highly volatile asset that workds well with this strategy. High-beta assets (those that move more than the general market) exhibit the strong, rapid conviction required to form a clear Marubozu and continue that momentum into the next period. Less volatile assets rarely form conviction candles strong enough to generate meaningful profits.
By combining the structural clarity of the Marubozu pattern with the rapid price action of high-beta stocks, this strategy provides a disciplined framework for exploiting short-term momentum in the intraday environment.