Learn NinjaScript: Tracking Highs and Lows Over Time
Running highs and lows are the building blocks of breakout indicators, pivot detection, stop placement, and risk sizing. “Highest high of the last 20 bars” is one function call. “Highest high of this session so far” is a loop. “Highest high since the last SMA cross” is a reset-on-trigger pattern. Different problems, different tools — and knowing which one fits keeps your code clean.
This post covers all three: MAX and MIN built-ins for fixed-lookback ranges, session-anchored tracking via Bars.IsFirstBarOfSession, and custom-trigger-reset patterns for extremes anchored to your own signals.
⚙️ MAX and MIN Built-ins
The cheapest case: a fixed-lookback rolling extreme. NinjaScript’s MAX(ISeries<double>, int period) returns the highest value in the series over the last period bars. MIN is the mirror. Both are indicator objects like SMA or EMA — you read them with [0], [1], etc.
double highest20 = MAX(High, 20)[0]; // highest High of the last 20 bars
double lowest20 = MIN(Low, 20)[0]; // lowest Low
// Works for any Series<double>, not just High/Low:
double highestClose20 = MAX(Close, 20)[0];
double highestCustom = MAX(mySeries, 20)[0];
Two things worth knowing. First, the period argument is an integer — you can’t pass a property that changes per bar, because the indicator caches its state against the value at instantiation. Second, MAX and MIN are strict-window — the 20th bar rolls off at the edge. They don’t count the first bar once it’s older than the window.
📅 Session Highs and Lows
A session-anchored extreme resets at the start of each trading day. Bars.IsFirstBarOfSession flips true on the first bar of each session — that’s your reset trigger:
private double sessionHigh;
private double sessionLow;
protected override void OnBarUpdate()
{
if (Bars.IsFirstBarOfSession)
{
sessionHigh = High[0];
sessionLow = Low[0];
}
else
{
sessionHigh = Math.Max(sessionHigh, High[0]);
sessionLow = Math.Min(sessionLow, Low[0]);
}
// Use sessionHigh / sessionLow — they update bar-by-bar within each session.
}
The pattern is clean: on the session’s first bar, seed from the bar’s own values; on subsequent bars, update with Math.Max / Math.Min. The trackers reset automatically at each session boundary.
For strategies or other indicators that want to read the session extremes at arbitrary history, make the trackers Series<double> (Week 10) instead of scalar fields. sessionHighSeries[3] then gives the session high value three bars ago.
🔄 Custom-Trigger-Anchored Extremes
“Highest high since the last SMA cross” uses the same reset-on-trigger shape, just with a different trigger:
private double highestSinceCross;
private bool trackingCross;
protected override void OnBarUpdate()
{
if (CurrentBar < Period) return;
bool crossedAbove = CrossAbove(Close, SMA(Period), 1);
if (crossedAbove)
{
highestSinceCross = High[0];
trackingCross = true;
}
else if (trackingCross)
{
highestSinceCross = Math.Max(highestSinceCross, High[0]);
}
// Reset when a downward cross invalidates the uptrend:
if (CrossBelow(Close, SMA(Period), 1))
trackingCross = false;
}
The pattern generalizes — any event can be the reset trigger. Breakout entry bar, a specific pattern detection, a session event. The shape is always “reset on trigger, accumulate after.”
⚡ Manual Loop vs Built-in Trade-off
MAX and MIN are optimized for fixed-period rolling windows. They maintain internal state incrementally, so they’re cheap to call on every bar. For that case, there’s no reason to write your own loop.
A manual loop becomes necessary when the lookback isn’t fixed, the window isn’t contiguous, or the “max” you want depends on a condition beyond the value itself. For example, “highest close among bars that also had above-average volume” — the built-in can’t express that filter, so you loop and check both conditions per bar.
// Manual loop: highest close among the last 20 bars where volume > 20-bar avg.
double result = double.MinValue;
double avgVol = SMA(Volume, 20)[0];
for (int i = 0; i < 20; i++)
{
if (Volumes[0][i] > avgVol && Close[i] > result)
result = Close[i];
}
More code, more flexibility. The trade-off is worth it when you genuinely need the condition; when you don’t, MAX is cheaper and clearer.
High Low Tracking Patterns
| Pattern | Best For | Complexity |
|---|---|---|
| MAX / MIN built-ins |
Fixed-lookback rolling high or low of any Series | One line. Incremental state management built in. |
| Session-anchored via IsFirstBarOfSession | Session highs and lows that reset at each session boundary. | Three lines per tracker. Reset on first bar, accumulate on the rest. |
| Custom-trigger reset pattern | Extremes anchored to your own signal events (cross, pattern, breakout). | Reset on trigger fire, accumulate while a tracking flag is set. |
| Manual loop | Conditional extremes (highest where some filter is true) or variable lookback windows. | More code than MAX. Use only when the condition actually requires it. |
📝 Pitfalls Checklist
- Trying to use a runtime-varying period with MAX/MIN. The period is fixed at instantiation. If you need a variable lookback, roll your own loop.
- Forgetting to reset session trackers. Without the
IsFirstBarOfSessionseed, the tracker carries yesterday’s extremes into today. - Using date comparisons instead of
IsFirstBarOfSession. Date comparisons break on session templates that don’t align with calendar days (overnight sessions). The built-in check handles that correctly. - Off-by-one on the trigger bar. “Highest since cross” — does the cross bar itself count as bar zero of the tracking? Pick a definition and be consistent. The example above includes the cross bar by seeding from
High[0]. - Using
High[N]when you meantMAX(High, N)[0]. The first is one bar’s high; the second is the highest high across N bars. Easy typo with very different semantics. - Scalar tracker when you need history. A scalar
sessionHighonly gives you the current bar’s value. If a strategy wants “session high three bars ago,” you need aSeries<double>.
🎉 Prop Trading Discounts
💥89% off at Bulenox.com with the code MDT89






