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.
| Bar | Open | High | Low | Close | Role |
|---|---|---|---|---|---|
| B6 | 1.08360 | 1.08380 | 1.08000 | 1.08060 | Anchor A (pivot low, confirmed at B9) |
| B14 | 1.08320 | 1.08360 | 1.08200 | 1.08340 | Anchor B (pivot low, confirmed at B17) |
| B21 | 1.08520 | 1.08530 | 1.08360 | 1.08490 | Third touch: low 1.08360 vs line 1.08375 (1.5 pips) |
| B22 | 1.08480 | 1.08560 | 1.08460 | 1.08530 | Trigger: 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:
| Bar | Open | High | Low | Close | Note |
|---|---|---|---|---|---|
| B23f | 1.08530 | 1.08550 | 1.08440 | 1.08460 | Stall |
| B24f | 1.08460 | 1.08480 | 1.08360 | 1.08380 | Sliding to the line |
| B25f | 1.08380 | 1.08400 | 1.08280 | 1.08300 | Low 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.
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:
| Outcome | R | P&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
- Pullback Trading Strategy — The Complete Guide (pillar)
- Fibonacci Pullback Strategy — Exact Rules and Three Worked Cases
- Order Block Pullback Entry — Displacement Thresholds
- Fair Value Gap Pullback — 3-Candle Rules and Fill Math
- 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.