The Learning Library
Contents

Module 8 — Low-Timeframe Trading Lab (Tick to M15)

Part III · Intraday Design, Alpha & Playbooks · Priority 🎯 Core Status: Draft v0.2 · Scope: commodity ETFs/ETPs + BTC/ETH spot & linear perps · Horizon: M1/M5 (M15 where appropriate), same-session flat Prerequisites: M5, M6, M22, M23, M7


Overview

Start this laboratory with the price you can actually execute in the market. Never start with an indicator’s theoretical signal.

A commodity ETP trades during U.S. regular hours. Its market price can deviate from NAV, and you pay a bid-ask spread on every transaction SEC ETF bulletin. A futures-based commodity pool rolls contracts continuously. When the futures curve is in contango, rolling contracts drags performance relative to spot Fidelity on contango/backwardation. A physical trust like GLD holds bullion and discloses daily holdings SPDR Gold Shares (GLD). Meanwhile, a BTC or ETH linear perpetual exchanges funding payments between longs and shorts on an exchange-specific schedule that must be accounted for if held through funding timestamps Coinbase perpetual funding.

Our core hypothesis is narrow: at M1, M5, and M15, simple technical baselines can be tested rigorously enough that a negative feasibility verdict is just as valuable as a positive one.

Intraday edge is not about complex price prediction. It is about whether a simple trade setup can cover transaction costs and exit flat before the session close. Who is on the other side of your trade? The market maker quoting the spread and, in perpetuals, the funding counterparty. Your strategy must generate an average gross gain that comfortably exceeds that transaction toll.

Evidence status: hypothesis, mechanics-supported. Product wrappers and exchange mechanics explain why trading frictions behave as they do. They do not prove that an intraday edge exists. You must establish net profitability through rigorous replay.

The primary pitfall at low timeframes is the ambiguous-bar mirage. An OHLC bar records only that a high and a low were touched. It does not record the intrabar price path. A backtester that assumes a profit target was hit before a stop loss when both levels sit inside the same bar will report fictional profits ohlcv.io on bar resolution. Combined with filling at the signal bar’s close instead of the next bar’s open Saral on next-bar-open execution, this error creates an illusion of profitability that vanishes in live trading.

💡 The Audit Ledger Analogy: Treat the OHLC bar as an audit ledger. The bar’s ATR is the gross wage the market offers; the spread is the broker’s mandatory toll. When both stop and target are touched inside the same bar, that entry is an ambiguous journal entry. To remain honest, you must book the worst-case loss.

📌 Data Contract: This module operates strictly on OHLCV + spread data. We assume no order book, tick data, or partial fills. Signals evaluate at the close of bar t and fill at the open of bar t+1. Spreads are deducted on both entry and exit. Same-bar barrier conflicts resolve stop-first. All positions are forced flat before the session close. Completed H1/H4/D1 bars serve as causal context only.


How It Works

The laboratory operates on three coordinated components.

1. The Conservative Replay Engine

The engine replays M1, M5, and M15 bars with strict causal discipline:

  • Execution Timing: Signals calculated on the close of bar t execute at the open of bar t+1.
  • Cost Deduction: Timestamped spread plus fee and slippage stress are deducted on every trade.
  • Ambiguous-Bar Resolution: If a bar touches both the stop loss and profit target, the engine books the stop loss first (worst case) or excludes the trade and logs the ambiguous share.
  • Forced Flatten: Every position is closed unconditionally at or before the final eligible bar of the session (ETP RTH close or crypto synthetic boundary). Gaps fill at the next open.

2. Two Deterministic Control Baselines

The lab evaluates two standard technical controls:

  • Breakout Control: Donchian channel breakouts with ATR range-expansion and ADX trend filters.
  • Reversion Control: Bollinger Band and Keltner Channel mean reversion with RSI and session VWAP stretch filters.

3. Pre-Trade Feasibility Gates

Before any trade is executed, two cost gates must pass:

  • Spread-to-ATR: Spread ÷ ATR ≤ 15%.
  • Cost-to-Target: (Spread + Fees + Slippage) ÷ Target ≤ 10%.

Figure: The Module 8 laboratory pipeline. Data flows from left to right through cost filters, signal generation, conservative execution, and session-end flattening.

flowchart TD
    bars["OHLCV + spread<br/>M1/M5/M15 completed bars"] --> clock["Session clock<br/>ETF RTH vs UTC synthetic"]
    bars --> atr["Technical calculations<br/>ATR Donchian BB/Keltner<br/>RSI ADX VWAP slopes"]
    atr --> signal["Two controls<br/>breakout vs reversion"]
    clock --> gate1{"Spread-to-ATR<br/>below ceiling?"}
    atr --> gate1
    clock --> gate2{"Cost-to-target<br/>below ceiling?"}
    atr --> gate2
    clock --> gate3{"Bar eligible<br/>in declared session?"}
    gate1 -->|"no"| reject["Block bar<br/>no new entry"]
    gate2 -->|"no"| reject
    gate3 -->|"no"| reject
    gate1 -->|"yes"| ready["Eligible for<br/>next-bar-open eval"]
    gate2 -->|"yes"| ready
    gate3 -->|"yes"| ready
    signal --> ready
    ready --> exec["Bar-based execution<br/>close t -> open t+1<br/>spread deducted"]
    exec --> ambig{"Both barriers<br/>touched?"}
    ambig -->|"no"| book["Book realized<br/>stop or target"]
    ambig -->|"yes"| adverse["Conservative: stop/adverse<br/>first or exclude & count"]
    book --> flatten{"Flatten at<br/>session boundary?"}
    adverse --> flatten
    flatten -->|"yes"| flat["Forced flatten<br/>same session"]
    flatten -->|"no"| hold["Hold to next bar<br/>time stop still ticking"]

    classDef data fill:#e8f0fe,stroke:#4a86e8
    classDef process fill:#f3f3f3,stroke:#888
    classDef decision fill:#fff4d6,stroke:#d6a300
    classDef risk fill:#fde8e8,stroke:#c0392b
    classDef ok fill:#e6f4ea,stroke:#2e7d32

    class bars,clock data
    class atr,signal,exec,book,adverse,flat,hold process
    class gate1,gate2,gate3,ambig,flatten decision
    class reject risk
    class ready ok

The Step-by-Step Replay Workflow

  1. Load clean OHLCV + spread data for your commodity ETP or crypto perpetual.
  2. Compute indicators on completed bars (ATR, Donchian, Bollinger, VWAP).
  3. Evaluate cost gates at bar t close: Verify Spread-to-ATR and Cost-to-Target are below ceilings.
  4. Arm trade for bar t+1 open: Pay the ask price (open + half-spread) for longs.
  5. Simulate bar-by-bar progression: Check stops, targets, and time limits against subsequent OHLC bars.
  6. Apply conservative exit rules: Book stop loss if both barriers are touched in a single bar.
  7. Force flatten at session end: Close any open position at the final session bar.

Strategy Rules

These rules establish the laboratory baseline contract inherited by all subsequent strategy modules (M10–M12).

The Invariant Lab Contract

Contract Area Laboratory Rule Practical Purpose
Price Basis & Spread Declare mid vs. last price; document spread units (full vs. half, price vs. bps). Prevents undercounting execution costs on M1/M5 bars.
Session & Flattening ETP: RTH (09:30–16:00 ET); Crypto: UTC synthetic (00:00–23:50 UTC). Force flatten at session end. Eliminates overnight gap risk and unmodeled funding costs.
Execution Timing Signal on close of bar t → fill at open of bar t+1. Eliminates look-ahead bias from same-close execution.
Path Ambiguity Both stop and target touched in one bar → resolve stop-first. Accounts for intrabar path uncertainty conservatively.
Cost Ceilings Spread ÷ ATR ≤ 15% and All-in Cost ÷ Target ≤ 10%. Discards expensive bars before spending compute on signals.
HTF Context Completed H1/H4/D1 features joined strictly at bar close timestamp. Prevents leakage of forming higher-timeframe bars.
Risk Limits Fixed ATR stop loss, session trade caps, and daily loss limits from M23. Prevents catastrophic drawdown during adverse regimes.

The Two Control Baselines

1. Breakout Control (Donchian / ATR / ADX)

  • Long Entry: close[t] > donchian_high[t] by buffer, with ADX(14) > 18 and cost gates passing.
  • Stop Loss: entry_price - (1.2 × ATR).
  • Profit Target: entry_price + (1.0 × ATR).
  • Time Stop: 16 bars (80 minutes on M5) or session end.

2. Reversion Control (Bollinger / Keltner / RSI / VWAP)

  • Long Entry: close[t] < bb_lower[t] and close[t] < session_vwap × 0.998 with RSI(14) < 30 and ADX(14) < 20 (non-trending tape).
  • Stop Loss: entry_price - (1.2 × ATR).
  • Profit Target: Reversion to session VWAP or Bollinger midline.
  • Time Stop: 12 bars (60 minutes on M5) or session end.

📌 Transfer Evidence Note: Documented breakout patterns Volatility Box on opening range breakout and mean-reversion studies Volatility Box on Keltner vs Bollinger in equity index futures represent transfer evidence. Use them to establish prior ranges for lookbacks and multipliers, never as proof of net edge in your target lane.


Building It In Python

Below is a complete, vectorized Polars implementation of the laboratory indicators and replay engine.

1. Intuition: Calculate Lab Indicators

We compute ATR, Donchian channels, Bollinger Bands, Keltner Channels, RSI, and session VWAP from completed bars.

# Laboratory technical indicators in Polars
# Q: How do we construct clean, causal features?
import polars as pl

def compute_lab_indicators(frame: pl.DataFrame) -> pl.DataFrame:
    # 1. Average True Range (Wilder EMA)
    prev_close = frame["close"].shift(1)
    tr = pl.max_horizontal([
        frame["high"] - frame["low"],
        (frame["high"] - prev_close).abs(),
        (frame["low"] - prev_close).abs(),
    ])
    atr14 = tr.ewm_mean(span=14, adjust=False).alias("atr_14")

    # 2. Donchian Channels (shifted by 1 bar to avoid lookahead)
    don_high = frame["high"].shift(1).rolling(20).max().alias("don_high")
    don_low = frame["low"].shift(1).rolling(20).min().alias("don_low")

    # 3. Bollinger Bands (20-period SMA +/- 2 SD)
    sma20 = frame["close"].rolling(20).mean()
    sd20 = frame["close"].rolling(20).std()
    bb_upper = (sma20 + 2.0 * sd20).alias("bb_upper")
    bb_lower = (sma20 - 2.0 * sd20).alias("bb_lower")

    # 4. RSI (14-period Wilder)
    delta = frame["close"].diff()
    gain = delta.clip(0, None).ewm_mean(alpha=1/14, adjust=False)
    loss = (-delta.clip(None, 0)).ewm_mean(alpha=1/14, adjust=False)
    rs = gain / (loss + 1e-10)
    rsi14 = (100 - (100 / (1 + rs))).alias("rsi_14")

    # 5. Cumulative Session VWAP
    tp = (frame["high"] + frame["low"] + frame["close"]) / 3
    cum_pv = (tp * frame["volume"]).cum_sum().over("session_id")
    cum_vol = frame["volume"].cum_sum().over("session_id")
    vwap = (cum_pv / (cum_vol + 1e-10)).alias("session_vwap")

    return frame.with_columns([atr14, don_high, don_low, bb_upper, bb_lower, rsi14, vwap])

2. Intuition: Generate Signals & Apply Feasibility Gates

We generate signals at bar t close and enforce Spread-to-ATR and Cost-to-Target ceilings.

# Signal generation and cost gating
# Q: Which bars qualify for execution?

def generate_lab_signals(frame: pl.DataFrame) -> pl.DataFrame:
    # Feasibility Cost Gates
    s2atr = frame["spread"] / frame["atr_14"]
    c2t = frame["spread"] / (0.9 * frame["atr_14"])
    cost_pass = (s2atr <= 0.15) & (c2t <= 0.10)

    # Breakout Control Signal
    breakout_long = (frame["close"] > frame["don_high"] * 1.0005) & cost_pass

    # Reversion Control Signal
    reversion_long = (
        (frame["close"] < frame["bb_lower"]) &
        (frame["rsi_14"] < 30) &
        (frame["close"] < frame["session_vwap"] * 0.998) &
        cost_pass
    )

    return frame.with_columns([
        s2atr.alias("spread_to_atr"),
        c2t.alias("cost_to_target"),
        breakout_long.alias("sig_breakout"),
        reversion_long.alias("sig_reversion"),
    ])

3. Intuition: Conservative Bar-Replay Engine

We simulate trade progression bar by bar. Entry occurs at next-bar open; ambiguous exits resolve stop-first; open positions flatten at session end.

# Conservative bar-based replay engine
# Q: Does our edge survive honest execution and worst-case path resolution?

def run_conservative_replay(
    frame: pl.DataFrame,
    signal_col: str,
    k_stop: float = 1.2,
    k_target: float = 1.0,
    max_bars: int = 16
) -> pl.DataFrame:
    records = frame.to_dicts()
    trades = []
    in_pos = False
    entry_price = 0.0
    stop_price = 0.0
    target_price = 0.0
    entry_idx = 0

    for i in range(len(records) - 1):
        curr = records[i]
        nxt = records[i+1]

        # Check Entry Signal (Signal on t -> Fill on t+1 open)
        if not in_pos and curr[signal_col] and not curr["is_final_bar"]:
            in_pos = True
            entry_price = nxt["open"] + 0.5 * nxt["spread"]  # Pay half-spread on entry
            atr = curr["atr_14"]
            stop_price = entry_price - (k_stop * atr)
            target_price = entry_price + (k_target * atr)
            entry_idx = i + 1
            continue

        # Manage Open Position
        if in_pos:
            bars_held = i - entry_idx + 1
            hit_stop = curr["low"] <= stop_price
            hit_target = curr["high"] >= target_price

            # 1. Ambiguous Bar Resolution (Stop-First)
            if hit_stop and hit_target:
                exit_price = stop_price - 0.5 * curr["spread"]
                trades.append({"entry": entry_price, "exit": exit_price, "net": exit_price - entry_price, "type": "ambig_stop"})
                in_pos = False
            # 2. Clean Stop
            elif hit_stop:
                exit_price = stop_price - 0.5 * curr["spread"]
                trades.append({"entry": entry_price, "exit": exit_price, "net": exit_price - entry_price, "type": "stop"})
                in_pos = False
            # 3. Clean Target
            elif hit_target:
                exit_price = target_price - 0.5 * curr["spread"]
                trades.append({"entry": entry_price, "exit": exit_price, "net": exit_price - entry_price, "type": "target"})
                in_pos = False
            # 4. Time Stop or Forced Session Flatten
            elif bars_held >= max_bars or curr["is_final_bar"]:
                exit_price = curr["close"] - 0.5 * curr["spread"]
                trades.append({"entry": entry_price, "exit": exit_price, "net": exit_price - entry_price, "type": "flatten"})
                in_pos = False

    return pl.DataFrame(trades)

Hand-Checkable Numeric Example

Suppose an M5 gold ETP setup triggers a reversion signal with the following parameters:

Parameter Value Calculation / Meaning
Current M5 Close $42.10 Below lower Bollinger Band ($42.12)
Session VWAP $42.35 Stretched tape: $42.10 < 0.998 \times 42.35$ ($42.26)
RSI(14) 27.0 Oversold tail confirmed (< 30)
M5 ATR $0.18 Gross bar wage
M5 Spread $0.030 Round-trip toll
Spread-to-ATR 16.7% $0.030 ÷ 0.18$ (Exceeds 15% ceiling)
Verdict BLOCKED Cost gate refuses to arm the trade

What this means for your P&L: Even though price is deeply stretched and RSI is oversold, the lab refuses to trade. The $0.03 spread consumes 16.7% of the bar’s expected move, meaning transaction costs would erode your expected edge.


Testing It Honestly

Honest backtesting enforces the data-aware contract without compromise:

  1. Next-Bar-Open Execution: Every fill occurs at the open of bar t+1. Testing with same-bar close execution artificially borrows overnight gaps Saral on next-bar-open execution.
  2. Stop-First Ambiguous Resolution: When a single bar touches both stop and target, resolve adversely. An edge that exists only when winning ambiguous bars is fictitious ohlcv.io on bar resolution.
  3. Mandatory Session Flatten: Every trade closes at or before the session boundary. No positions carry overnight.
  4. Purged Walk-Forward Splits: Test folds are session-aligned with embargo periods to prevent label leakage.
  5. Cost Stress Scans: Every backtest is evaluated under median spreads, p90 spread spikes, and added slippage stress.
# Purged walk-forward cross-validation skeleton
def generate_purged_splits(session_ids: list, n_splits: int = 5, embargo_sessions: int = 1):
    unique_sessions = sorted(list(set(session_ids)))
    split_size = len(unique_sessions) // (n_splits + 1)

    for i in range(1, n_splits + 1):
        train_sessions = unique_sessions[:i * split_size]
        test_sessions = unique_sessions[i * split_size + embargo_sessions:(i + 1) * split_size]
        yield train_sessions, test_sessions

⚠️ Pitfall Diagnostic: Run your strategy under two quick stress tests:

  1. Same-Close vs. Next-Open: If profits vanish when moving from same-close to next-open fills, your backtest was peeking at the gap.
  2. Median vs. p90 Spread: If net return collapses under 90th percentile spreads, your strategy is overconcentrated in illiquid hours.

Variants & Extensions

Variant Modification Practical Purpose Trade-off
M15 Step-Out Replay on M15 bars Rescues setups where M5 Spread-to-ATR is prohibitive Lower trade frequency; wider stops
Session Window Slicing Restrict trading to peak liquidity hours Avoids wide spreads and low ATR periods Reduces total session sample size
Causal HTF Gating Require completed H1 EMA trend alignment Filters out low-expectancy counter-trend signals Fewer trades per session
Perpetual Funding Handling Close before funding timestamp or book actual rate Accounts for funding cash flows in crypto perps Requires tracking exchange funding schedules

Hands-On Project

Deliverable: docs/research/m08_feasibility_lab.md and reproducible notebook notebooks/m08_feasibility_lab.ipynb.

Project Card — M8 Baseline Feasibility (v1.0)

Area Pre-Registration Requirement
Identity One commodity ETP (GLD or PDBC) and one crypto perpetual (BTC or ETH) on an approved venue; declare price basis and spread units.
Horizon Replay M1, M5, and M15; maximum hold duration in bars; mandatory same-session forced flatten.
Clock ETP: RTH 09:30–16:00 ET; Crypto: UTC 00:00–23:50 synthetic session (flatten at 23:50 UTC).
Execution Signal on close of bar t → fill at open of bar t+1; deduct timestamped spread; stop-first ambiguous resolution.
Costs Full spread deducted per round trip plus fee/slippage stress; evaluate under median and p90 spread surfaces.
Validation Purged rolling walk-forward with embargo; untouched final holdout period.
Acceptance Criteria Positive net expectancy on purged OOS; survival under p90 spread stress; parameter plateau (no single-cell spikes); ambiguous trade share < 5%.

Step-by-Step Instructions

  1. Load clean M1/M5/M15 data. Compute ATR, Donchian, Bollinger, RSI, and session VWAP.
  2. Replay both the Breakout and Reversion baselines across all three timeframes.
  3. Apply next-bar-open execution and stop-first ambiguous-bar resolution.
  4. Run purged walk-forward cross-validation and report net expectancy per fold.
  5. Perform a parameter sensitivity sweep around lookbacks and ATR multipliers to verify parameter plateaus.
  6. Publish the final research memo with explicit Pass, Revise, or Reject verdicts for each lane.

Key Takeaways

  • Execution timing determines validity: Backtests that fill at the signal bar’s close borrow unearned gaps. Honest testing fills at the next bar’s open.
  • Ambiguous bars must resolve adversely: An OHLC bar cannot prove intrabar path order. Always book the stop loss when both barriers are touched.
  • Cost gates prevent expensive mistakes: Spread-to-ATR and Cost-to-Target filters discard unviable bars before signals are evaluated.
  • Baselines provide standard controls: Donchian breakouts and Bollinger/VWAP reversions establish honest benchmarks for subsequent modules.
  • No position survives the session: Forced flattening eliminates overnight gap risk and unmodeled funding costs.
  • Look for parameter plateaus: A strategy that works at only one parameter setting is a statistical fluke. True edges show stability across neighbor values.

References


Next: Module 9 — Causal Multi-Timeframe Context for Intraday Entries · Companion: Module 8 Strategies — Low-Timeframe Lab Experiments