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

Fibonacci Pullback Strategy — Exact Rules, the Level Math, and Three Worked Cases

The most-drawn tool in trading, specified like an engineer would: a deterministic swing-selection algorithm, the golden-ratio mathematics computed line by line, and three worked cases — valid, failed, and ambiguous — with arithmetic you can check against the bars.

The Fibonacci retracement is the most widely drawn tool in trading and the most poorly specified one. Ask ten traders to mark the same chart and you will get ten different level sets, because every guide says "draw it from the swing low to the swing high" and none of them defines a swing. This guide removes the ambiguity entirely: an exact swing-selection algorithm, exact trigger rules, an exact stop, exact targets, three worked cases with every number computed, and a complete Python implementation that verifies its own claims. No mysticism about the golden ratio — and no pretending the tool is more than it is.

1. What the Tool Is — and What It Is Not

A Fibonacci retracement divides the vertical distance of a price move into proportional levels using ratios derived from the Fibonacci sequence. After an impulse move from a low L to a high H, the levels are placed at fractions of the range measured down from H (for an up-move). Traders watch the 23.6%, 38.2%, 50%, 61.8% and 78.6% depths; the 61.8% level corresponds to the golden ratio, and the band between 50% and 61.8% is conventionally called the golden zone.

Now the honesty section, because it changes how you should use the tool. Extensive backtests of Fibonacci retracements over thousands of stocks found that the 38%, 50% and 62% depths were no likelier to appear than any other retracement value — the levels are not natural laws of price. The ratios themselves are human-made conventions, and if markets respond to them at all, a large part of that response is reflexive: enough participants watch the same levels that order flow concentrates around them. That is a real effect, but it is a convention effect, not a cosmic one.

So why trade them? Three defensible reasons, none of which require believing the ratio is magic:

One: a predefined location. The zone is marked before the trade, not during it. You are not deciding where to enter while price is moving — the decision was made when the leg qualified. That converts a judgment call into a waiting game, and waiting games are winnable.

Two: a built-in invalidation. The 0.786 level gives the setup a structural failure point that is independent of your feelings. Beyond it, the move is no longer behaving like a retracement, and the rule says so without negotiation.

Three: testability. Because the rules are Boolean, the strategy can be coded, backtested, and compared against a baseline. A tool you can falsify is worth more than a tool you can only believe. Section 9 shows exactly how to run that falsification test on your own data.

What it is not: not a signal generator (the trigger does that job), not a reversal detector (0.786 hands that job to structure), and not a prediction machine. It is a pullback location tool inside an established trend — nothing more, and nothing less than that is needed.

2. The Mathematics — Powers of the Golden Ratio

The retracement ratios are not arbitrary decimals. With the golden ratio \(\varphi=\frac{1+\sqrt{5}}{2}\approx 1.618034\), the level set is built from powers of its inverse:

$$\varphi^{-1}=0.618034,\qquad \varphi^{-2}=0.381966,\qquad \varphi^{-3}=0.236068,\qquad \sqrt{\varphi^{-1}}=0.786151$$

So 61.8% is \(\varphi^{-1}\), 38.2% is \(\varphi^{-2}\), 23.6% is \(\varphi^{-3}\), and 78.6% is the square root of \(\varphi^{-1}\). The 50% level, note carefully, is not a Fibonacci ratio at all — it is half the range, included by convention because midpoints matter to the human eye. Anyone who tells you 50% is a Fibonacci number is repeating marketing, not mathematics.

For an impulse leg from low \(L\) to high \(H\), the retracement level at depth \(r\) is:

$$P_r = H - r\,(H-L)$$

and the extension target at multiple \(e>1\), measured from the leg origin, is:

$$P_e = L + e\,(H-L)$$

The alternative extension convention you will see elsewhere — "project 61.8% of the range beyond the high" — is algebraically identical: \(H + (e-1)(H-L) = L + e(H-L)\) because \(H = L + (H-L)\). The two conventions coincide exactly; there is no discrepancy to argue about.

For our worked leg (\(L=1.08000\), \(H=1.09500\), range 150.0 pips), the arithmetic produces the level set used throughout this guide:

LevelComputationPrice
23.6%1.09500 − 0.236 × 0.015001.09146
38.2%1.09500 − 0.382 × 0.015001.08927
50.0%1.09500 − 0.500 × 0.015001.08750
61.8%1.09500 − 0.618 × 0.015001.08573
78.6%1.09500 − 0.786 × 0.015001.08321
127.2% ext1.08000 + 1.272 × 0.015001.09908
161.8% ext1.08000 + 1.618 × 0.015001.10427

The golden zone in this series is the band from 1.08573 to 1.08750 — the 0.500 to 0.618 depths. It is the only retracement area where entries are permitted, and even inside it, a touch is not a trade. That brings us to the two problems every Fibonacci guide solves badly: which swing, and what trigger.

3. Swing Selection — the Deterministic Algorithm

Anchor ambiguity is where Fibonacci trading quietly dies. The same chart offers a dozen defensible "swing highs", and each choice produces a different level set — which means the trader, consciously or not, can choose the anchors that justify the trade they already want. This is called anchor shopping, and it is curve-fitting in real time.

Our rule set eliminates the choice with a published algorithm, identical to the pivot definition used across this series:

Pivots. A pivot low is a bar whose low is strictly lower than the lows of the 3 bars on each side (L=3, R=3). A pivot high is the mirror. A pivot becomes usable only 3 bars after it forms — the confirmation delay that guarantees no repainting.

The leg. A qualified leg is a confirmed pivot low followed by the next confirmed pivot high, with one extra condition: the leg's range must be at least \(2\times\text{ATR}_{14}\) measured at the confirmation bar. The ATR filter removes Fibonacci drawings on noise — a 15-pip wiggle is not an impulse leg on EURUSD H1, and treating it as one produces meaningless levels inside the spread.

The anchors. The leg's low and high are the wick extremes of those two pivots, and the levels are computed once, at confirmation. The anchors never move again. If price later makes a higher high, that is a new leg with a new (higher) qualification bar — not a reason to redraw the old one.

In the worked data used throughout this guide, the algorithm finds exactly one qualified leg: low at bar B3 (1.08000), high at bar B8 (1.09500), confirmed at bar B11 where ATR14 equals 26.64 pips. The qualification check is: \(150.0 \ge 2 \times 26.64 = 53.29\) pips — comfortably qualified. Notice the timing cost of honesty: the leg only becomes tradeable at B11, three bars after the high, because the pivot is not knowable before then. Backtests that "catch" the top of the leg are repainting.

4. Anchor Sensitivity — What Discipline Actually Buys

Before the rules, a demonstration of why the algorithm matters. Here is the same structure with four different anchor choices a reasonable trader might make — all computed by the verification code in Section 12:

Anchor pairRange (pips)0.618 level0.786 levelStop shift vs our rule
Wick-to-wick (our rule)150.01.085731.083210.0 pips
Body low (B3 open)142.01.086221.08384+6.3 pips tighter
Prior minor low (B2)144.01.086101.08368+4.7 pips tighter
Minor swing high (B7)120.01.084581.08257−6.4 pips wider

Read the last column carefully, because it is the whole argument. The anchor choice is not cosmetic — it silently moves your stop by up to 6.4 pips, which at a fixed risk budget changes your position size by roughly 15%, and therefore changes every outcome you will ever record. Two traders with identical risk rules, identical charts and identical discipline will produce different P&L purely because they anchored differently. Choosing anchors once, by written rule, is therefore a risk-management decision. The body-low anchor gives a tighter stop — and places it inside the noise band where it will be swept first. The minor-high anchor gives a wider stop on a smaller range — and a lower-probability leg. Our wick-to-wick rule is not the only defensible choice; it is the one that is fixed in advance, which is what makes the results mean something.

5. The Exact Rules

Fibonacci Pullback — Complete Rule Set (long side)

  • Context: a qualified impulse leg — confirmed pivot low → next confirmed pivot high, range ≥ 2 × ATR14 at the high's confirmation bar. Anchors fixed at confirmation; no re-anchoring.
  • Setup: price retraces into the golden zone (0.500–0.618 depths of the leg). The zone is armed when any bar's low reaches the zone top; it stays armed while the leg is valid.
  • Trigger: a bullish engulfing bar (prior bar red; current bar green; open ≤ prior close; close ≥ prior open) whose close lands inside the zone.
  • Entry: at the trigger bar's close (market; costs modeled separately).
  • Stop: 0.786 level − buffer, where buffer = max(2 pips, 0.15 × ATR14 at the trigger bar).
  • Targets: TP1 = leg high; TP2 = 1.272 extension; TP3 = 1.618 extension. Partial exits 50% / 30% / 20%.
  • Invalidation: a close below the 0.786 level. Once invalidated, no re-entry on the same leg; wait for a new qualified leg.

Two clarifications that prevent most of the losses this setup can produce. First, the trigger's close must be inside the zone — a wick into the zone followed by a close back above it is not an entry, because the close is the only price that represents a decision the market actually made at that level. Second, the stop is structural, not arithmetic comfort: it sits below 0.786 with a volatility-scaled buffer, and if that produces a wide stop, the position size shrinks — the stop is never tightened to keep the lot size up.

6. Worked Case 1 — Valid: Every Number Computed

The data is synthetic (labeled bars, EURUSD-like H1, 1 pip = 0.0001), and every value below is computed by the Python module in Section 12, which asserts each claim. The pullback bars are:

BarOpenHighLowCloseRole
B31.080801.081001.080001.08090Leg low (pivot, confirmed at B6)
B81.091801.095001.091001.09460Leg high (pivot, confirmed at B11)
B101.091201.091501.086801.08680Retracement enters the zone
B111.086501.086801.085601.08590Red bar; low pierces 0.618 by 1.3 pips
B121.085801.087201.085501.08690Trigger: engulfing close inside zone

The trigger check, in full: prior bar B11 is red (close 1.08590 < open 1.08650). Bar B12 is green (close 1.08690 > open 1.08580). Engulfing requires open of B12 ≤ close of B11: \(1.08580 \le 1.08590\) ✓. And close of B12 ≥ open of B11: \(1.08690 \ge 1.08650\) ✓. The close 1.08690 lies inside the zone [1.08573, 1.08750] ✓. All three conditions hold; the setup fires at B12.

The trade arithmetic, step by step:

ATR14 at trigger (B12) = 26.43 pips
Buffer = max(2, 0.15 x 26.43) = 3.96 pips
Stop   = 0.786 level - buffer
       = 1.08321 - 0.000396 = 1.08281
Entry  = 1.08690 (trigger close)
Risk   = 1.08690 - 1.08281 = 0.004086 = 40.86 pips

TP1 = leg high 1.09500 : 81.0 pips = 1.98R  (hit at B18)
TP2 = 1.272 ext 1.09908 : 121.8 pips = 2.98R  (hit at B21)
TP3 = 1.618 ext 1.10427 : 173.7 pips = 4.25R  (hit at B25)

Blended 50/30/20 outcome:
  0.50 x 1.98R + 0.30 x 2.98R + 0.20 x 4.25R = 2.74R before costs
  After 1-pip spread (0.02R): 2.71R

Position sizing ($10,000 account, 1% risk, EURUSD):
  Budget = $100.00
  Cost-adjusted risk per lot = (40.86 + 1.0) x $10 = $418.60
  Lots = floor(100 / 418.60 / 0.01) x 0.01 = 0.23
  Money at risk = 0.23 x $418.60 = $96.29
  P&L at blended: 2.71 x $96.29 = +$261.03

Three details deserve attention. The stop distance is 40.86 pips — wide, because the entry is at the top of the zone and the stop is at the far side of 0.786. That is the honest geometry of this setup, and it is why the position is sized to 0.23 lots rather than the "1 lot per $10k" that signal sellers imply. The spread is included in the sizing (cost-adjusted risk per lot uses 41.86 pips, not 40.86), because a risk budget that ignores costs is fiction. And the partial ladder is why the blended result (2.71R) is lower than the full-runner 4.25R — taking 50% at the leg high reduces both the variance and the average, deliberately.

7. Worked Case 2 — Failed: the Same Trade, the Other Path

The failed version uses the identical leg, identical trigger, identical entry and stop — and a different continuation. After B12, price stalls instead of resuming:

BarOpenHighLowCloseNote
B13f1.086901.087601.085901.08620Stall; small red bar
B14f1.086201.086601.084601.08490Body expansion downward
B15f1.084901.085301.083401.08360Approaching 0.786 (1.08321)
B16f1.083601.084001.082301.08250Low 1.08230 ≤ stop 1.08281 → filled

The arithmetic of the loss: stop fill at 1.08281, entry 1.08690, so the realized loss is the full 40.86-pip risk plus the 1-pip spread — −1.02R, which at 0.23 lots is −$98.64. The close at 1.08250 is also below the 0.786 level (1.08321), confirming the structural invalidation: this was not a stop-hunt wick, it was the retracement becoming something else.

Three lessons live in that arithmetic. One: the loss was fully known before the entry. The stop distance defined the position size before the trade existed; nothing was decided under pressure. Two: the invalidation was structural. "Close below 0.786" is a condition written in advance, checkable on the chart, identical for every trader following the rules. Three: the recovery math is symmetric. The account stands at $9,901.36 and recovering to $10,000 requires +1.00% — exactly one risk unit. That symmetry is the entire argument for 1% risk: every loss is recovered by one average winner, and no single event can damage the compounding engine. At 5% risk the same event costs $493 and requires +5.2% to recover; three in a row require +16.4%.

8. Worked Case 3 — Ambiguous: the Trigger That Missed by Half a Pip

The third case is the one most guides never show, and it is the one that tests whether the rule set survives contact with reality. Same leg, same zone, and a candidate trigger that fails by the smallest measurable margin:

Candidate bar B12a: open 1.08595, close 1.08695
Prior bar B11a:     close 1.08590, open 1.08650

Engulfing condition: open of B12a <= close of B11a
                      1.08595 <= 1.08590  ->  FALSE (misses by 0.5 pip)

Close 1.08695 is inside the zone. The bar is green. The engulfing
condition fails. Verdict by the rule set: NO TRADE.

Price then does what price does: it runs, without us. The counterfactual — computed by the same code with the condition loosened to "green close above the prior bar's open" — gives entry 1.08695, stop 1.08282, risk 41.33 pips, and a blended result of +2.69R that never entered the account.

The discipline argument that follows is not motivational filler; it is mechanical. If you loosen the engulfing rule after every miss, you are selecting rules based on single outcomes — the textbook definition of curve-fitting, and the reason so many "slightly adjusted" Fibonacci systems drift into negative expectancy. The correct response to this case has two parts. First, log it: this miss is data. Second, if the loose variant accumulates a genuinely better sample over fifty setups — more total R, after costs, across different regimes — then adopt it as the new rule, prospectively. Decided from a sample, applied forward; never adjusted in the moment. The strict rule costs you occasional winners like this one; the loose rule costs you a different set of losers. Only a sample can tell you which is cheaper, and the sample has to come before the change, not after the frustration.

There is also an asymmetry worth stating plainly: skipping a valid trade costs opportunity (measurable, recoverable, invisible in your equity curve). Taking an invalid trade costs money and, worse, corrupts the statistics you use to evaluate the system. The rule set prices both errors explicitly, and it prefers the first.

9. The Evidence Question — and the Baseline That Tests It

Return to the honest record from Section 1: extensive backtests found that 38%, 50% and 62% retracement depths were no likelier than other values, and the levels are human conventions. That finding is about levels as reversal points in isolation — it does not test a complete system with trend context, a trigger, a structural stop and partial exits. But it does set the burden of proof: if the golden zone adds nothing over a random entry depth on the same leg, then the ratio is decoration and the edge (if any) lives in the trend logic, the trigger and the risk management.

So the implementation in Section 12 builds that comparison in. For every qualified leg, the harness runs two variants with identical trigger rules, identical stops, identical targets and identical costs — differing only in where the entry band sits:

Golden zone: entries only in the 0.500–0.618 band (the strategy).

Random depth: for each leg, an entry band of the same width centered on a depth drawn uniformly from 0.295 to 0.700 (the baseline).

If the golden-zone version does not beat the random-depth version on your data, the ratio adds nothing measurable, and you should know that before trading it — not after. If it does beat the baseline consistently across walk-forward blocks, you have evidence that the convention concentrates order flow in a tradeable way. Either result is useful. Neither can be obtained from someone's screenshot.

No performance claims: this guide contains no win rates, no profit factors, no "70% accuracy" numbers — because we have not verified any on live data, and unverified numbers are marketing. What this guide gives you is the machinery to measure your own.

10. Risk Math — the Complete Arithmetic

Position sizing with costs included, computed 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$$

where \(E\) is equity, \(S\) the stop in pips, \(C\) the round-trip cost in pips, and \(V\) the pip value per standard lot. For $10,000, 1%, a 40.86-pip stop and a 1-pip spread on EURUSD: \(100 / (41.86 \times 10) = 0.2389\), floored to 0.23 lots — risking $96.29 including the spread. Rounding down is not fussiness: the budget is a ceiling, and 0.24 lots would breach it before costs.

The outcome table for the three cases at 0.23 lots:

OutcomeRP&L
Full stop (Case 2)−1.02R−$98.64
TP1 only, then breakeven (50% booked)+0.97R+$93.40
TP1 + TP2 (80% booked, runner stopped at entry)+1.87R+$180.10
All three targets (Case 1)+2.71R+$261.03

Now the number that actually decides whether the strategy is a business — expectancy:

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

Illustrative inputs (hypothetical, NOT a claim about this strategy):
  Win rate p = 45%   Avg win W = 2.0R   Avg loss L = 1.0R

  Expectancy = 0.45 x 2.0 - 0.55 x 1.0 = +0.35R per trade

Sensitivity — the honest range:
  p = 35%:  0.35 x 2.0 - 0.65 x 1.0 = +0.05R   (thin)
  p = 30%:  0.30 x 2.0 - 0.70 x 1.0 = -0.10R   (dead)

Breakeven win rate at W = 2.0R:  p* = 1 / (1 + 2.0) = 33.3%
With 1-pip spread (effective W = 1.98R):  p* = 33.6%

Read those six lines as the business plan. A strategy with a blended 2R average winner dies below a ~34% win rate; everything between 34% and 45% is the difference between a living edge and a dead one, and only your logged trades can tell you which side you are on. The blended average win depends on how far runners actually run — which is why the partial ladder (50/30/20) exists: it raises the realized win rate at the cost of the tail. And the drawdown arithmetic belongs here too: ten consecutive losses at 1% risk cost 9.6% of the account (\(0.99^{10}=0.904\)); the same streak at 5% risk costs 40% (\(0.95^{10}=0.599\)) and requires +67% to recover. The stop distance is the market's decision; the risk percentage is the only variable in the entire chain that is entirely yours.

11. Validation Protocol — and Our Falsifiable Hypothesis

Every strategy claim in this series is published with a protocol that could prove it wrong. For the Fibonacci pullback, the hypothesis is:

H1 (our own, falsifiable): Golden-zone entries (0.500–0.618) outperform random-depth entries drawn from the same qualified legs only when the leg range exceeds 2×ATR14; below that displacement threshold, zone entries and random-depth entries perform alike. Falsification: if zone entries show no advantage at any displacement bucket across walk-forward blocks, the ratio adds nothing and should be dropped — keeping only the leg qualification, the trigger and the stop logic.

The protocol, implemented in the module below:

  • Chronological split 60% train / 20% validation / 20% test. Parameters (leg multiple, buffer) are chosen on train only.
  • Walk-forward blocks: after parameter selection, the frozen configuration is evaluated on each subsequent out-of-sample block, and per-block results are reported separately — consistency across blocks matters more than the average.
  • Matched baseline: the random-depth comparison from Section 9, same sample, same costs.
  • Sensitivity grid: leg multiple ∈ {1.5, 2.0, 2.5, 3.0} × buffer ∈ {0.10, 0.15, 0.25} × ATR — a result that only exists at one grid point is noise, not an edge.
  • Costs: spread charged per trade; commission configurable as a fraction of R. Entries at the trigger close (known when it prints — no look-ahead); pivots usable only after confirmation (no repainting).

The module ships with a seeded synthetic demo so you can watch the machinery run before pointing it at real data. On the synthetic random walk, the harness reports what it must: no edge — validation expectancy −1.08R over 6 setups, test −0.76R over 5, and the golden zone essentially tied with random depth (−0.011R vs −0.198R on tiny samples, both ≈ zero before costs, negative after). That is the correct result on data with no structure, and it is the strongest evidence that the harness is honest: a backtest that finds money in a random walk is broken. Your MT5 CSV is the real test — the loader accepts standard exports and the same protocol runs unchanged.

12. The Complete Implementation

What follows is the full module — the shared library for this series. It contains the rule set as code, the three worked cases as literal OHLC arrays with assertions that verify every number quoted above (pivot locations, leg range, trigger condition, stop fill), the anchor-sensitivity table, the validation harness with the random-depth baseline and walk-forward blocks, and an MT5 CSV loader. It is pure standard library, Python 3.7+, no dependencies. Run it three ways:

python fib_pullback.py examples     # verify the three worked cases
python fib_pullback.py validate     # synthetic walk-forward + baseline demo
python fib_pullback.py csv EURUSD_H1.csv   # the real test: your data

Later guides in this series import this library, so keep it in the same folder as the scripts that follow.

#!/usr/bin/env python3
"""
fib_pullback.py - Fibonacci pullback strategy: reference implementation.

Companion code for the AlgoSpecial guide "Fibonacci Pullback Strategy"
(algospecial.com/blogs/fibonacci-pullback-strategy). Pure standard library,
Python 3.7+. No third-party dependencies.

WHAT THIS FILE CONTAINS
-----------------------
1. The exact rule set as code: deterministic swing selection, Fibonacci
   levels, the golden-zone entry, the engulfing trigger, the 0.786 stop,
   structure and extension targets, and 50/30/20 partial exits.
2. The three worked cases from the article (valid / failed / ambiguous) as
   literal OHLC arrays, with assertions that verify every claim made in the
   text (pivot locations, leg range, trigger condition, stop fill).
3. A validation harness: chronological 60/20/20 split, walk-forward blocks,
   a random-depth baseline, and a sensitivity grid over the leg multiple and
   the stop buffer.
4. An MT5 CSV loader so the same harness runs on exported data.

CONVENTIONS (stated once, used everywhere)
------------------------------------------
- Prices are quote-currency decimals; 1 pip = 0.0001 for these examples.
- ATR14 uses the SMA-of-TR convention: the mean of the last 14 true ranges.
  Wilder smoothing differs slightly; the convention is declared so every
  number in the article is reproducible.
- Pivots are strict fractals with 3 bars on each side (L=3, R=3), usable
  only from the confirmation bar (pivot index + 3). No repainting.
- A qualified leg is a confirmed pivot low followed by the next confirmed
  pivot high (or mirror), with range >= atr_mult * ATR14 at confirmation.
- Golden zone = retracement depths 0.500 to 0.618. Trigger = engulfing bar
  whose close lands inside the zone (bullish for longs, bearish for shorts).
- Stop = 0.786 level minus buffer; buffer = max(2 pips, buffer_mult * ATR14).
- Targets: TP1 = leg extreme, TP2 = 1.272 extension, TP3 = 1.618 extension,
  measured from the leg origin: origin + e * (high - low).
- Costs: spread charged once per trade (pips), commission as a fraction of R.
  Within a bar, the stop is checked before targets (conservative).

USAGE
-----
    python fib_pullback.py examples       # the three worked cases
    python fib_pullback.py validate       # synthetic walk-forward + baseline
    python fib_pullback.py csv path.csv   # run the harness on MT5 data
"""

from __future__ import annotations

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

PIP = 0.0001
RETRACE_RATIOS = (0.236, 0.382, 0.500, 0.618, 0.786)
EXT_RATIOS = (1.272, 1.618)
ZONE_CENTER = 0.559
ZONE_HALF = 0.059


# --------------------------------------------------------------------------
# Data primitives
# --------------------------------------------------------------------------

@dataclass
class Bar:
    o: float
    h: float
    l: float
    c: float


def pips(x: float) -> float:
    return x / PIP


def true_range(bar: Bar, prev_close: Optional[float]) -> float:
    if prev_close is None:
        return bar.h - bar.l
    return max(bar.h - bar.l, abs(bar.h - prev_close), abs(bar.l - prev_close))


def atr_sma(bars: Sequence[Bar], index: int, period: int = 14) -> Optional[float]:
    if index + 1 < period:
        return None
    total = 0.0
    for i in range(index - period + 1, index + 1):
        prev_close = bars[i - 1].c if i > 0 else None
        total += true_range(bars[i], prev_close)
    return total / period


# --------------------------------------------------------------------------
# Pivots and legs
# --------------------------------------------------------------------------

def fractal_lows(bars: Sequence[Bar], left: int = 3, right: int = 3) -> List[int]:
    out: List[int] = []
    for i in range(left, len(bars) - right):
        low = bars[i].l
        if all(bars[j].l > low for j in range(i - left, i)) and \
           all(bars[j].l > low for j in range(i + 1, i + right + 1)):
            out.append(i)
    return out


def fractal_highs(bars: Sequence[Bar], left: int = 3, right: int = 3) -> List[int]:
    out: List[int] = []
    for i in range(left, len(bars) - right):
        high = bars[i].h
        if all(bars[j].h < high for j in range(i - left, i)) and \
           all(bars[j].h < high for j in range(i + 1, i + right + 1)):
            out.append(i)
    return out


@dataclass
class Leg:
    low_idx: int
    high_idx: int
    low: float
    high: float
    confirmed_idx: int  # first bar index at which the leg is usable

    @property
    def rng(self) -> float:
        return self.high - self.low


def qualified_legs(bars: Sequence[Bar], atr_mult: float = 2.0,
                   period: int = 14, left: int = 3, right: int = 3) -> List[Leg]:
    """Confirmed pivot low -> next confirmed pivot high, range-filtered."""
    lows = fractal_lows(bars, left, right)
    highs = fractal_highs(bars, left, right)
    legs: List[Leg] = []
    for li in lows:
        later = [h for h in highs if h > li]
        if not later:
            continue
        hi = later[0]
        confirmed = hi + right
        if confirmed >= len(bars):
            continue
        a = atr_sma(bars, confirmed, period)
        if a is None:
            continue
        rng = bars[hi].h - bars[li].l
        if rng >= atr_mult * a:
            legs.append(Leg(li, hi, bars[li].l, bars[hi].h, confirmed))
    return legs


# --------------------------------------------------------------------------
# Levels and setup detection
# --------------------------------------------------------------------------

def fib_retracements(low: float, high: float) -> Dict[float, float]:
    rng = high - low
    return {r: high - r * rng for r in RETRACE_RATIOS}


def fib_extensions(low: float, high: float) -> Dict[float, float]:
    rng = high - low
    return {e: low + e * rng for e in EXT_RATIOS}


def is_bullish_engulfing(prev: Bar, cur: Bar) -> bool:
    return (prev.c < prev.o and cur.c > cur.o and
            cur.o <= prev.c and cur.c >= prev.o)


def is_bearish_engulfing(prev: Bar, cur: Bar) -> bool:
    return (prev.c > prev.o and cur.c < cur.o and
            cur.o >= prev.c and cur.c <= prev.o)


@dataclass
class Setup:
    leg: Leg
    levels: Dict[float, float]
    zone_lo: float
    zone_hi: float
    entry_idx: int
    entry: float
    stop: float
    tp1: float
    tp2: float
    tp3: float

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


def find_long_setup_depth(bars: Sequence[Bar], leg: Leg, center: float,
                          half: float = ZONE_HALF, buffer_mult: float = 0.15,
                          period: int = 14) -> Optional[Setup]:
    """Long setup with the entry band centered on a retracement depth."""
    levels = fib_retracements(leg.low, leg.high)
    rng = leg.high - leg.low
    deep = leg.high - (center + half) * rng   # deeper price (larger depth)
    shallow = leg.high - (center - half) * rng  # shallower price (smaller depth)
    lo_edge, hi_edge = min(deep, shallow), max(deep, shallow)
    for i in range(leg.confirmed_idx + 1, len(bars)):
        bar, prev = bars[i], bars[i - 1]
        if lo_edge <= bar.c <= hi_edge and is_bullish_engulfing(prev, bar):
            a = atr_sma(bars, i, period)
            if a is None:
                continue
            buffer = max(2 * PIP, buffer_mult * a)
            stop = levels[0.786] - buffer
            if stop >= bar.c:
                continue
            ext = fib_extensions(leg.low, leg.high)
            return Setup(leg=leg, levels=levels, zone_lo=lo_edge, zone_hi=hi_edge,
                         entry_idx=i, entry=bar.c, stop=stop,
                         tp1=leg.high, tp2=ext[1.272], tp3=ext[1.618])
    return None


def find_long_setup(bars: Sequence[Bar], leg: Leg, buffer_mult: float = 0.15,
                    period: int = 14) -> Optional[Setup]:
    return find_long_setup_depth(bars, leg, ZONE_CENTER, ZONE_HALF,
                                 buffer_mult, period)


# --------------------------------------------------------------------------
# Trade simulation with partial exits and costs
# --------------------------------------------------------------------------

@dataclass
class TradeResult:
    entry_idx: int
    exit_idx: int
    entry: float
    stop: float
    risk: float
    outcome: str
    realized_r: float
    tp_hits: List[Tuple[float, int]]


def simulate_long(bars: Sequence[Bar], setup: Setup, spread_pips: float = 1.0,
                  commission_r: float = 0.0,
                  partials: Tuple[float, float, float] = (0.5, 0.3, 0.2),
                  max_bars: int = 200) -> TradeResult:
    entry, stop, risk = setup.entry, setup.stop, setup.risk
    targets = (setup.tp1, setup.tp2, setup.tp3)
    remaining = 1.0
    realized = 0.0
    next_tp = 0
    tp_hits: List[Tuple[float, int]] = []
    cost_r = spread_pips * PIP / risk + commission_r
    end = min(len(bars), setup.entry_idx + 1 + max_bars)
    for i in range(setup.entry_idx + 1, end):
        bar = bars[i]
        if bar.l <= stop:  # conservative: stop first within the bar
            realized += remaining * (-1.0)
            return TradeResult(setup.entry_idx, i, entry, stop, risk,
                               "stop", realized - cost_r, tp_hits)
        while next_tp < 3 and bar.h >= targets[next_tp]:
            frac = partials[next_tp]
            take = min(frac, remaining)
            realized += take * (targets[next_tp] - entry) / risk
            remaining -= take
            tp_hits.append((targets[next_tp], i))
            next_tp += 1
        if remaining <= 1e-9:
            return TradeResult(setup.entry_idx, i, entry, stop, risk,
                               "all_targets", realized - cost_r, tp_hits)
    last_close = bars[end - 1].c
    realized += remaining * (last_close - entry) / risk
    return TradeResult(setup.entry_idx, end - 1, entry, stop, risk,
                       "timeout", realized - cost_r, tp_hits)


def size_position(equity: float, risk_pct: float, stop_pips: float,
                  pip_value_per_lot: float = 10.0,
                  cost_pips: float = 1.0, lot_step: float = 0.01) -> Tuple[float, float]:
    """Lots that keep risk + costs inside the budget. Rounds DOWN."""
    budget = equity * risk_pct / 100.0
    per_lot = (stop_pips + cost_pips) * pip_value_per_lot
    lots = math.floor((budget / per_lot) / lot_step) * lot_step
    return lots, lots * per_lot


# --------------------------------------------------------------------------
# Metrics
# --------------------------------------------------------------------------

def metrics(results: Sequence[TradeResult]) -> Dict[str, float]:
    if not results:
        return {"n": 0}
    rs = [t.realized_r for t in results]
    wins = [r for r in rs if r > 0]
    losses = [r for r in rs if r <= 0]
    gross_win = sum(wins)
    gross_loss = -sum(losses)
    eq = 0.0
    peak = 0.0
    max_dd = 0.0
    for r in rs:
        eq += r
        peak = max(peak, eq)
        max_dd = max(max_dd, peak - eq)
    return {
        "n": len(rs),
        "win_rate": len(wins) / len(rs),
        "avg_win_r": (sum(wins) / len(wins)) if wins else 0.0,
        "avg_loss_r": (sum(losses) / len(losses)) if losses else 0.0,
        "expectancy_r": sum(rs) / len(rs),
        "profit_factor": (gross_win / gross_loss) if gross_loss > 0 else float("inf"),
        "max_dd_r": max_dd,
        "total_r": sum(rs),
    }


# --------------------------------------------------------------------------
# Synthetic data and baseline
# --------------------------------------------------------------------------

def synthetic_bars(n: int, seed: int = 7, start: float = 1.0800) -> List[Bar]:
    """Regime-switching random walk; deterministic for a given seed."""
    rng = random.Random(seed)
    bars: List[Bar] = []
    price = start
    drift = 0.0
    vol = 0.0012
    for i in range(n):
        if i % 120 == 0:
            drift = rng.choice([0.00006, -0.00006, 0.0, 0.00012, -0.00012])
            vol = rng.choice([0.0008, 0.0012, 0.0018])
        o = price
        steps = [rng.gauss(drift, vol) for _ in range(4)]
        h = max([o] + [o + s for s in steps]) + abs(rng.gauss(0, vol / 3))
        l = min([o] + [o + s for s in steps]) - abs(rng.gauss(0, vol / 3))
        c = o + sum(steps) / 4
        c = min(max(c, l), h)
        bars.append(Bar(o, h, l, c))
        price = c
    return bars


def run_strategy(bars: Sequence[Bar], atr_mult: float = 2.0,
                 buffer_mult: float = 0.15, spread_pips: float = 1.0,
                 depth_center: Optional[float] = None,
                 rng: Optional[random.Random] = None,
                 start_idx: int = 0) -> List[TradeResult]:
    """Run the strategy once per qualified leg; no overlapping re-entries."""
    legs = qualified_legs(bars, atr_mult)
    results: List[TradeResult] = []
    last_exit = -1
    for leg in legs:
        if leg.confirmed_idx < start_idx or leg.confirmed_idx <= last_exit:
            continue
        if depth_center is None:
            setup = find_long_setup(bars, leg, buffer_mult)
        else:
            d = depth_center if rng is None else rng.uniform(0.295, 0.700)
            setup = find_long_setup_depth(bars, leg, d, ZONE_HALF, buffer_mult)
        if setup is None:
            continue
        res = simulate_long(bars, setup, spread_pips)
        results.append(res)
        last_exit = res.exit_idx
    return results


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

PREFIX = [
    Bar(1.08000, 1.08180, 1.07980, 1.08160),  # B-2
    Bar(1.08160, 1.08340, 1.08140, 1.08320),  # B-1
    Bar(1.08320, 1.08380, 1.08160, 1.08180),  # B0
    Bar(1.08120, 1.08250, 1.08060, 1.08220),  # B1
    Bar(1.08220, 1.08280, 1.08060, 1.08080),  # B2
    Bar(1.08080, 1.08100, 1.08000, 1.08090),  # B3  <- leg low
    Bar(1.08090, 1.08260, 1.08080, 1.08240),  # B4
    Bar(1.08240, 1.08480, 1.08230, 1.08460),  # B5
    Bar(1.08460, 1.08810, 1.08450, 1.08790),  # B6
    Bar(1.08790, 1.09200, 1.08780, 1.09180),  # B7
    Bar(1.09180, 1.09500, 1.09100, 1.09460),  # B8  <- leg high
    Bar(1.09460, 1.09480, 1.09080, 1.09120),  # B9
    Bar(1.09120, 1.09150, 1.08680, 1.08680),  # B10
]
B11 = Bar(1.08650, 1.08680, 1.08560, 1.08590)
B12 = Bar(1.08580, 1.08720, 1.08550, 1.08690)  # engulfing trigger
B12A = Bar(1.08595, 1.08710, 1.08570, 1.08695)  # fails by 0.5 pip

VALID_CONT = [
    Bar(1.08690, 1.08860, 1.08670, 1.08830),
    Bar(1.08830, 1.08980, 1.08790, 1.08950),
    Bar(1.08950, 1.09120, 1.08910, 1.09080),
    Bar(1.09080, 1.09240, 1.09030, 1.09200),
    Bar(1.09200, 1.09420, 1.09160, 1.09390),
    Bar(1.09390, 1.09540, 1.09340, 1.09500),  # TP1
    Bar(1.09500, 1.09680, 1.09460, 1.09640),
    Bar(1.09640, 1.09820, 1.09600, 1.09790),
    Bar(1.09790, 1.09940, 1.09750, 1.09910),  # TP2
    Bar(1.09910, 1.10080, 1.09870, 1.10040),
    Bar(1.10040, 1.10200, 1.09990, 1.10160),
    Bar(1.10160, 1.10320, 1.10120, 1.10280),
    Bar(1.10280, 1.10450, 1.10240, 1.10420),  # TP3
]
FAILED_CONT = [
    Bar(1.08690, 1.08760, 1.08590, 1.08620),
    Bar(1.08620, 1.08660, 1.08460, 1.08490),
    Bar(1.08490, 1.08530, 1.08340, 1.08360),
    Bar(1.08360, 1.08400, 1.08230, 1.08250),  # stop hit
]
AMBIG_CONT = [
    Bar(1.08695, 1.08840, 1.08660, 1.08820),
    Bar(1.08820, 1.09000, 1.08780, 1.08970),
    Bar(1.08970, 1.09150, 1.08930, 1.09110),
    Bar(1.09110, 1.09300, 1.09070, 1.09270),
    Bar(1.09270, 1.09460, 1.09230, 1.09430),
    Bar(1.09430, 1.09620, 1.09390, 1.09590),
    Bar(1.09590, 1.09760, 1.09550, 1.09730),
    Bar(1.09730, 1.09900, 1.09690, 1.09870),
    Bar(1.09870, 1.10020, 1.09830, 1.09990),
    Bar(1.09990, 1.10160, 1.09950, 1.10130),
    Bar(1.10130, 1.10300, 1.10090, 1.10270),
    Bar(1.10270, 1.10450, 1.10230, 1.10420),
]


def verify_worked_cases() -> None:
    """Assertions: every claim in the article is checked here."""
    valid = PREFIX + [B11, B12] + VALID_CONT
    failed = PREFIX + [B11, B12] + FAILED_CONT
    ambig = PREFIX + [B11, B12A] + AMBIG_CONT

    legs = qualified_legs(valid)
    assert len(legs) == 1, "expected exactly one qualified leg"
    leg = legs[0]
    assert leg.low == 1.08000 and leg.high == 1.09500
    assert abs(leg.rng - 0.01500) < 1e-12
    a_conf = atr_sma(valid, leg.confirmed_idx)
    assert leg.rng >= 2.0 * a_conf

    setup = find_long_setup(valid, leg)
    assert setup is not None and setup.entry_idx == 14
    assert abs(setup.entry - 1.08690) < 1e-12
    assert abs(setup.levels[0.786] - 1.08321) < 1e-9
    a_entry = atr_sma(valid, 14)
    buffer = max(2 * PIP, 0.15 * a_entry)
    assert abs(setup.stop - (setup.levels[0.786] - buffer)) < 1e-12

    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_long_setup(failed, leg)
    assert setup_f is not None
    res_f = simulate_long(failed, setup_f, spread_pips=1.0)
    assert res_f.outcome == "stop"

    legs_a = qualified_legs(ambig)
    assert len(legs_a) == 1
    assert find_long_setup(ambig, legs_a[0]) is None, "ambiguous must not trigger"


def print_worked_cases() -> None:
    valid = PREFIX + [B11, B12] + VALID_CONT
    failed = PREFIX + [B11, B12] + FAILED_CONT
    ambig = PREFIX + [B11, B12A] + AMBIG_CONT

    leg = qualified_legs(valid)[0]
    setup = find_long_setup(valid, leg)
    a_conf = atr_sma(valid, leg.confirmed_idx)
    a_entry = atr_sma(valid, 14)

    print("=" * 66)
    print("WORKED CASE 1 - VALID (bars B-2..B25, 1 pip = 0.0001)")
    print("=" * 66)
    print("Leg low  B3 = %.5f   Leg high B8 = %.5f   range = %.1f pips"
          % (leg.low, leg.high, pips(leg.rng)))
    print("ATR14 at confirmation B11 = %.2f pips" % pips(a_conf))
    print("Leg qualifies: %.1f >= 2.0 x %.2f = %.2f pips"
          % (pips(leg.rng), pips(a_conf), pips(2.0 * a_conf)))
    print("ATR14 at trigger B12 = %.2f pips" % pips(a_entry))
    print("Levels: 0.236=%.5f 0.382=%.5f 0.500=%.5f 0.618=%.5f 0.786=%.5f"
          % tuple(setup.levels[r] for r in RETRACE_RATIOS))
    print("Golden zone: %.5f - %.5f" % (setup.zone_lo, setup.zone_hi))
    print("Trigger B12: engulfing close %.5f inside zone" % setup.entry)
    print("Stop  = 0.786 level - buffer(%.2f pips) = %.5f"
          % (pips(max(2 * PIP, 0.15 * a_entry)), setup.stop))
    print("Risk  = %.2f pips (entry - stop)" % pips(setup.risk))
    print("TP1 = %.5f (%.1f pips, %.2fR)" % (setup.tp1, pips(setup.tp1 - setup.entry),
                                             pips(setup.tp1 - setup.entry) / pips(setup.risk)))
    print("TP2 = %.5f (%.1f pips, %.2fR)" % (setup.tp2, pips(setup.tp2 - setup.entry),
                                             pips(setup.tp2 - setup.entry) / pips(setup.risk)))
    print("TP3 = %.5f (%.1f pips, %.2fR)" % (setup.tp3, pips(setup.tp3 - setup.entry),
                                             pips(setup.tp3 - setup.entry) / pips(setup.risk)))
    res = simulate_long(valid, setup, spread_pips=1.0)
    print("Resolution: TP1 at bar %d, TP2 at bar %d, TP3 at bar %d"
          % (res.tp_hits[0][1], res.tp_hits[1][1], res.tp_hits[2][1]))
    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: $10,000 x 1%% = $100 budget; lots = %.2f; risk+cost = $%.2f"
          % (lots, risk_money))
    print("P&L at blended: $%.2f" % (res.realized_r * risk_money))
    print()

    print("=" * 66)
    print("WORKED CASE 2 - FAILED (same leg, same trigger, different path)")
    print("=" * 66)
    setup_f = find_long_setup(failed, leg)
    res_f = simulate_long(failed, setup_f, spread_pips=1.0)
    print("Entry %.5f  stop %.5f  risk %.2f pips" % (setup_f.entry, setup_f.stop, pips(setup_f.risk)))
    print("Bar B16f low 1.08230 <= stop %.5f -> filled at stop" % setup_f.stop)
    print("Outcome: %s at bar %d; realized %.2fR" % (res_f.outcome, res_f.exit_idx, res_f.realized_r))
    print("P&L at %.2f lots: $%.2f" % (lots, res_f.realized_r * risk_money))
    print()

    print("=" * 66)
    print("WORKED CASE 3 - AMBIGUOUS (trigger fails by 0.5 pip; no trade)")
    print("=" * 66)
    leg_a = qualified_legs(ambig)[0]
    assert find_long_setup(ambig, leg_a) is None
    prev, cur = B11, B12A
    print("Candidate B12a: o=%.5f c=%.5f vs prior c=%.5f o=%.5f"
          % (cur.o, cur.c, prev.c, prev.o))
    print("Engulfing needs o_cur <= c_prev: %.5f <= %.5f is %s -> no trigger"
          % (cur.o, prev.c, cur.o <= prev.c))
    a_entry_a = atr_sma(ambig, 14)
    buffer_a = max(2 * PIP, 0.15 * a_entry_a)
    stop_a = fib_retracements(leg.low, leg.high)[0.786] - buffer_a
    risk_a = cur.c - stop_a
    print("Counterfactual (loose rule): entry %.5f stop %.5f risk %.2f pips"
          % (cur.c, stop_a, pips(risk_a)))
    tp2 = fib_extensions(leg.low, leg.high)[1.272]
    tp3 = fib_extensions(leg.low, leg.high)[1.618]
    blended_a = 0.5 * pips(leg.high - cur.c) / pips(risk_a) + \
                0.3 * pips(tp2 - cur.c) / pips(risk_a) + \
                0.2 * pips(tp3 - cur.c) / pips(risk_a)
    print("Missed: price ran to TP3 area; counterfactual blended %.2fR" % blended_a)
    print()


# --------------------------------------------------------------------------
# Validation harness on synthetic data (machinery demo, not an edge claim)
# --------------------------------------------------------------------------

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 bars, validation %d bars, test %d bars" % (len(train), len(val), len(test)))

    print("\nSensitivity grid on TRAIN (expectancy in R per trade):")
    print("%-10s" % "atr_mult", end="")
    for b in (0.10, 0.15, 0.25):
        print("buffer=%.2f " % b, end="")
    print()
    best = (None, -999.0)
    for am in (1.5, 2.0, 2.5, 3.0):
        print("%-10.1f" % am, end="")
        for bm in (0.10, 0.15, 0.25):
            res = run_strategy(train, am, bm)
            m = metrics(res)
            e = m.get("expectancy_r", 0.0)
            if e > best[1]:
                best = ((am, bm), e)
            print("%9.3f " % e, end="")
        print()
    chosen = best[0] if best[0] else (2.0, 0.15)
    print("Chosen on train: atr_mult=%.1f buffer=%.2f" % chosen)

    for name, seg in (("validation", val), ("test", test)):
        m = metrics(run_strategy(seg, chosen[0], 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("\nRandom-depth baseline vs golden zone (whole sample):")
    zone = metrics(run_strategy(bars, chosen[0], chosen[1]))
    rng = random.Random(11)
    base_results: List[TradeResult] = []
    for _ in range(5):
        base_results.extend(run_strategy(bars, chosen[0], chosen[1],
                                         depth_center=0.0, rng=rng))
    base = metrics(base_results)
    print("golden zone : n=%d expectancy=%.3fR" % (zone["n"], zone.get("expectancy_r", 0.0)))
    print("random depth: n=%d expectancy=%.3fR" % (base["n"], base.get("expectancy_r", 0.0)))
    print("(Synthetic data proves the harness runs; it cannot establish an edge.)")


# --------------------------------------------------------------------------
# MT5 CSV loader
# --------------------------------------------------------------------------

def load_mt5_csv(path: str) -> List[Bar]:
    """Parse an MT5-exported CSV (tab or comma separated, header optional)."""
    bars: List[Bar] = []
    with open(path, "r", encoding="utf-8-sig") as fh:
        for line in fh:
            parts = line.strip().replace("\t", ";").replace(",", ";").split(";")
            if len(parts) < 5:
                continue
            try:
                nums = [float(x) for x in parts[-5:]]
            except ValueError:
                continue
            o, h, l, c = nums[1], nums[2], nums[3], nums[4]
            bars.append(Bar(o, h, l, c))
    return bars


# --------------------------------------------------------------------------
# Entry point
# --------------------------------------------------------------------------

def anchor_sensitivity() -> None:
    """Same structure, four defensible anchor choices - level shifts."""
    pairs = [
        ("wick-to-wick (our rule)", 1.08000, 1.09500),
        ("body low (B3 open)", 1.08080, 1.09500),
        ("prior minor low (B2)", 1.08060, 1.09500),
        ("minor swing high (B7)", 1.08000, 1.09200),
    ]
    print("=" * 66)
    print("ANCHOR SENSITIVITY (same structure, different anchor choices)")
    print("=" * 66)
    print("%-26s %8s %9s %9s %10s" % ("anchor pair", "range", "0.618", "0.786", "stop delta"))
    base = None
    for name, lo, hi in pairs:
        rng = hi - lo
        l618 = hi - 0.618 * rng
        l786 = hi - 0.786 * rng
        if base is None:
            base = l786
        print("%-26s %6.1f %9.5f %9.5f %+9.1f" % (name, pips(rng), l618, l786, pips(l786 - base)))
    print()


def main() -> None:
    mode = sys.argv[1] if len(sys.argv) > 1 else "examples"
    if mode == "examples":
        verify_worked_cases()
        print_worked_cases()
        anchor_sensitivity()
    elif mode == "validate":
        bars = synthetic_bars(3000, seed=7)
        walk_forward_report(bars)
    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()

13. Failure Modes Specific to Fibonacci Pullbacks

Anchor shopping. The most expensive habit in the tool. If you find yourself re-anchoring the leg after a losing trade — "the real swing was actually lower" — you are no longer running a strategy; you are running an excuse generator. The anchor algorithm exists so that this decision is not available to you in the moment.

Trading touches without triggers. A touch of the zone is information; the engulfing close is permission. Entries at touches have no defined moment of confirmation, which means every entry is judged differently, which means the statistics of your trades describe nothing. In the worked data, the pullback touched the zone at B10 and B11 before the trigger at B12 — two touches, zero trades, one entry.

Negotiating with 0.786. When price closes below the level, the setup is over; "giving it room" converts a defined −1R event into an undefined one. The failed case shows the arithmetic: the stop limited the event to −1.02R. Traders who held past the invalidation watched the same move continue to 1.07900 — more than −2R on the same position size. The 0.786 rule is what makes the losing case boring, and boring losses are the entire point.

Fib in a range. The strategy requires a qualified leg (range ≥ 2×ATR14 with confirmed pivots). In a range, no leg qualifies, no zone is drawn, no trade exists. Traders who draw Fibonacci inside consolidation are applying a trend tool to a no-trend market — the levels still "work" as occasional bounces, but the expectancy math that depends on continuation is gone.

Chasing extensions beyond structure. The 1.618 extension is a target, not a promise. If the leg high is 40 pips away and the 1.618 extension is 170 pips away, the realistic first target is the leg high, and the runner is a lottery ticket with defined cost (20% of the position). Position sizing on the full extension as if it were the base case is how a working strategy produces a broken equity curve.

Ignoring the cost arithmetic on tight charts. A 40.86-pip stop absorbs a 1-pip spread at 2.4% of risk — fine. The same pattern on M5 with an 8-pip stop absorbs the spread at 12.5% of risk, which silently subtracts 0.125R from every average win and moves the breakeven win rate from 33.3% to 38.5%. The rule: below H1, the Fibonacci pullback must clear a higher bar, and usually does not.

14. FAQ

What is the Fibonacci pullback strategy?

A trend-continuation setup: after a qualified impulse leg, price retraces into the 0.500–0.618 golden zone, an engulfing trigger fires with its close inside the zone, the stop sits below the 0.786 level with an ATR-scaled buffer, and targets are the leg high plus the 1.272 and 1.618 extensions. Every condition is Boolean; nothing is left to discretion.

Do Fibonacci levels actually work?

The academic record does not show that 38.2%, 50% or 61.8% depths are special — extensive backtests found them no likelier than other values, and the ratios are human conventions. What the tool genuinely provides is a predefined location, a built-in invalidation at 0.786, and testability. This guide includes a random-depth baseline so you can test whether the zone adds anything on your own data.

Which swing high and low should I use?

Wick-to-wick extremes of confirmed fractal pivots (3 bars each side, confirmed 3 bars later), and only for legs at least 2×ATR14 in range. Fixed at confirmation, never re-anchored. The anchor-sensitivity table in Section 4 shows why: defensible alternatives shift the stop by up to 6.4 pips, changing every outcome downstream.

What is the golden zone?

The band between the 0.500 and 0.618 retracement levels. It is the only entry area in our rule set, and entries still require an engulfing close inside it — a touch alone is not a trade.

Where does the stop go?

Below the 0.786 level minus a buffer of max(2 pips, 0.15×ATR14) for longs; mirrored for shorts. The 0.786 level is the structural invalidation — a close beyond it means the retracement is behaving like a reversal, and the buffer absorbs the stop-cluster sweep that usually sits just past the level.

Can I automate it?

Yes — the complete Python implementation in this article detects legs, zone, trigger, stop and targets, then runs walk-forward validation and the random-depth baseline on your exported CSV. No rule in the set requires judgment at the moment of the trade.

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. ×