DEV Community

laclance
laclance

Posted on

Detecting Support and Resistance Without Lookahead Bias in Go

Support and resistance is easy to draw after the fact.

Looking at a completed chart, a swing high around $51,000 or repeated lows around $49,000 can seem obvious. The difficulty starts when the same algorithm has to run one candle at a time in a live system.

At that point, one rule matters more than almost anything else:

A decision at candle N must not depend on candle N+1.

That sounds trivial. It is surprisingly easy to violate.

The hidden lookahead problem

Imagine defining a swing high as a candle whose high is greater than the highs of the three candles before it and the three candles after it.

That definition is perfectly reasonable for chart analysis.

But the three candles after the candidate pivot do not exist when the candidate candle closes.

If a backtest marks that swing high immediately, the strategy has learned something from the future.

The correct interpretation is different: the swing high may have occurred at candle N, but it only becomes confirmed after the required later candles have closed.

That distinction between pivot time and confirmation time is what keeps historical results honest.

Start with closed candles

The simplest boundary is to make closed candles the unit of computation.

type Candle struct {
    OpenTime  time.Time
    CloseTime time.Time
    Open      float64
    High      float64
    Low       float64
    Close     float64
    Volume    float64
}
Enter fullscreen mode Exit fullscreen mode

A strategy should not continually reinterpret an unfinished candle as though it were final.

Instead, a live system can append each newly closed candle and calculate support/resistance from the same type of input used by the backtest.

That makes the live and historical paths much easier to reason about.

Make the candle prefix the contract

Suppose the system has received 500 closed candles.

Everything calculated at that point should depend only on those 500 candles and the supplied options.

Given the same prefix again, the result should be deterministic.

That is the model used by go-sr:

levels, err := sr.Compute(candles, sr.Options{
    Timeframe:   "5m",
    Lookback:    120,
    Mode:        sr.ModeZones,
    MinStrength: 2,
})
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

The result includes the detected levels as well as strategy-oriented metadata:

levels.NearestSupport
levels.NearestResistance

levels.NearestSupportDistance
levels.NearestResistanceDistance

levels.NearestSupportStrength
levels.NearestResistanceStrength

levels.NearSupport
levels.NearResistance
Enter fullscreen mode Exit fullscreen mode

This lets strategy code consume support/resistance as data instead of treating it only as something to draw on a chart.

Confirmation does not mean immutability

There is an important nuance here.

“No lookahead” does not mean support and resistance must remain identical forever.

When new candles close:

  • new pivots can become confirmed;
  • existing zones can accumulate additional touches;
  • the rolling lookback window can move;
  • old structure can fall outside that window.

All of those can legitimately change the current S/R result.

The requirement is narrower and more useful:

The action you made at time T must have been computable using only information available at time T.

A later candle is allowed to change tomorrow’s answer. It must not secretly improve yesterday’s answer.

Lines versus zones

Support and resistance can also be represented in different ways.

A line-based implementation might merge nearby pivot prices according to a fixed percentage tolerance.

That is useful when you want simple, predictable horizontal levels.

A zone-based implementation recognizes that market structure is rarely exact to the cent. Volatility can be used to determine a more natural region around clustered pivots.

go-sr supports both approaches:

// ATR-aware zones
sr.Options{
    Timeframe:   "5m",
    Lookback:    120,
    Mode:        sr.ModeZones,
    MinStrength: 2,
}
Enter fullscreen mode Exit fullscreen mode

and:

// Traditional lines
sr.Options{
    Timeframe: "5m",
    Lookback:  120,
    Mode:      sr.ModeLegacy,
    Tolerance: 0.002,
}
Enter fullscreen mode Exit fullscreen mode

Neither representation solves lookahead by itself. The important part is when the underlying pivots are allowed to influence the result.

Multi-timeframe data has the same problem

The same rule applies when aggregating lower-timeframe candles.

If you are computing hourly S/R from 5-minute data, the current unfinished hourly candle should not be treated as a completed historical candle.

A useful architecture is:

exchange / historical data
        ↓
closed 5m candles
        ↓
completed higher-timeframe buckets
        ↓
support/resistance computation
        ↓
strategy
Enter fullscreen mode Exit fullscreen mode

This keeps the information boundary explicit.

For example:

candles15m := sr.AggregateCandlesToTimeframe(
    candles5m,
    "5m",
    "15m",
)
Enter fullscreen mode Exit fullscreen mode

The strategy can then run the same S/R calculation on those completed higher-timeframe candles.

Backtest/live parity is the real goal

Avoiding lookahead is not just a backtesting concern.

A useful trading component should behave the same way when its inputs come from:

  • a historical fixture;
  • an exchange REST API;
  • a WebSocket candle stream;
  • a broker;
  • a replay engine.

The market-data adapter may change, but the S/R algorithm should not need a special “backtest mode” that gets different information from the live version.

That is a strong architectural test.

If the historical implementation needs access to data that the live implementation cannot possibly have at the same moment, there is probably a timing problem.

Test the information boundary

Tests for technical indicators should go beyond checking a few expected prices.

Useful invariants include:

  • identical candle prefixes produce identical output;
  • only closed prefixes are passed to the calculation;
  • pivots are not exposed before their confirmation point;
  • adding future candles does not retroactively change decisions already recorded by the backtest;
  • multi-timeframe aggregation does not leak partial future buckets.

Those tests are less visually impressive than a perfect chart.

They are much more valuable.

A small Go implementation

I extracted these ideas into go-sr, an Apache-2.0 Go module with no external runtime dependencies:

go get github.com/laclance/go-sr@v1.1.0
Enter fullscreen mode Exit fullscreen mode

Repository:

https://github.com/laclance/go-sr

It includes line and zone modes, nearest-level metadata, multi-timeframe helpers, and a standalone runnable example.

I’m particularly interested in real integration feedback. If you already have a Go backtester or trading bot with its own OHLCV type, I’d like to know whether the API drops naturally into that pipeline.

Because for support and resistance, the hardest question usually is not:

“Did it identify the level?”

It is:

“When was the algorithm actually allowed to know that level existed?”

Top comments (0)