OnStateChange is the lifecycle hook NinjaTrader calls as your indicator or strategy moves through defined states. Put the wrong work in the wrong state and you get silent misbehavior — most dangerously, a strategy that trades differently on historical bars than on live bars, so your backtest describes a system you will never actually run. This article is educational only and is not trading advice; nothing here implies any result or profitability.
OnStateChange is a method NinjaTrader calls every time your script transitions between states, and the current state is available as the State property. You do not call it yourself; the framework drives it. The transitions run in order: State.SetDefaults, State.Configure, State.DataLoaded, State.Historical, State.Transition, State.Realtime, and finally State.Terminated. Each state exists so that a specific kind of setup happens at the one moment it is safe to happen. The standard pattern is a switch on State:
protected override void OnStateChange()
{
if (State == State.SetDefaults)
{
Name = "MyStrategy";
Calculate = Calculate.OnBarClose;
IsOverlay = false;
}
else if (State == State.Configure)
{
AddDataSeries(BarsPeriodType.Minute, 15);
}
else if (State == State.DataLoaded)
{
atr = ATR(14);
}
}The mental model that keeps you out of trouble: SetDefaults describes the script, Configure requests the data and account resources it needs, DataLoaded wires up indicators and objects that depend on that data, and Historical/Realtime mark which phase of price data you are processing. Everything else is the framework moving you between those points.
State.SetDefaults runs first and runs even before the user configures the script, so it must only set properties: Name, Description, Calculate, IsOverlay, default input parameters, and for strategies things like EntriesPerDirection and IsInstantiatedOnEachOptimizationIteration. Do not create indicators or read live data here — the data does not exist yet.
State.Configure is where you request resources. This is the correct place to call AddDataSeries to add a secondary instrument or timeframe, because the framework needs to know your full data requirements before it loads anything. Adding a series later, or conditionally after Configure, leads to unsynced multi-series bugs.
State.DataLoaded runs after all Bars objects exist, which is exactly why indicator instantiation belongs here. Calling ATR(14), EMA(20), or your own custom indicators in DataLoaded guarantees they are bound to loaded data. Instantiating an indicator in SetDefaults will throw or return garbage because the price series it needs is not there yet. Keep a field for each and assign it once in DataLoaded rather than re-creating indicators inside OnBarUpdate, which is wasteful and can leak.
When you run a strategy, NinjaTrader first replays stored bars to bring your logic up to the present — that is the historical phase, State.Historical. Once it reaches live incoming data, it flips to State.Realtime. The State.Transition step fires at the exact boundary, which is your chance to reconcile anything that only exists live, such as reading real account positions or syncing to an existing live order.
The reason these states are exposed at all is legitimate: a few operations genuinely only make sense live. You cannot subscribe to a live level-2 feed against historical bars, and account-level reconciliation only matters once real fills exist. NinjaTrader itself uses this split internally — historical fills are simulated against bar data, live fills come from the broker.
The trap is that because the state is readable, people start branching their trading logic on it. That is where backtests quietly stop describing reality.
Here is the failure. Someone writes an entry rule, it does not fire the way they want on historical bars, so they special-case it:
// WRONG: trading logic diverges between backtest and live
protected override void OnBarUpdate()
{
if (CurrentBar < 20) return;
if (State == State.Realtime)
{
// extra confirmation only applied live
if (Close[0] > highBand[0] && Close[0] > Open[0])
EnterLong();
}
else
{
// looser rule during the historical replay
if (Close[0] > highBand[0])
EnterLong();
}
}Your backtest runs almost entirely on the else branch, so its equity curve is built from the looser rule. Live, you trade the stricter rule. The numbers you validated belong to a system you are not running. This is a specific, common form of the problem covered in [why most backtests fail](/learn/why-most-backtests-fail) — the tested logic and the deployed logic are not the same code path.
The fix is discipline: trading conditions must be identical in both states. Reserve the state check for non-trading concerns only:
protected override void OnBarUpdate()
{
if (CurrentBar < 20) return;
// one rule, both phases
if (Close[0] > highBand[0] && Close[0] > Open[0])
EnterLong();
}
protected override void OnStateChange()
{
if (State == State.Transition)
{
// fine: reconcile live account state at the boundary
}
}If you must run the free [NinjaScript validator](/ninjascript), it flags state-conditional entry and exit calls precisely because they are a reliable source of backtest-live divergence.
Calculate is set in SetDefaults and controls how often OnBarUpdate fires: Calculate.OnBarClose fires once per closed bar, Calculate.OnEachTick fires on every incoming tick, and Calculate.OnPriceChange fires when the price changes. This choice interacts with the historical/realtime split in a way people underestimate. Historical bars have no tick stream unless you enable Tick Replay, so with Calculate.OnEachTick your historical bars behave as if only the closing tick arrived, while live bars fire on every tick. If your logic reads intrabar values, it will behave differently in each phase — see [repainting](/learn/repainting) for how that manifests.
When you do need intrabar granularity live, guard the once-per-bar work with IsFirstTickOfBar so it does not run on every tick:
protected override void OnBarUpdate()
{
if (IsFirstTickOfBar)
{
// runs once per new bar even under OnEachTick
}
}The lifecycle is not decoration. SetDefaults describes, Configure requests, DataLoaded wires, Historical/Realtime mark the phase, and OnBarUpdate does the work under the cadence Calculate sets. Keep each responsibility in its own state and the backtest you read is the strategy you ship. This is educational material, not trading advice, and describes API behavior rather than any expected outcome.
Educational only — not financial advice. Trading involves substantial risk of loss.