Free learning tools Test your finance and trading knowledge before you build, buy, or automate.
Pullback series — entry techniques

Pivot Point Pullback — All Four Formula Families, the Bias Rule, and Three Worked Cases

The floor trader's arithmetic, computed in full: four formula families side by side, the open-vs-pivot bias rule, the minimum-reward filter, and three worked cases with every number verified.

Pivot points are the oldest daily-level system in trading: a handful of formulas applied to yesterday's high, low and close, producing today's reference lines. The tradition is honest about one thing most modern guides are not — pivots were originally used as targets and exit levels, not entry triggers — and this guide keeps that honesty while building the entry case properly: all four formula families computed, a bias rule tied to the session open, a minimum-reward filter that refuses cheap trades, and three worked cases with every number produced by code that asserts its own claims.

1. The Four Formula Families — Computed Side by Side

With the prior session's high \(H\), low \(L\) and close \(C\), the four families in circulation are:

Classic (floor trader):

$$P=\frac{H+L+C}{3},\quad R_1=2P-L,\quad S_1=2P-H,\quad R_2=P+(H-L),\quad S_2=P-(H-L)$$

$$R_3=H+2(P-L),\quad S_3=L-2(H-P)$$

Fibonacci:

$$R_1=P+0.382(H-L),\quad R_2=P+0.618(H-L),\quad R_3=P+1.0(H-L)$$

with supports mirrored below \(P\), and the same \(P=(H+L+C)/3\).

Woodie: \(P=(H+L+2C)/4\), with the Classic range formulas for R1/S1/R2/S2 — the doubled close weight biases the pivot toward where the market actually finished.

Camarilla: levels anchored to the close rather than the pivot: \(R_1=C+1.1(H-L)/12\), \(R_2=C+1.1(H-L)/6\), \(R_3=C+1.1(H-L)/4\), \(R_4=C+1.1(H-L)/2\), supports mirrored.

For the worked reference day (\(H=1.08600\), \(L=1.08200\), \(C=1.08450\), range 40 pips), the Classic set computes to:

LevelFormulaPrice
P(1.08600 + 1.08200 + 1.08450) / 31.08417
R12P − L = 2.16833 − 1.082001.08633
S12P − H = 2.16833 − 1.086001.08233
R2P + (H − L) = 1.08417 + 0.004001.08817
R3H + 2(P − L) = 1.08600 + 0.004331.09033
Fib R1 (for comparison)P + 0.382 × 0.004001.08570
Woodie P (for comparison)(1.08600 + 1.08200 + 2×1.08450) / 41.08425
Camarilla R1 (for comparison)1.08450 + 1.1 × 0.00400 / 121.08487

Two observations from the table. The families disagree — Fib R1 sits 6.3 pips below Classic R1, Woodie P sits 0.8 pips above Classic P, and Camarilla's levels cluster near the close by construction. And the reset assumptions matter: "prior session" means a defined timezone (we use the platform's daily bars as given, stated so the arithmetic is reproducible); traders on different daily-close conventions compute different levels from the same market. Fix the convention, then compute.

One structural property worth knowing before trading these levels: in the Classic set, R2 and higher are symmetric around P, but R1 and S1 are not. R2 sits exactly (H−L) above P and S2 exactly (H−L) below, while R1's distance above P is (P−L) and S1's distance below is (H−P) — equal only when the pivot happens to split the range in half. In the worked day, P−L = 21.7 pips and H−P = 18.3 pips, so R1 is 3.4 pips further from P than S1 is. Traders who assume "R1 and S1 are mirror images" are running arithmetic that does not exist — the asymmetry is a property of where the close sat in yesterday's range, and it is computable before the session starts.

2. The Bias Rule and the Minimum-Reward Rule

Two filters separate this entry protocol from "buy when price touches P."

The bias rule. Long setups are allowed only when the session opens above P. The open is the day's first positioning statement — where the overnight auction hands price to the active session — and a day that opens above its pivot starts with buyers in control of the reference level. In the worked case the session opens at 1.08500 against a pivot of 1.08417. The bias is also recorded and bucketed in the harness (Section 8), because a filter nobody measures is a superstition.

The minimum-reward rule. The distance from entry to TP1 (R1) must be at least \(1.0 \times\) the structural risk. This is the rule the third worked case tests: a valid trigger whose R1 sits only 0.81R away is skipped. Why refuse a setup that "looks fine"? Because a first target closer than the stop means the trade's expected payoff is capped below its cost of failure — the geometry pays less than it risks before partials even matter. The rule is a convention (the threshold is one parameter, testable), but it is written down, and Section 7 shows exactly what it costs when it skips a winner.

3. The Exact Rules

Pivot Pullback — Complete Rule Set (long side, per session)

  • Levels: computed from the prior session's H, L, C (Classic by default; all four families available). Fixed before the session; never recomputed intraday.
  • Bias: the session must open above P.
  • Touch: a bar whose low is within 0.10 × ATR14 of P and below the previous bar's low (a progressing probe).
  • Trigger: a bullish engulfing bar with close at or above P.
  • Minimum reward: R1 − entry ≥ 1.0 × risk, else skip.
  • Entry: at the trigger bar's close.
  • Stop: lowest low from the first touch through the trigger, minus max(2 pips, 0.15 × ATR14).
  • Invalidation: a close below S1 before the trigger cancels the day.
  • Targets: TP1 = R1, TP2 = R2, TP3 = R3. Partials 50/30/20. One setup per day.

4. Worked Case 1 — Valid: Every Number Computed

Synthetic labeled data, EURUSD-like H1, 24-bar sessions, 1 pip = 0.0001. Every value is computed by the module in Section 11, which asserts each claim.

BarOpenHighLowCloseRole
e01.085001.085601.084601.08540Session opens above P (1.08417) — bias set
e21.086201.086801.085801.08650Session high 1.08680
e51.084901.085001.084001.08420Touch: low 1.08400 at P
e61.084101.085201.084001.08495Trigger: engulfing close 1.08495

The trigger check in full: prior bar e5 is red (close 1.08420 < open 1.08490). Bar e6 is green (close 1.08495 > open 1.08410). Engulfing requires open of e6 ≤ close of e5: \(1.08410 \le 1.08420\) ✓. And close of e6 ≥ open of e5: \(1.08495 \ge 1.08490\) ✓. The close is above P (1.08417) ✓, the day opened above P ✓, and the touch occurred at e5 ✓. All conditions hold.

The trade arithmetic:

Classic levels: P=1.08417 R1=1.08633 R2=1.08817 R3=1.09033 S1=1.08233

ATR14 at trigger = 7.79 pips
Buffer = max(2, 0.15 x 7.79) = 2.00 pips (the 2-pip floor applies)
Stop = lowest low since touch (1.08400) - 0.000200 = 1.08380
Entry = 1.08495 (trigger close)
Risk = 1.08495 - 1.08380 = 0.001150 = 11.50 pips

TP1 (R1) = 1.08633 : 13.8 pips = 1.20R  (hit at e8)
TP2 (R2) = 1.08817 : 32.2 pips = 2.80R  (hit at e10)
TP3 (R3) = 1.09033 : 53.8 pips = 4.68R  (hit at e13)

Blended 50/30/20 = 2.38R before costs, 2.29R after 1-pip spread

Sizing ($10,000, 1% risk, EURUSD):
  cost-adjusted risk per lot = (11.50 + 1.0) x $10 = $125.00
  lots = floor(100 / 125.00 / 0.01) x 0.01 = 0.80
  money at risk = 0.80 x $125.00 = $100.00
  P&L at blended: 2.29 x $100.00 = +$228.99

Notice how the minimum-reward rule shaped this trade: TP1 (R1) sits only 13.8 pips from the entry — the pivot arithmetic places R1 a half-range away, and the range was 40 pips. At 1.20R the trade passes the filter by a thin margin, and the blended outcome (2.29R) comes mostly from R2 and R3. That is the honest profile of pivot trades: modest first targets, real runner potential, and a filter that refuses the setups where even R1 is out of reach.

5. Worked Case 2 — Failed: Through the Stop

Same levels, same trigger, same entry and stop — different continuation:

BarOpenHighLowCloseNote
f71.084951.085101.084301.08450Stall below P
f81.084501.084701.083401.08360Low 1.08340 ≤ stop 1.08380 → filled

The arithmetic of the loss: stop fill at 1.08380, entry 1.08495, so the realized loss is the full 11.50-pip risk plus the 1-pip spread — −1.09R, which at 0.80 lots is −$108.70. The failure mode is specific and worth naming: the trigger reclaimed P, then price failed to hold it, and the stop below the touch low did its job. Note that the close at 1.08360 is above S1 (1.08233) — the day is not "dead" by the invalidation rule; the trade is simply stopped, and the rule set permits no re-entry after the stop within the same session. One setup per day, one outcome per setup.

The recovery math: the account stands at $9,891.30; recovering requires +1.10% — one risk unit plus costs. At 5% risk the same event costs $543 and requires +5.7% to recover.

6. Worked Case 3 — Ambiguous: the Trade the Reward Filter Refused

The third case changes the reference day, not the rules: a session that closes at its low (C = 1.08200 = L) drags the pivot down with it — P = 1.08333, and R1 = 1.08467 sits only 13.3 pips above P. The next session opens above P, pulls back, touches, and prints a textbook engulfing trigger at 1.08370. Every entry condition is satisfied — and the minimum-reward rule kills the trade, because R1 is 9.7 pips away against a 12.0-pip risk (0.81R < 1.0R):

Reference day closed at the low: P = 1.08333, R1 = 1.08467
Trigger close 1.08370; stop 1.08250; risk 12.00 pips
TP1 (R1) = 9.7 pips = 0.81R < 1.0R -> SKIPPED

Counterfactual (min-reward off): outcome all targets, realized +2.06R
- a trade the filter refused that would have paid.

Same discipline argument as every filter in this series, with the numbers attached: the rule skipped +2.06R here, and it exists because the geometry of close-at-the-low days systematically produces cheap R1 targets that cannot cover their own risk on average. The implementation runs the filter-off variant on your data and buckets the difference — if the refused trades collectively pay, the threshold is wrong and gets lowered; if they collectively bleed, the rule stays. What is not acceptable is deciding it trade by trade, after seeing the outcome.

7. The Traditional Tension — Pivots as Targets vs Entries

The floor-trader tradition is explicit that pivot levels are used mostly as targets and exits, and this rule set honors that by placing TP1 at R1, TP2 at R2 and TP3 at R3 — the levels do their traditional job. The entry use at P is the non-traditional part, and it carries two extra requirements (the bias rule and the minimum-reward rule) precisely because it is non-traditional: the level alone is not claimed to be an entry edge; the edge claim, if any, lives in the combination of bias, touch geometry, trigger, structural stop and reward filter. The harness measures the combination, not the folklore.

There is also a set of midpoint levels the tradition recognizes but rarely names: the halfway prices between P and R1 (often labeled M+), between R1 and R2 (M++), and their mirrors below P (M−, M−−). They are treated as weaker support/resistance zones — second-order versions of the primary levels. This rule set deliberately excludes them from the trade logic: every additional level is another opportunity to anchor-shop ("it touched M+ so the setup counts"), and the primary set is already sufficient for a testable system. Midpoints can be added later as a documented variant — with their own sample, not as an excuse.

No performance claims: no win rates, no "pivots work 80% of the time" numbers — none verified, none published. The synthetic demo below is a random walk where no edge can exist; the harness reports exactly that, and your session data is the real test.

8. The Bias Hypothesis

H1 (our own, falsifiable): Pullbacks to P on sessions that open above P produce higher continuation expectancy after costs than pullbacks on sessions that open below P, consistently across walk-forward blocks. Falsification: if open-below-P setups match or beat open-above-P setups out-of-sample, the bias rule is dropped.

The harness collects every setup with the bias filter disabled, records the open-vs-P flag, and reports both buckets. On the seeded synthetic demo the buckets are populated — 24 open-above-P setups at +0.135R versus 8 open-below-P setups at −0.617R — and while the direction matches the hypothesis, the samples are noise on random data and prove nothing except that the machinery runs. The comparison matters on real session data, where the opening auction carries actual information.

9. Risk Math — the Complete Arithmetic

Position sizing with costs, for the Case 1 trade:

$$\text{Lots}=\left\lfloor\frac{E \times \text{risk\%}}{(S + C)\times V}\Big/0.01\right\rfloor \times 0.01$$

with equity \(E = \$10{,}000\), risk 1%, stop \(S = 11.50\) pips, cost \(C = 1\) pip, pip value \(V = \$10\) per lot: \(100 / (12.50 \times 10) = 0.80\) lots exactly — risking $100.00 including the spread. The outcome table for the three cases:

OutcomeRP&L
Full stop (Case 2)−1.09R−$108.70
TP1 only, then breakeven (50% booked)+0.51R+$50.65
TP1 + TP2 (80% booked, runner stopped at entry)+1.32R+$131.70
All three targets (Case 1)+2.29R+$228.99
Minimum-reward counterfactual (Case 3, filter off)+2.06R+$206.00

And expectancy:

$$E_R = p_{win}\cdot W_R - (1-p_{win})\cdot L_R$$

Illustrative inputs (hypothetical, NOT a claim):
  p = 45%   W = 2.0R   L = 1.0R
  Expectancy = 0.45 x 2.0 - 0.55 x 1.0 = +0.35R per trade

Sensitivity:
  p = 35%:  +0.05R  (thin)
  p = 30%:  -0.10R  (dead)

Breakeven win rate at W = 2.0R: 33.3% (33.6% with 1-pip spread)

One pivot-specific note for the arithmetic: with one setup per day and one session per instrument, the opportunity count is naturally capped — a pivot strategy on a single pair produces roughly 20 setups a month, not 200. The expectancy must therefore be read per trade and per month together; a +0.35R edge at 20 trades and $100 risk is +$700 expected monthly on a $10,000 account, but the same edge at 3 trades per month is a hobby.

10. Validation Protocol

The module implements the series protocol: chronological 60/20/20 split; tolerance and buffer chosen on train only; the frozen configuration evaluated on validation and test blocks; a sensitivity grid over tolerance ∈ {0.10, 0.25} × buffer ∈ {0.10, 0.15, 0.25} × ATR; the bias hypothesis test (filter off, bucketed) on the full sample; costs per trade; one setup per day; entries at the trigger close. On the seeded synthetic demo: the train grid runs positive (0.19–0.43R) purely by noise, the chosen configuration (tolerance 0.10, buffer 0.15) returns −0.01R on validation (n = 6) and −0.67R on test (n = 3), and the bias buckets split +0.135R (open above P, n = 24) versus −0.617R (open below P, n = 8). The tiny out-of-sample samples are themselves a lesson: pivot setups are rare by construction, and small-sample expectancy is not evidence.

11. The Complete Implementation

The module below imports the shared library published with the Fibonacci guide (keep both files in the same folder). It contains all four pivot families, the setup finder with the bias and minimum-reward rules, the three worked cases as literal OHLC arrays with assertions verifying every number quoted above, and the full validation harness. Run it three ways:

python pivot_pullback.py examples    # verify the three worked cases
python pivot_pullback.py validate    # synthetic walk-forward + bias buckets
python pivot_pullback.py csv EURUSD_D1.csv   # the real test: your data
#!/usr/bin/env python3
"""
pivot_pullback.py - Pivot point pullback entry: reference implementation.

Companion code for the AlgoSpecial guide "Pivot Point Pullback"
(algospecial.com/blogs/pivot-point-pullback). Imports the shared library
published with the Fibonacci guide (fib_pullback.py) - keep both files in
the same folder. Pure standard library, Python 3.7+.

PIVOT FORMULAS (all four families implemented; worked cases use Classic)
-----------------------------------------------------------------------
With prior-period H, L, C:

Classic (floor):
  P  = (H + L + C) / 3
  R1 = 2P - L        S1 = 2P - H
  R2 = P + (H - L)   S2 = P - (H - L)
  R3 = H + 2(P - L)  S3 = L - 2(H - P)

Fibonacci:
  P  = (H + L + C) / 3
  R1 = P + 0.382(H - L)   S1 = P - 0.382(H - L)
  R2 = P + 0.618(H - L)   S2 = P - 0.618(H - L)
  R3 = P + 1.000(H - L)   S3 = P - 1.000(H - L)

Woodie:
  P  = (H + L + 2C) / 4
  R1 = 2P - L        S1 = 2P - H
  R2 = P + (H - L)   S2 = P - (H - L)

Camarilla:
  R1 = C + 1.1(H - L)/12   S1 = C - 1.1(H - L)/12
  R2 = C + 1.1(H - L)/6    S2 = C - 1.1(H - L)/6
  R3 = C + 1.1(H - L)/4    S3 = C - 1.1(H - L)/4
  R4 = C + 1.1(H - L)/2    S4 = C - 1.1(H - L)/2

RULE SET (long side; sessions are fixed-length blocks of session_len bars)
--------------------------------------------------------------------------
- Reference day: prior session's H, L, C -> today's pivot levels.
- Bias: today's session must OPEN above P (the overnight/opening
  positioning is bullish). The bias flag is recorded and can be bucketed.
- Touch: a bar whose low is within tol of P (tol = 0.10 * ATR14 at the
  bar) and below the previous bar's low (a progressing probe).
- Trigger: a bullish engulfing bar with close at or above P.
- Minimum reward: the distance from entry to TP1 (R1) must be at least
  min_reward_r * risk; otherwise the setup is skipped.
- Stop: lowest low from the first touch through the trigger, minus
  buffer = max(2 pips, 0.15 * ATR14).
- Invalidation: a close below S1 before the trigger cancels the day.
- Targets: TP1 = R1, TP2 = R2, TP3 = R3 (Classic set in the worked
  cases). Partials 50/30/20. One setup per day.

WHAT THIS FILE CONTAINS
-----------------------
1. All four pivot families, the setup finder and simulation (reusing
   the shared simulator with identical partials and costs).
2. The three worked cases from the article as literal OHLC arrays with
   assertions verifying every claim (level arithmetic, bias, touch,
   trigger, minimum-reward skip, stop fill).
3. A validation harness: 60/20/20 split, walk-forward, a bias-bucket
   hypothesis test (open-above-P vs open-below-P - the article's H1)
   and a sensitivity grid over tolerance and buffer.

USAGE
-----
    python pivot_pullback.py examples    # the three worked cases
    python pivot_pullback.py validate    # synthetic harness demo
    python pivot_pullback.py csv EURUSD_H1.csv
"""

from __future__ import annotations

import sys
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple

from fib_pullback import (Bar, PIP, pips, atr_sma, is_bullish_engulfing,
                          simulate_long, metrics, synthetic_bars,
                          load_mt5_csv, size_position)


def compute_pivots(day: Sequence[Bar], method: str = "classic") -> Dict[str, float]:
    h = max(b.h for b in day)
    l = min(b.l for b in day)
    c = day[-1].c
    rng = h - l
    if method == "classic":
        p = (h + l + c) / 3.0
        return {"P": p, "R1": 2 * p - l, "S1": 2 * p - h,
                "R2": p + rng, "S2": p - rng,
                "R3": h + 2 * (p - l), "S3": l - 2 * (h - p)}
    if method == "fib":
        p = (h + l + c) / 3.0
        return {"P": p, "R1": p + 0.382 * rng, "S1": p - 0.382 * rng,
                "R2": p + 0.618 * rng, "S2": p - 0.618 * rng,
                "R3": p + rng, "S3": p - rng}
    if method == "woodie":
        p = (h + l + 2 * c) / 4.0
        return {"P": p, "R1": 2 * p - l, "S1": 2 * p - h,
                "R2": p + rng, "S2": p - rng}
    if method == "camarilla":
        return {"R1": c + 1.1 * rng / 12, "S1": c - 1.1 * rng / 12,
                "R2": c + 1.1 * rng / 6, "S2": c - 1.1 * rng / 6,
                "R3": c + 1.1 * rng / 4, "S3": c - 1.1 * rng / 4,
                "R4": c + 1.1 * rng / 2, "S4": c - 1.1 * rng / 2}
    raise ValueError("unknown method: %s" % method)


@dataclass
class PivotSetup:
    day_start: int
    entry_idx: int
    entry: float
    stop: float
    tp1: float
    tp2: float
    tp3: float
    levels: Dict[str, float]
    bias: bool

    @property
    def risk(self) -> float:
        return self.entry - self.stop


def find_pivot_setup(bars: Sequence[Bar], day_start: int, levels: Dict[str, float],
                     session_len: int = 24, tol_atr: float = 0.10,
                     buffer_mult: float = 0.15, period: int = 14,
                     min_reward_r: float = 1.0,
                     require_bias: bool = True) -> Optional[PivotSetup]:
    """Scan one session for a pullback to P; first valid trigger wins."""
    p = levels["P"]
    bias = bars[day_start].o > p
    touched = False
    min_low: Optional[float] = None
    end = min(len(bars), day_start + session_len)
    for i in range(day_start, end):
        bar, prev = bars[i], bars[i - 1]
        a = atr_sma(bars, i, period)
        if a is None:
            continue
        if bar.c < levels["S1"]:             # too deep: the day is dead for longs
            return None
        tol = tol_atr * a
        if bar.l <= p + tol and bar.l < prev.l:
            touched = True
        if touched:
            min_low = bar.l if min_low is None else min(min_low, bar.l)
        if touched and is_bullish_engulfing(prev, bar) and bar.c >= p:
            if require_bias and not bias:
                continue
            buffer = max(2 * PIP, buffer_mult * a)
            stop = min_low - buffer
            if stop >= bar.c:
                continue
            risk = bar.c - stop
            if levels["R1"] - bar.c < min_reward_r * risk:
                continue                     # minimum-reward rule
            return PivotSetup(day_start, i, bar.c, stop,
                              levels["R1"], levels["R2"], levels["R3"],
                              dict(levels), bias)
    return None


def collect_pivot_results(bars: Sequence[Bar], session_len: int = 24,
                          method: str = "classic", tol_atr: float = 0.10,
                          buffer_mult: float = 0.15, spread_pips: float = 1.0,
                          min_reward_r: float = 1.0,
                          require_bias: bool = True) -> List[Tuple[PivotSetup, object]]:
    out: List[Tuple[PivotSetup, object]] = []
    n_days = len(bars) // session_len
    for d in range(1, n_days):
        day_start = d * session_len
        levels = compute_pivots(bars[day_start - session_len:day_start], method)
        setup = find_pivot_setup(bars, day_start, levels, session_len,
                                 tol_atr, buffer_mult, min_reward_r=min_reward_r,
                                 require_bias=require_bias)
        if setup is None:
            continue
        res = simulate_long(bars, setup, spread_pips)
        out.append((setup, res))
    return out


def run_pivot_strategy(bars: Sequence[Bar], session_len: int = 24,
                       method: str = "classic", tol_atr: float = 0.10,
                       buffer_mult: float = 0.15, spread_pips: float = 1.0,
                       min_reward_r: float = 1.0,
                       require_bias: bool = True) -> List:
    return [res for (_, res) in collect_pivot_results(
        bars, session_len, method, tol_atr, buffer_mult, spread_pips,
        min_reward_r, require_bias)]


# --------------------------------------------------------------------------
# The three worked cases (literal arrays; assertions verify the article)
# --------------------------------------------------------------------------

DAY1_A = [
    Bar(1.08300, 1.08320, 1.08270, 1.08280),
    Bar(1.08280, 1.08300, 1.08250, 1.08260),
    Bar(1.08260, 1.08280, 1.08230, 1.08240),
    Bar(1.08240, 1.08260, 1.08220, 1.08230),
    Bar(1.08230, 1.08250, 1.08210, 1.08220),
    Bar(1.08220, 1.08240, 1.08200, 1.08215),  # day low 1.08200
    Bar(1.08215, 1.08240, 1.08205, 1.08230),
    Bar(1.08230, 1.08250, 1.08210, 1.08240),
    Bar(1.08240, 1.08280, 1.08220, 1.08270),
    Bar(1.08270, 1.08320, 1.08260, 1.08310),
    Bar(1.08310, 1.08370, 1.08300, 1.08360),
    Bar(1.08360, 1.08420, 1.08350, 1.08410),
    Bar(1.08410, 1.08470, 1.08400, 1.08460),
    Bar(1.08460, 1.08520, 1.08450, 1.08510),
    Bar(1.08510, 1.08570, 1.08500, 1.08560),
    Bar(1.08560, 1.08600, 1.08550, 1.08590),  # day high 1.08600
    Bar(1.08590, 1.08600, 1.08560, 1.08570),
    Bar(1.08570, 1.08580, 1.08540, 1.08550),
    Bar(1.08550, 1.08560, 1.08520, 1.08530),
    Bar(1.08530, 1.08540, 1.08500, 1.08510),
    Bar(1.08510, 1.08520, 1.08480, 1.08490),
    Bar(1.08490, 1.08500, 1.08460, 1.08470),
    Bar(1.08470, 1.08480, 1.08440, 1.08450),
    Bar(1.08450, 1.08460, 1.08420, 1.08450),  # close 1.08450
]
DAY2_VALID = [
    Bar(1.08500, 1.08560, 1.08460, 1.08540),  # e0 opens above P
    Bar(1.08540, 1.08640, 1.08520, 1.08620),
    Bar(1.08620, 1.08680, 1.08580, 1.08650),  # e2 day high 1.08680
    Bar(1.08650, 1.08670, 1.08540, 1.08570),
    Bar(1.08570, 1.08590, 1.08460, 1.08490),
    Bar(1.08490, 1.08500, 1.08400, 1.08420),  # e5 touch (low 1.08400)
    Bar(1.08410, 1.08520, 1.08400, 1.08495),  # e6 trigger
    Bar(1.08495, 1.08560, 1.08470, 1.08540),
    Bar(1.08540, 1.08650, 1.08510, 1.08630),  # TP1 1.086333 hit
    Bar(1.08630, 1.08720, 1.08590, 1.08700),
    Bar(1.08700, 1.08830, 1.08670, 1.08810),  # TP2 1.088167 hit
    Bar(1.08810, 1.08900, 1.08770, 1.08880),
    Bar(1.08880, 1.08980, 1.08840, 1.08960),
    Bar(1.08960, 1.09050, 1.08920, 1.09030),  # TP3 1.090333 hit
    Bar(1.09030, 1.09090, 1.08990, 1.09070),
    Bar(1.09070, 1.09120, 1.09030, 1.09100),
    Bar(1.09100, 1.09140, 1.09060, 1.09120),
    Bar(1.09120, 1.09160, 1.09080, 1.09140),
    Bar(1.09140, 1.09180, 1.09100, 1.09160),
    Bar(1.09160, 1.09200, 1.09120, 1.09180),
    Bar(1.09180, 1.09220, 1.09140, 1.09200),
    Bar(1.09200, 1.09240, 1.09160, 1.09220),
    Bar(1.09220, 1.09260, 1.09180, 1.09240),
    Bar(1.09240, 1.09280, 1.09200, 1.09260),
]
FAILED_CONT = [
    Bar(1.08495, 1.08510, 1.08430, 1.08450),
    Bar(1.08450, 1.08470, 1.08340, 1.08360),  # low <= stop -> filled
]
DAY1_B = DAY1_A[:16] + [
    Bar(1.08590, 1.08600, 1.08560, 1.08570),
    Bar(1.08570, 1.08580, 1.08490, 1.08510),
    Bar(1.08510, 1.08520, 1.08420, 1.08440),
    Bar(1.08440, 1.08450, 1.08350, 1.08370),
    Bar(1.08370, 1.08380, 1.08290, 1.08310),
    Bar(1.08310, 1.08320, 1.08240, 1.08260),
    Bar(1.08260, 1.08270, 1.08210, 1.08220),
    Bar(1.08220, 1.08230, 1.08200, 1.08200),  # close at the low 1.08200
]
DAY2_AMBIG = [
    Bar(1.08360, 1.08440, 1.08340, 1.08420),  # a0 opens above P
    Bar(1.08420, 1.08480, 1.08380, 1.08460),  # a1 day high 1.08480
    Bar(1.08460, 1.08470, 1.08340, 1.08360),
    Bar(1.08360, 1.08380, 1.08270, 1.08290),  # a3 touch (low 1.08270)
    Bar(1.08280, 1.08390, 1.08270, 1.08370),  # a4 trigger, TP1 only 0.81R
    Bar(1.08370, 1.08460, 1.08340, 1.08440),
    Bar(1.08440, 1.08530, 1.08410, 1.08510),  # TP1 1.084667 hit
    Bar(1.08510, 1.08600, 1.08480, 1.08580),
    Bar(1.08580, 1.08680, 1.08550, 1.08660),
    Bar(1.08660, 1.08760, 1.08630, 1.08740),  # TP2 1.087333 hit
    Bar(1.08740, 1.08830, 1.08710, 1.08810),
    Bar(1.08810, 1.08890, 1.08780, 1.08870),  # TP3 1.088667 hit
    Bar(1.08870, 1.08920, 1.08840, 1.08900),
    Bar(1.08900, 1.08950, 1.08870, 1.08930),
    Bar(1.08930, 1.08980, 1.08900, 1.08960),
    Bar(1.08960, 1.09000, 1.08930, 1.08980),
    Bar(1.08980, 1.09020, 1.08950, 1.09000),
    Bar(1.09000, 1.09040, 1.08970, 1.09020),
    Bar(1.09020, 1.09060, 1.08990, 1.09040),
    Bar(1.09040, 1.09080, 1.09010, 1.09060),
    Bar(1.09060, 1.09100, 1.09030, 1.09080),
    Bar(1.09080, 1.09120, 1.09050, 1.09100),
    Bar(1.09100, 1.09140, 1.09070, 1.09120),
    Bar(1.09120, 1.09160, 1.09090, 1.09140),
]


def verify_worked_cases() -> None:
    valid = DAY1_A + DAY2_VALID
    failed = DAY1_A + DAY2_VALID[:7] + FAILED_CONT
    ambig = DAY1_B + DAY2_AMBIG

    levels = compute_pivots(DAY1_A, "classic")
    assert abs(levels["P"] - 1.0841667) < 1e-6
    assert abs(levels["R1"] - 1.0863333) < 1e-6
    assert abs(levels["S1"] - 1.0823333) < 1e-6
    fib = compute_pivots(DAY1_A, "fib")
    assert abs(fib["R1"] - (levels["P"] + 0.382 * 0.0040)) < 1e-9
    woodie = compute_pivots(DAY1_A, "woodie")
    assert abs(woodie["P"] - 1.08425) < 1e-9
    cam = compute_pivots(DAY1_A, "camarilla")
    assert abs(cam["R1"] - (1.0845 + 1.1 * 0.0040 / 12)) < 1e-9

    setup = find_pivot_setup(valid, 24, levels)
    assert setup is not None and setup.entry_idx == 30, "trigger at e6 (idx 30)"
    assert abs(setup.entry - 1.08495) < 1e-12
    assert setup.bias, "day must open above P"
    res_v = simulate_long(valid, setup, spread_pips=1.0)
    assert res_v.outcome == "all_targets" and len(res_v.tp_hits) == 3

    setup_f = find_pivot_setup(failed, 24, levels)
    assert setup_f is not None
    res_f = simulate_long(failed, setup_f, spread_pips=1.0)
    assert res_f.outcome == "stop"

    levels_b = compute_pivots(DAY1_B, "classic")
    assert abs(levels_b["P"] - 1.0833333) < 1e-6
    assert find_pivot_setup(ambig, 24, levels_b) is None, "min-reward must skip"
    cf = collect_pivot_results(ambig, min_reward_r=0.0)
    assert len(cf) == 1 and cf[0][1].outcome == "all_targets"


def print_worked_cases() -> None:
    valid = DAY1_A + DAY2_VALID
    failed = DAY1_A + DAY2_VALID[:7] + FAILED_CONT
    ambig = DAY1_B + DAY2_AMBIG

    levels = compute_pivots(DAY1_A, "classic")
    setup = find_pivot_setup(valid, 24, levels)
    a_entry = atr_sma(valid, setup.entry_idx)
    buffer = max(2 * PIP, 0.15 * a_entry)

    print("=" * 66)
    print("WORKED CASE 1 - VALID (1 pip = 0.0001)")
    print("=" * 66)
    print("Reference day: H=1.08600 L=1.08200 C=1.08450")
    print("Classic: P=%.5f R1=%.5f R2=%.5f R3=%.5f"
          % (levels["P"], levels["R1"], levels["R2"], levels["R3"]))
    print("S1=%.5f (invalidation)" % levels["S1"])
    print("Day 2 opens 1.08500 > P -> bullish bias")
    print("Touch e5 low 1.08400 <= P + tol; trigger e6 engulfing close %.5f"
          % setup.entry)
    print("ATR14 at trigger = %.2f pips; buffer = %.2f pips"
          % (pips(a_entry), pips(buffer)))
    print("Stop = lowest low since touch (1.08400) - buffer = %.5f" % setup.stop)
    print("Risk = %.2f pips" % pips(setup.risk))
    for name, tp in (("TP1 (R1)", setup.tp1), ("TP2 (R2)", setup.tp2), ("TP3 (R3)", setup.tp3)):
        print("%s = %.5f : %.1f pips = %.2fR" % (name, tp, pips(tp - setup.entry),
                                                 pips(tp - setup.entry) / pips(setup.risk)))
    res = simulate_long(valid, setup, spread_pips=1.0)
    blended = 0.5 * pips(setup.tp1 - setup.entry) / pips(setup.risk) + \
              0.3 * pips(setup.tp2 - setup.entry) / pips(setup.risk) + \
              0.2 * pips(setup.tp3 - setup.entry) / pips(setup.risk)
    print("Blended 50/30/20 = %.2fR before costs, %.2fR after 1-pip spread"
          % (blended, res.realized_r))
    lots, risk_money = size_position(10000.0, 1.0, pips(setup.risk))
    print("Sizing: lots = %.2f; risk+cost = $%.2f; P&L = $%.2f"
          % (lots, risk_money, res.realized_r * risk_money))
    print()

    print("=" * 66)
    print("WORKED CASE 2 - FAILED (trigger, then through the stop)")
    print("=" * 66)
    setup_f = find_pivot_setup(failed, 24, levels)
    res_f = simulate_long(failed, setup_f, spread_pips=1.0)
    print("Same entry %.5f, stop %.5f, risk %.2f pips"
          % (setup_f.entry, setup_f.stop, pips(setup_f.risk)))
    print("Next+1 bar low 1.08340 <= stop %.5f -> filled at stop" % setup_f.stop)
    print("Outcome: %s at bar %d; realized %.2fR; P&L $%.2f"
          % (res_f.outcome, res_f.exit_idx, res_f.realized_r, res_f.realized_r * risk_money))
    print()

    print("=" * 66)
    print("WORKED CASE 3 - AMBIGUOUS (minimum-reward rule skips the trade)")
    print("=" * 66)
    levels_b = compute_pivots(DAY1_B, "classic")
    assert find_pivot_setup(ambig, 24, levels_b) is None
    cf = collect_pivot_results(ambig, min_reward_r=0.0)
    s_cf, r_cf = cf[0]
    tp1_r = pips(s_cf.tp1 - s_cf.entry) / pips(s_cf.risk)
    print("Reference day closed at the low: P=%.5f R1=%.5f" % (levels_b["P"], levels_b["R1"]))
    print("Trigger close %.5f; TP1 (R1) only %.2fR away < 1.0R -> SKIPPED"
          % (s_cf.entry, tp1_r))
    print("Counterfactual (min-reward off): entry %.5f, stop %.5f, risk %.2f pips"
          % (s_cf.entry, s_cf.stop, pips(s_cf.risk)))
    print("Counterfactual result: %s; realized %.2fR"
          % (r_cf.outcome, r_cf.realized_r))
    print()


# --------------------------------------------------------------------------
# Validation harness
# --------------------------------------------------------------------------

def walk_forward_report(bars: Sequence[Bar]) -> None:
    n = len(bars)
    i60, i80 = int(n * 0.6), int(n * 0.8)
    train, val, test = bars[:i60], bars[i60:i80], bars[i80:]
    print("=" * 66)
    print("VALIDATION (synthetic bars, seed 7) - machinery demo only")
    print("=" * 66)
    print("Split: train %d, validation %d, test %d bars" % (len(train), len(val), len(test)))

    print("\nSensitivity grid on TRAIN (expectancy R/trade):")
    print("%-10s" % "tol_atr", end="")
    for b in (0.10, 0.15, 0.25):
        print("buffer=%.2f " % b, end="")
    print()
    best = (None, -999.0)
    for tol in (0.10, 0.25):
        print("%-10.2f" % tol, end="")
        for bm in (0.10, 0.15, 0.25):
            m = metrics(run_pivot_strategy(train, tol_atr=tol, buffer_mult=bm))
            e = m.get("expectancy_r", 0.0)
            if e > best[1]:
                best = ((tol, bm), e)
            print("%9.3f " % e, end="")
        print()
    chosen = best[0] if best[0] else (0.10, 0.15)
    print("Chosen on train: tol_atr=%.2f buffer=%.2f" % chosen)

    for name, seg in (("validation", val), ("test", test)):
        m = metrics(run_pivot_strategy(seg, tol_atr=chosen[0], buffer_mult=chosen[1]))
        if m["n"] == 0:
            print("%s: no setups" % name)
            continue
        print("%s: n=%d win=%.0f%% avg_win=%.2fR avg_loss=%.2fR expectancy=%.3fR PF=%.2f maxDD=%.2fR"
              % (name, m["n"], 100 * m["win_rate"], m["avg_win_r"], m["avg_loss_r"],
                 m["expectancy_r"], m["profit_factor"], m["max_dd_r"]))

    print("\nBias hypothesis test (bias filter OFF, buckets by open-vs-P):")
    pairs = collect_pivot_results(bars, tol_atr=chosen[0], buffer_mult=chosen[1],
                                  require_bias=False)
    with_bias = metrics([res for (s, res) in pairs if s.bias])
    against = metrics([res for (s, res) in pairs if not s.bias])
    print("open above P (bias): n=%d expectancy=%.3fR" % (with_bias["n"], with_bias.get("expectancy_r", 0.0)))
    print("open below P      : n=%d expectancy=%.3fR" % (against["n"], against.get("expectancy_r", 0.0)))
    print("(Synthetic data proves the harness runs; it cannot establish an edge.)")


def main() -> None:
    mode = sys.argv[1] if len(sys.argv) > 1 else "examples"
    if mode == "examples":
        verify_worked_cases()
        print_worked_cases()
    elif mode == "validate":
        walk_forward_report(synthetic_bars(3000, seed=7))
    elif mode == "csv" and len(sys.argv) > 2:
        bars = load_mt5_csv(sys.argv[2])
        print("Loaded %d bars from %s" % (len(bars), sys.argv[2]))
        walk_forward_report(bars)
    else:
        print(__doc__)


if __name__ == "__main__":
    main()

12. Failure Modes Specific to Pivot Trading

Recomputing levels intraday. The pivot is yesterday's verdict on today's reference. Recomputing it mid-session ("the real pivot is where price keeps bouncing") is anchor shopping with a formula attached. Compute before the session, freeze, trade.

Formula family roulette. Classic, Fibonacci, Woodie and Camarilla disagree by design — Classic R1 vs Fib R1 differed by 6.3 pips in the worked day. Switching families after a losing trade to get a "better" level is the same disease as switching anchors. Pick one, test it, keep it.

Timezone neglect. "Prior session" depends on the daily close convention; two brokers' daily bars produce different H/L/C from the same market. Fix the convention, state it in your journal, and never compare backtests across brokers without checking it.

Trading P without the trigger. The pivot is a location; the engulfing close is permission. Entries at touches have no confirmation moment, and their statistics describe nothing. The touch arms; the close fires.

Ignoring the reward arithmetic. R1 sits wherever the formula puts it — on close-at-the-low days it can be closer than the stop (Case 3: 0.81R). Taking those trades "because the setup is valid" trades geometry against you; the minimum-reward filter exists to refuse them systematically.

Forgetting the opportunity cost of one-per-day. Pivots are a low-frequency system; forcing multiple entries per session to "make the day worth it" breaks the one-setup rule and fills the sample with correlated copies of the same idea. The count is the strategy's honest frequency — trade the frequency you have.

13. FAQ

How are pivot points calculated?

Classic: P=(H+L+C)/3, R1=2P−L, S1=2P−H, R2=P+(H−L), S2=P−(H−L), R3=H+2(P−L), S3=L−2(H−P). The worked day (H=1.08600, L=1.08200, C=1.08450) gives P=1.08417, R1=1.08633, S1=1.08233. Fib, Woodie and Camarilla sets are computed in the same table and the code.

What is the bias rule?

Long setups require the session to open above P (worked case: open 1.08500 vs P 1.08417). The flag is recorded and bucketed so the filter's value is measured, not assumed — the demo shows +0.135R above P vs −0.617R below (n=24/8, synthetic noise).

Why skip trades with TP1 under 1R?

Because R1 closer than the stop means the geometry pays less than it risks. Case 3: TP1 0.81R, skipped, counterfactual +2.06R. The filter costs winners sometimes — it is a written, measured convention, not a per-trade decision.

Are pivots entries or targets?

Traditionally targets/exits — and TP1/TP2/TP3 here are R1/R2/R3. The entry use at P is non-traditional and carries the extra bias and reward filters because of it. The harness measures the combination.

Where does the stop go?

Below the lowest low from first touch through the trigger, minus max(2 pips, 0.15×ATR14): 1.08380 vs entry 1.08495 (11.50 pips) in the worked case. A close below S1 before the trigger cancels the day.

Which formula family is best?

None is demonstrably superior; the families differ by design and the code ships all four so you can compare on your data. What matters is fixing the formula and computing levels before the session.

Do pivot levels work on intraday timeframes?

The levels are daily by construction, but intraday traders use them as the day's map: the worked session's pullback happened over five H1 bars, with the stop and targets all set by daily arithmetic. The frequency scales with the entry timeframe, not the level timeframe — H1 entries on D1 pivots are the standard configuration. Weekly and monthly pivots exist by the same formulas applied to weekly/monthly HLC, with proportionally wider levels and stops.

Continue the Series

Trade Structure With Live Tools

Zones, structure breaks and liquidity levels across 50+ symbols — the concepts in this series, computed live on your charts.

Browse MT5 Tools Live Signals
🚀 Invite friends — Earn $5 You both get $5 credit on Go Ad · opencode.ai AI-Powered Coding Agent — Try Free Build apps, fix bugs & ship faster with opencode. Get $5 free credit when you join. × Ad · quo.com QuoPhone — $20 Visa Gift Card Free Sign up to Quo, subscribe 3 months, get a $20 Visa gift card. Atif's referral gift for you. ×