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

Trendline Pullback Trading — Pivot-Fit Algorithm, Slope Math, and Three Worked Cases

The most subjective tool in trading, made mechanical: anchors from confirmed pivots, the line as a formula, slope normalized in ATR per bar, and three worked cases with every number computed.

No tool in technical analysis carries more of the analyst's thumbprint than the trendline. Two traders, same chart, two different lines — and both can defend their choice. That subjectivity is not a stylistic quirk; it destroys the ability to test the tool, because a rule you can redraw after the outcome is not a rule. This guide fixes the problem completely: anchors from confirmed fractal pivots, the line as an explicit formula, a tolerance measured in ATR, a slope normalized in ATR per bar, and three worked cases with every number computed by code that asserts its own claims.

1. The Line Is an Object, Not a Drawing

A trend line in this rule set is constructed mechanically and then frozen. The construction has three parts, each a single sentence:

Anchors. Two consecutive confirmed fractal pivot lows (3 bars on each side, usable only 3 bars after forming) with the second low higher than the first. In the worked data: anchor A at bar index 6 with a low of 1.08000, anchor B at index 14 with a low of 1.08200.

The formula. With \(a = \) the first anchor and \(b = \) the second, the slope is \(s = (b_{px} - a_{px})/(b_{idx} - a_{idx})\), and the line value at any bar \(i\) is:

$$L(i) = a_{px} + s\,(i - a_{idx})$$

For the worked line: \(s = 0.00200/8 = 0.00025\) per bar — 2.5 pips per bar. The line is now a function, not a picture; every subsequent claim about it is arithmetic.

The tolerance. A bar "touches" the line when its low is within tolerance of \(L(i)\), where tolerance = 0.10 × ATR14 measured at the line's confirmation bar — 1.87 pips in the worked data. The tolerance exists because a line is a mathematical object and price is not; demanding exact equality produces zero touches on every real chart. What matters is that the tolerance is declared and fixed, not eyeballed.

And the repainting disclosure, stated plainly because it is the quiet killer of trendline backtests: this rule set cannot repaint. The anchors are confirmed pivots — the second anchor is not knowable until three bars after it forms — and the line is fixed at that confirmation bar (index 17 in the worked data). Setups are scanned only after confirmation. A hand-drawn backtest "catches" the third touch with a line the trader drew after seeing it; this system could not have. What it tests is what you would have traded, which is the only kind of test worth running.

2. Slope Normalization — the One Number That Makes Steepness Comparable

"Steep" is meaningless in raw pips per bar, because a 3-pip-per-bar line is steep on a quiet pair and shallow on a volatile one. The fix is one division:

$$\text{slope}_{norm} = \frac{s}{\text{ATR}_{14}\ \text{at the confirmation bar}} \qquad [\text{ATR units per bar}]$$

The worked line: \(0.00025 / 0.001871 = 0.134\) ATR/bar — a moderate slope. The number is now comparable across instruments, timeframes and volatility regimes, which makes it testable: the article's hypothesis (Section 7) buckets trades by slope_norm and compares the distributions. Without the normalization, that test cannot exist; with it, "steep lines break more often" becomes a claim that either survives the data or dies.

A related honesty note: the implementation works on the price scale as given (linear). Log-scale lines are the correct choice for very long horizons where percentage moves dominate; within the horizons this series trades (H1 to D1 swings), the difference is second-order. The convention is stated so the results are reproducible, not because the choice is sacred.

3. The Exact Rules

Trendline Pullback — Complete Rule Set (long side)

  • Line: two consecutive confirmed fractal pivot lows, second higher than first. Slope \(s\), line value \(L(i) = a_{px} + s(i - a_{idx})\). Fixed at the second anchor's confirmation; never redrawn.
  • Tolerance: 0.10 × ATR14 at the confirmation bar. A bar touches when its low is within tolerance of \(L(i)\). Anchors count as touches 1 and 2.
  • Arming: the third touch arms the setup.
  • Trigger: a bullish engulfing bar within 2 bars of that touch, closing at or above \(L(i)\).
  • Entry: at the trigger bar's close.
  • Stop: lowest low from the first touch through the trigger, minus max(2 pips, 0.15 × ATR14 at the trigger).
  • Invalidation: a close below \(L(i)\) minus the buffer kills the line permanently.
  • Targets: ref high = highest high from anchor B through the bar before the trigger. TP1 = ref high; TP2 = ref high + 0.5 × (ref high − \(L\) at entry); TP3 = ref high + 1.0 × (same). Partials 50/30/20.

Two rules deserve emphasis. The 2-bar trigger window exists because the engulfing reclaim often prints on the bar after the touch, and a strictly-simultaneous requirement would miss most real triggers — but an unlimited window would let price wander far above the line before "triggering." Two bars is the declared compromise. And the stop uses the lowest low since the first touch, not just the trigger bar's low: the pullback's true floor is the sweep low that tested the line, and the stop belongs below it.

4. Worked Case 1 — Valid: Every Number Computed

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

BarOpenHighLowCloseRole
B61.083601.083801.080001.08060Anchor A (pivot low, confirmed at B9)
B141.083201.083601.082001.08340Anchor B (pivot low, confirmed at B17)
B211.085201.085301.083601.08490Third touch: low 1.08360 vs line 1.08375 (1.5 pips)
B221.084801.085601.084601.08530Trigger: engulfing close 1.08530

The trigger check in full: prior bar B21 is red (close 1.08490 < open 1.08520). Bar B22 is green (close 1.08530 > open 1.08480). Engulfing requires open of B22 ≤ close of B21: \(1.08480 \le 1.08490\) ✓. And close of B22 ≥ open of B21: \(1.08530 \ge 1.08520\) ✓. The close is above the line value at B22 (1.08400) ✓, and B22 is within 2 bars of the third touch at B21 ✓. All conditions hold.

The trade arithmetic:

Anchors: A idx 6 low 1.08000, B idx 14 low 1.08200 (confirmed B17)
Slope = 0.00200 / 8 bars = 2.5 pips/bar
ATR14 at confirmation = 18.71 pips -> slope_norm = 0.134 ATR/bar
Tolerance = 0.10 x 18.71 = 1.87 pips
Line at B21 = 1.08375; touch low 1.08360 -> 1.5 pips away (touch #3)

ATR14 at trigger (B22) = 16.64 pips
Buffer = max(2, 0.15 x 16.64) = 2.50 pips
Stop = lowest low since touch (1.08360) - 0.000250 = 1.08335
Entry = 1.08530 (trigger close)
Risk = 1.08530 - 1.08335 = 0.001950 = 19.50 pips

Ref high (B14..B21) = 1.08820; line at entry = 1.08400
TP1 = 1.08820 : 29.0 pips = 1.49R  (hit at B25)
TP2 = 1.09030 : 50.0 pips = 2.56R  (hit at B27)
TP3 = 1.09240 : 71.0 pips = 3.64R  (hit at B30)

Blended 50/30/20 = 2.24R before costs, 2.19R after 1-pip spread

Sizing ($10,000, 1% risk, EURUSD):
  cost-adjusted risk per lot = (19.50 + 1.0) x $10 = $205.00
  lots = floor(100 / 205.00 / 0.01) x 0.01 = 0.48
  money at risk = 0.48 x $205.00 = $98.38
  P&L at blended: 2.19 x $98.38 = +$215.47

Notice the structure of the numbers. The touch landed 1.5 pips from the line — inside the 1.87-pip tolerance but not exactly on it, which is what real touches look like. The stop (19.50 pips) is wider than the pullback itself because the entry fires 17 pips above the sweep low; the position size shrinks accordingly (0.48 lots). And the first target is the price the market already printed — 1.08820 — not a projection: 1.49R for taking the proven level, with the projections (TP2, TP3) riding on the 50% runner.

5. Worked Case 2 — Failed: the Line That Broke

Same line, same touch, same trigger — different continuation:

BarOpenHighLowCloseNote
B23f1.085301.085501.084401.08460Stall
B24f1.084601.084801.083601.08380Sliding to the line
B25f1.083801.084001.082801.08300Low 1.08280 ≤ stop 1.08335 → filled

The arithmetic of the loss: stop fill at 1.08335, entry 1.08530, so the realized loss is the full 19.50-pip risk plus the 1-pip spread — −1.05R, which at 0.48 lots is −$103.43. The close at 1.08300 is also below the line minus buffer (line at B25f = 1.08475; minus 2.5 pips = 1.08450), confirming the line is dead: the structure that produced the setup no longer exists, and no re-entry is permitted on it. A trendline that has been closed through is not a support line — it is a memory, and trading memories is how accounts bleed.

The recovery math stays symmetrical: the account stands at $9,896.57; returning to $10,000 requires +1.05% — one risk unit plus costs. At 5% risk the same event costs $517 and requires +5.4% to recover.

6. Worked Case 3 — Ambiguous: the Trade the Rules Cannot Close

The third case exposes a gap that almost no trendline guide mentions: the rule set defines a stop, three targets and an invalidation — but no time exit. Watch what that produces. Same trigger at B22, then price does the most frustrating thing markets do — almost works:

B26 peaks at 1.08810. TP1 sits at 1.08820. The high misses TP1
by 1.0 pip, then price drifts. The stop at 1.08335 is never touched.
The sample ends at 1.08650 with the position open.

Booked at sample end: (1.08650 - 1.08530)/0.00195 - 0.05 = +0.56R

With a 5-bar time stop (exit at B27 close 1.08680):
  (1.08680 - 1.08530)/0.00195 - 0.05 = +0.72R

Neither win nor loss: the trade earned +0.56R by the mechanical bookkeeping (position valued at the final close) and would have earned +0.72R with a time stop — but neither number describes how a human would actually manage the position, and that is exactly the problem. The honest framing has three parts. First, the rule set as published has no time-stop rule, and pretending otherwise would be a lie in the backtest. Second, the outcome class ("timeout") is visible in every run of the implementation and should be logged like any other. Third, if the time-stop variant accumulates a better sample — more total R after costs across walk-forward blocks — it gets adopted prospectively as a written rule. The 1-pip miss of TP1 is not information about the future; it is a reminder that any rule set with targets will occasionally leave trades in limbo, and the system should say so out loud.

7. The Slope Hypothesis

The oldest claim about trendlines — "steep lines break more easily" — becomes testable the moment slope is normalized. The hypothesis:

H1 (our own, falsifiable): Third-touch entries on lines with normalized slope ≤ 0.25 ATR/bar hold more often (higher continuation expectancy after costs) than entries on steeper lines (> 0.25 ATR/bar), consistently across walk-forward blocks. Falsification: if steep-line entries match or beat shallow ones out-of-sample, the slope distinction is dropped from the setup description — the filter would be decoration.

The implementation buckets every trade by slope_norm at entry and reports both distributions. On the seeded synthetic demo, one bucket is empty (all detected lines are shallow) and the other hovers near zero — the correct output for a random walk, and precisely why the real test is your exported data.

8. The Evidence Question — and the Baseline

The mechanical question for any trendline system: does the line add anything beyond the pivots that built it? A line through two higher pivot lows is, at heart, a structured way of saying "the sequence of higher lows is intact" — and a simpler rule could say the same thing with just the pivots. The implementation tests the full system (line + touch + trigger + stop + targets) on your data, and the slope bucketing tests the line-specific claim. If the system works only because of the engulfing trigger and the structural stop, the data will show it; if the touch geometry adds something, that shows too. Either way, the line's contribution is measured rather than assumed.

No performance claims: no win rates, no "trendlines work 70% of the time" numbers — none verified, none published. The demo numbers below come from a seeded random walk where no edge can exist, and the harness reports exactly that.

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 = 19.50\) pips, cost \(C = 1\) pip, pip value \(V = \$10\) per lot: \(100 / (20.50 \times 10) = 0.4878\), floored to 0.48 lots — risking $98.38 including the spread. The outcome table for the three cases:

OutcomeRP&L
Full stop (Case 2)−1.05R−$103.43
TP1 only, then breakeven (50% booked)+0.70R+$68.87
TP1 + TP2 (80% booked, runner stopped at entry)+1.40R+$137.73
All three targets (Case 1)+2.19R+$215.47
Timeout, booked at sample end (Case 3)+0.56R+$55.09

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)

The timeout row is the one most guides omit, and it matters for the arithmetic: an open position that never resolves still occupies the risk budget and blocks the next setup on the same line. Systems that ignore unresolved trades systematically overstate their opportunity count. Log the timeouts; they are part of the sample.

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 slope-bucket hypothesis test on the full sample; costs per trade; entries at the trigger close; lines and pivots strictly confirmed (no repainting). On the seeded synthetic demo: the train grid scatters from −0.74R to +0.03R across cells (noise, as expected), the train-chosen configuration (tolerance 0.25, buffer 0.25) returns −0.15R on the validation block (n = 10) and +0.03R on test (n = 8), and the slope buckets are thin — all shallow lines, expectancy near zero. The correct report for data without structure: no tradeable edge, honestly measured.

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 the line builder, the setup finder with touch counting and no repainting, the three worked cases as literal OHLC arrays with assertions verifying every number quoted above (including the timeout outcome), and the full validation harness. Run it three ways:

python trendline_pullback.py examples    # verify the three worked cases
python trendline_pullback.py validate    # synthetic walk-forward + slope buckets
python trendline_pullback.py csv EURUSD_H1.csv   # the real test: your data
#!/usr/bin/env python3
"""
trendline_pullback.py - Trendline pullback entry: reference implementation.

Companion code for the AlgoSpecial guide "Trendline Pullback Trading"
(algospecial.com/blogs/trendline-pullback-trading). 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+.

OPERATIONAL DEFINITION (ours - stated so it can be tested)
----------------------------------------------------------
A trend line in this rule set is drawn mechanically, never by eye:

- Anchors: two consecutive confirmed fractal pivot lows (L=3, R=3,
  usable only 3 bars after forming - no repainting) where the second low
  is higher than the first. Line value at bar i: a_px + slope*(i - a_idx).
- Slope normalization: slope_norm = slope / ATR14 at the confirmation
  bar, expressed in ATR units per bar. This makes steepness comparable
  across instruments and regimes.
- Touch: a bar whose low is within tolerance of the line, where
  tolerance = tol_atr * ATR14 at the line's confirmation bar
  (tol_atr = 0.10 by default). The two anchors count as touches 1 and 2.
- Setup: after the line's confirmation, the third touch arms the line;
  the entry requires a bullish engulfing bar within 2 bars of that touch
  with a close at or above the line value.
- Stop: lowest low from the first touch through the trigger bar, minus
  buffer = max(2 pips, 0.15 * ATR14 at the trigger).
- Invalidation: a close below the line value minus the buffer kills the
  line (returns None).
- Targets: ref_high = highest high from anchor B through the bar before
  the trigger. TP1 = ref_high; TP2 = ref_high + 0.5*(ref_high - line at
  entry); TP3 = ref_high + 1.0*(same). Partials 50/30/20.

WHAT THIS FILE CONTAINS
-----------------------
1. The line builder, 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 (anchors, slope, touch count,
   trigger, stop, invalidation, timeout outcome).
3. A validation harness: 60/20/20 split, walk-forward, a slope-bucket
   hypothesis test (shallow vs steep lines - the article's H1) and a
   sensitivity grid over tolerance and buffer.

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

from __future__ import annotations

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

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


@dataclass
class TrendLine:
    a_idx: int
    b_idx: int
    a_px: float
    b_px: float
    slope: float
    slope_norm: float
    confirmed_idx: int
    tol: float

    def value(self, i: int) -> float:
        return self.a_px + self.slope * (i - self.a_idx)


@dataclass
class TLSetup:
    line: TrendLine
    entry_idx: int
    entry: float
    stop: float
    tp1: float
    tp2: float
    tp3: float
    touches: int
    slope_norm: float

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


def find_trendlines(bars: Sequence[Bar], left: int = 3, right: int = 3,
                    period: int = 14, tol_atr: float = 0.10) -> List[TrendLine]:
    """Lines through consecutive confirmed pivot lows (rising)."""
    lows = fractal_lows(bars, left, right)
    lines: List[TrendLine] = []
    for k in range(len(lows) - 1):
        a, b = lows[k], lows[k + 1]
        if bars[b].l <= bars[a].l:
            continue
        slope = (bars[b].l - bars[a].l) / (b - a)
        confirmed = b + right
        if confirmed >= len(bars):
            continue
        a_atr = atr_sma(bars, confirmed, period)
        if a_atr is None:
            continue
        lines.append(TrendLine(a, b, bars[a].l, bars[b].l, slope,
                               slope / a_atr, confirmed, tol_atr * a_atr))
    return lines


def find_tl_setup(bars: Sequence[Bar], line: TrendLine, buffer_mult: float = 0.15,
                  period: int = 14) -> Optional[TLSetup]:
    """Third touch arms the line; engulfing within 2 bars triggers."""
    touches = 2
    last_touch = -1
    min_low: Optional[float] = None
    for i in range(line.confirmed_idx + 1, len(bars)):
        bar, prev = bars[i], bars[i - 1]
        lv = line.value(i)
        a = atr_sma(bars, i, period)
        if a is None:
            continue
        buffer = max(2 * PIP, buffer_mult * a)
        if bar.c < lv - buffer:          # line broken
            return None
        if abs(bar.l - lv) <= line.tol:
            touches += 1
            last_touch = i
        if touches > 2:
            min_low = bar.l if min_low is None else min(min_low, bar.l)
        if (touches >= 3 and last_touch >= 0 and i - last_touch <= 2
                and is_bullish_engulfing(prev, bar) and bar.c >= lv):
            stop = min_low - buffer
            if stop >= bar.c:
                continue
            ref_high = max(x.h for x in bars[line.b_idx:i])
            span = ref_high - lv
            return TLSetup(line, i, bar.c, stop, ref_high,
                           ref_high + 0.5 * span, ref_high + span,
                           touches, line.slope_norm)
    return None


def collect_tl_results(bars: Sequence[Bar], tol_atr: float = 0.10,
                       buffer_mult: float = 0.15, spread_pips: float = 1.0,
                       start_idx: int = 0) -> List[Tuple[TLSetup, object]]:
    out: List[Tuple[TLSetup, object]] = []
    last_exit = -1
    for line in find_trendlines(bars, tol_atr=tol_atr):
        if line.confirmed_idx < start_idx or line.confirmed_idx <= last_exit:
            continue
        setup = find_tl_setup(bars, line, buffer_mult)
        if setup is None:
            continue
        res = simulate_long(bars, setup, spread_pips)
        out.append((setup, res))
        last_exit = res.exit_idx
    return out


def run_tl_strategy(bars: Sequence[Bar], tol_atr: float = 0.10,
                    buffer_mult: float = 0.15, spread_pips: float = 1.0,
                    start_idx: int = 0) -> List:
    return [res for (_, res) in collect_tl_results(bars, tol_atr, buffer_mult,
                                                   spread_pips, start_idx)]


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

PRE = [
    Bar(1.07600, 1.07780, 1.07580, 1.07760),  # 0
    Bar(1.07760, 1.07920, 1.07740, 1.07900),  # 1
    Bar(1.07900, 1.08060, 1.07880, 1.08040),  # 2
    Bar(1.08040, 1.08200, 1.08020, 1.08160),  # 3
    Bar(1.08160, 1.08320, 1.08140, 1.08300),  # 4
    Bar(1.08300, 1.08420, 1.08280, 1.08360),  # 5
]
STRUCT = [
    Bar(1.08360, 1.08380, 1.08000, 1.08060),  # 6  anchor A (low 1.08000)
    Bar(1.08060, 1.08220, 1.08020, 1.08200),  # 7
    Bar(1.08200, 1.08360, 1.08180, 1.08340),  # 8
    Bar(1.08340, 1.08500, 1.08320, 1.08480),  # 9
    Bar(1.08480, 1.08640, 1.08460, 1.08600),  # 10
    Bar(1.08600, 1.08640, 1.08480, 1.08520),  # 11
    Bar(1.08520, 1.08540, 1.08380, 1.08420),  # 12
    Bar(1.08420, 1.08460, 1.08280, 1.08320),  # 13
    Bar(1.08320, 1.08360, 1.08200, 1.08340),  # 14 anchor B (low 1.08200)
    Bar(1.08340, 1.08500, 1.08320, 1.08480),  # 15
    Bar(1.08480, 1.08620, 1.08460, 1.08600),  # 16
    Bar(1.08600, 1.08760, 1.08580, 1.08740),  # 17 (B confirmed here)
    Bar(1.08740, 1.08800, 1.08680, 1.08760),  # 18
    Bar(1.08760, 1.08820, 1.08600, 1.08640),  # 19
    Bar(1.08640, 1.08660, 1.08480, 1.08520),  # 20
    Bar(1.08520, 1.08530, 1.08360, 1.08490),  # 21 third touch (1.5 pips)
]
TRIGGER = Bar(1.08480, 1.08560, 1.08460, 1.08530)  # 22 engulfing close
VALID_CONT = [
    Bar(1.08530, 1.08680, 1.08510, 1.08660),  # 23
    Bar(1.08660, 1.08800, 1.08620, 1.08780),  # 24
    Bar(1.08780, 1.08860, 1.08740, 1.08840),  # 25 TP1 1.08820 hit
    Bar(1.08840, 1.08960, 1.08800, 1.08940),  # 26
    Bar(1.08940, 1.09050, 1.08900, 1.09030),  # 27 TP2 1.09030 hit
    Bar(1.09030, 1.09140, 1.08990, 1.09120),  # 28
    Bar(1.09120, 1.09220, 1.09080, 1.09200),  # 29
    Bar(1.09200, 1.09280, 1.09160, 1.09260),  # 30 TP3 1.09240 hit
]
FAILED_CONT = [
    Bar(1.08530, 1.08550, 1.08440, 1.08460),  # 23f
    Bar(1.08460, 1.08480, 1.08360, 1.08380),  # 24f
    Bar(1.08380, 1.08400, 1.08280, 1.08300),  # 25f low <= stop -> filled
]
TIMEOUT_CONT = [
    Bar(1.08530, 1.08640, 1.08500, 1.08620),  # 23
    Bar(1.08620, 1.08720, 1.08580, 1.08700),  # 24
    Bar(1.08700, 1.08790, 1.08660, 1.08770),  # 25
    Bar(1.08770, 1.08810, 1.08730, 1.08750),  # 26 peak 1.08810, TP1 missed by 1 pip
    Bar(1.08750, 1.08770, 1.08660, 1.08680),  # 27
    Bar(1.08680, 1.08720, 1.08580, 1.08600),  # 28
    Bar(1.08600, 1.08660, 1.08560, 1.08620),  # 29
    Bar(1.08620, 1.08680, 1.08580, 1.08650),  # 30 sample end
]


def verify_worked_cases() -> None:
    valid = PRE + STRUCT + [TRIGGER] + VALID_CONT
    failed = PRE + STRUCT + [TRIGGER] + FAILED_CONT
    timeout = PRE + STRUCT + [TRIGGER] + TIMEOUT_CONT

    lines = find_trendlines(valid)
    assert lines and lines[0].a_idx == 6 and lines[0].b_idx == 14
    tl = lines[0]
    assert abs(tl.slope - 0.00025) < 1e-12
    assert abs(tl.slope_norm - tl.slope / atr_sma(valid, 17)) < 1e-12

    setup = find_tl_setup(valid, tl)
    assert setup is not None and setup.entry_idx == 22
    assert abs(setup.entry - 1.08530) < 1e-12
    assert setup.touches == 3
    assert setup.stop < setup.entry
    res_v = simulate_long(valid, setup, spread_pips=1.0)
    assert res_v.outcome == "all_targets" and len(res_v.tp_hits) == 3

    lines_f = find_trendlines(failed)
    setup_f = find_tl_setup(failed, lines_f[0])
    assert setup_f is not None
    res_f = simulate_long(failed, setup_f, spread_pips=1.0)
    assert res_f.outcome == "stop"

    lines_t = find_trendlines(timeout)
    setup_t = find_tl_setup(timeout, lines_t[0])
    assert setup_t is not None and setup_t.entry_idx == 22
    res_t = simulate_long(timeout, setup_t, spread_pips=1.0)
    assert res_t.outcome == "timeout" and res_t.tp_hits == []


def print_worked_cases() -> None:
    valid = PRE + STRUCT + [TRIGGER] + VALID_CONT
    failed = PRE + STRUCT + [TRIGGER] + FAILED_CONT
    timeout = PRE + STRUCT + [TRIGGER] + TIMEOUT_CONT

    tl = find_trendlines(valid)[0]
    setup = find_tl_setup(valid, tl)
    a_conf = atr_sma(valid, tl.confirmed_idx)
    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("Anchors: A idx %d low %.5f, B idx %d low %.5f (confirmed at %d)"
          % (tl.a_idx, tl.a_px, tl.b_idx, tl.b_px, tl.confirmed_idx))
    print("Slope = %.1f pips/bar; ATR14 at confirmation = %.2f pips"
          % (pips(tl.slope), pips(a_conf)))
    print("Normalized slope = %.3f ATR/bar" % tl.slope_norm)
    print("Tolerance = 0.10 x %.2f = %.2f pips" % (pips(a_conf), pips(tl.tol)))
    print("Line value at touch B21 = %.5f; low 1.08360 -> 1.5 pips away"
          % tl.value(21))
    print("Third touch B21, trigger B22 within 2 bars: 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.08360) - buffer = %.5f" % setup.stop)
    print("Risk = %.2f pips" % pips(setup.risk))
    print("Ref high (B14..B21 max) = %.5f; line at entry = %.5f"
          % (setup.tp1, tl.value(22)))
    for name, tp in (("TP1", setup.tp1), ("TP2", setup.tp2), ("TP3", 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 line broken)")
    print("=" * 66)
    lines_f = find_trendlines(failed)
    setup_f = find_tl_setup(failed, lines_f[0])
    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("B25f low 1.08280 <= stop %.5f -> filled at stop" % setup_f.stop)
    print("B25f close 1.08300 is below line - buffer -> line broken")
    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 (timeout: neither TP1 nor stop)")
    print("=" * 66)
    lines_t = find_trendlines(timeout)
    setup_t = find_tl_setup(timeout, lines_t[0])
    res_t = simulate_long(timeout, setup_t, spread_pips=1.0)
    print("Trigger at B22 as before; price peaks at 1.08810 - TP1 1.08820")
    print("missed by 1.0 pip; stop %.5f never touched; sample ends at %.5f"
          % (setup_t.stop, timeout[-1].c))
    print("Outcome: %s at bar %d; realized %.2fR (open position booked at end)"
          % (res_t.outcome, res_t.exit_idx, res_t.realized_r))
    print("With a 5-bar time stop (exit at B27 close 1.08680): %.2fR"
          % ((1.08680 - setup_t.entry) / setup_t.risk - 0.0001 / setup_t.risk))
    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_tl_strategy(train, tol, 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_tl_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("\nSlope hypothesis test (whole sample, chosen config):")
    pairs = collect_tl_results(bars, chosen[0], chosen[1])
    shallow = metrics([res for (s, res) in pairs if s.slope_norm <= 0.25])
    steep = metrics([res for (s, res) in pairs if s.slope_norm > 0.25])
    print("shallow (<=0.25 ATR/bar): n=%d expectancy=%.3fR" % (shallow["n"], shallow.get("expectancy_r", 0.0)))
    print("steep   (>0.25 ATR/bar): n=%d expectancy=%.3fR" % (steep["n"], steep.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 Trendlines

Redrawing after the outcome. The cardinal sin, and the reason most trendline backtests are fiction. If the line moved after the touch failed, the test measured nothing. The pivot-fit algorithm exists so that the line is frozen at confirmation and cannot be negotiated with.

Touch inflation. Counting every wick that "kind of" touches the line produces lines with nine touches, each one adding confidence and none of them adding information. One tolerance (0.10×ATR), declared once, applied forever. A touch is a Boolean, not a vibe.

Steep-line chasing. Steep lines look decisive and break constantly — the line that rises 8 pips per bar is not a support structure, it is a trend at its most fragile point. Normalize the slope (ATR/bar) and treat steepness as a measured variable, not a personality trait.

Using the line as a ceiling and floor simultaneously. A rising line is a support claim. The moment price closes through it, the claim is void — it does not become resistance, it becomes history. Flip-trades on broken trendlines are a different setup with different statistics; do not smuggle them into the pullback's track record.

Ignoring log scale on long horizons. Over months, equal pip distances are not equal percentage distances, and a linear line systematically misprices the geometry. Within H1-D1 swing horizons the effect is second-order; beyond them, switch to log and say so in the test.

No time stop, no plan for limbo. Case 3 in miniature: the trade that neither works nor fails. Without a written time-exit rule, every such trade becomes a discretionary decision, and discretionary decisions do not aggregate into statistics. Pick a rule (5-bar time stop, trail to breakeven after TP1) and test it — but write it down first.

13. FAQ

How do you draw a trendline without subjectivity?

Anchor it to two consecutive confirmed fractal pivot lows (3 bars each side, usable 3 bars after forming) with the second higher. The line is a formula, L(i) = a_px + slope x (i - a_idx), fixed at confirmation — there is no drawing left to do.

What counts as a touch?

A bar whose low is within 0.10×ATR14 (measured at confirmation) of the line. Anchors are touches 1 and 2; the third touch arms the setup; the entry still requires an engulfing close at or above the line within 2 bars of the touch.

What is normalized slope?

Slope divided by ATR14 at confirmation, in ATR units per bar. The worked line: 2.5 pips/bar on an 18.71-pip ATR = 0.134 ATR/bar. Normalization makes steepness comparable and testable across instruments and regimes.

Do trendlines repaint?

Hand-drawn ones do. This rule set cannot: anchors are confirmed pivots, the line is fixed at the confirmation bar, and scanning starts only after it. The backtest trades what was knowable at the time.

Where does the stop go?

Below the lowest low from the first touch through the trigger, minus max(2 pips, 0.15×ATR14). A close below the line minus the buffer kills the line permanently.

What is the timeout problem?

The rules define stop, targets and invalidation but no time exit. Case 3 shows a trade that neither hit TP1 (missed by 1 pip) nor stopped, ending at +0.56R booked at sample end (+0.72R with a 5-bar time stop). An undefined exit is a system gap — measure it, then write the rule.

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