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:
| Level | Formula | Price |
|---|---|---|
| P | (1.08600 + 1.08200 + 1.08450) / 3 | 1.08417 |
| R1 | 2P − L = 2.16833 − 1.08200 | 1.08633 |
| S1 | 2P − H = 2.16833 − 1.08600 | 1.08233 |
| R2 | P + (H − L) = 1.08417 + 0.00400 | 1.08817 |
| R3 | H + 2(P − L) = 1.08600 + 0.00433 | 1.09033 |
| Fib R1 (for comparison) | P + 0.382 × 0.00400 | 1.08570 |
| Woodie P (for comparison) | (1.08600 + 1.08200 + 2×1.08450) / 4 | 1.08425 |
| Camarilla R1 (for comparison) | 1.08450 + 1.1 × 0.00400 / 12 | 1.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.
| Bar | Open | High | Low | Close | Role |
|---|---|---|---|---|---|
| e0 | 1.08500 | 1.08560 | 1.08460 | 1.08540 | Session opens above P (1.08417) — bias set |
| e2 | 1.08620 | 1.08680 | 1.08580 | 1.08650 | Session high 1.08680 |
| e5 | 1.08490 | 1.08500 | 1.08400 | 1.08420 | Touch: low 1.08400 at P |
| e6 | 1.08410 | 1.08520 | 1.08400 | 1.08495 | Trigger: 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:
| Bar | Open | High | Low | Close | Note |
|---|---|---|---|---|---|
| f7 | 1.08495 | 1.08510 | 1.08430 | 1.08450 | Stall below P |
| f8 | 1.08450 | 1.08470 | 1.08340 | 1.08360 | Low 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.
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:
| Outcome | R | P&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
- Pullback Trading Strategy — The Complete Guide (pillar)
- Fibonacci Pullback Strategy — Exact Rules and Three Worked Cases
- VWAP Pullback Strategy — Session Math and the Extension Gate
- Trendline Pullback Trading — Pivot-Fit Algorithm
- All Pullback Guides
Trade Structure With Live Tools
Zones, structure breaks and liquidity levels across 50+ symbols — the concepts in this series, computed live on your charts.
Go from article to implementation
Read the proof, then test the idea with calculators, free tools, case studies and a fixed-price build path.
Go deeper before you build, download or automate
Hand-picked engineering articles, indicator breakdowns and trading-system tools so visitors keep exploring instead of leaving after one page.
Pine Script to MT4/MT5 Conversion Planner
Analyze Pine strategies, repainting risk, TradingView alert bridges, MTF synchronization and the correct MQL4/MQL5 build path before paying for conversion.
Open planner
3-in-1 MT5 Indicator Bundle
12H Bias Engine, Smart Money Institutional Flow Dashboard and D1/H1 pending-signal logic with MQ5 + EX5 source included via Etsy.
MT5 Bank Orders Institutional Flow Dashboard
Supply/demand zones, BOS/CHoCH, liquidity levels, FVG, equal highs/lows and confirmed trade-plan logic explained with EX5 download.
MT5 Asia London NY Liquidity Engine
Asia range, London raid and New York continuation/reversal planning with internal session backtest stats and entry/SL/TP dashboard.
Interactive Brokers RSI Pair Trader
Tkinter desktop trading workflow, TWS API connection handling, RSI pair logic, risk controls and custom IBKR development positioning.
XAUUSD M30 CSV Data For Backtesting
Use clean MT5 historical data for Python backtests, strategy validation, spread checks and realistic gold EA research workflows.