Part 2: Implementing It in NinjaScript
In Part 1, I laid out the idea: a simple Opening Range Breakout (ORB) strategy on ES, built specifically to produce a clean, easy-to-follow trade lifecycle for the TradeEvents series. In this post I’ll walk through how ORBDemo_Full is actually built — the state machine, the bar-by-bar logic, and how order fills get turned into the data the strategy needs to report on.
This isn’t a NinjaScript tutorial from scratch — I’m assuming some familiarity with the Strategy base class. The goal here is to show how a small, well-defined trade idea maps onto NinjaScript’s lifecycle in a way that stays easy to reason about.
The state machine
Everything in this strategy hangs off one enum:
public enum ORBState
{
WaitingForSession, // Before NY open
BuildingRange, // First 15 minutes - tracking high/low
WaitingForBreakout, // Range set, waiting for close above/below
InPosition, // In a trade
Done // Trade completed for this session
}Each session, the strategy moves through these states in a strict line — there’s no going backwards, and no branching outside of “did we breakout long, short, or not at all.” That’s deliberate. A strategy with a handful of independent boolean flags (isInRange, hasEntered, isFlat...) tends to accumulate impossible states the longer it runs. An enum-driven state machine makes the current situation a single source of truth — which matters a lot once that state is also what gets reported out over a webhook (more on that in Part 3).
OnBarUpdate is just a dispatcher over this enum:
protected override void OnBarUpdate()
{
if (CurrentBar < BarsRequiredToTrade)
return;
if (!IsTodaySession())
return;
CalculateSessionTimes();
switch (orbState)
{
case ORBState.WaitingForSession:
CheckSessionStart();
break;
case ORBState.BuildingRange:
BuildOpeningRange();
break;
case ORBState.WaitingForBreakout:
CheckForBreakout();
break;
case ORBState.InPosition:
CheckForExit();
break;
case ORBState.Done:
// Nothing to do - session trade completed
break;
}
if (WebhookEnabled && webhook.ShouldSendCard())
SendCard();
}Each state has exactly one method responsible for moving the strategy forward, which makes it easy to find the logic for “what happens during the range-building period” without reading the whole file.
Walking through each state
WaitingForSession → BuildingRange
CheckSessionStart just waits for the bar time to cross the NY open (adjusted for TimezoneOffset), then seeds the range with the first bar’s high/low:
private void CheckSessionStart()
{
DateTime nyOpen = new DateTime(Time[0].Year, Time[0].Month, Time[0].Day, 9, 30, 0)
.AddHours(TimezoneOffset);
if (Time[0] >= nyOpen)
{
orbState = ORBState.BuildingRange;
rangeStartTime = nyOpen;
rangeEndTime = nyOpen.AddMinutes(RangePeriodMinutes);
rangeHigh = High[0];
rangeLow = Low[0];
}
}BuildingRange → WaitingForBreakout
Each new bar potentially widens the range. Once RangePeriodMinutes has elapsed, the range is considered final, and the strategy draws it on the chart as a reference box:
private void BuildOpeningRange()
{
if (High[0] > rangeHigh) rangeHigh = High[0];
if (Low[0] < rangeLow) rangeLow = Low[0];
if (Time[0] >= rangeEndTime)
{
orbState = ORBState.WaitingForBreakout;
Draw.Rectangle(this, "ORB_Range_" + rangeStartTime.ToString("yyyyMMdd"), true,
rangeStartTime, rangeHigh, rangeEndTime, rangeLow,
Brushes.DodgerBlue, new SolidColorBrush(Color.FromArgb(15, 0, 120, 255)), 50);
}
}Drawing the range isn’t required for the trade logic — it’s there so that anyone watching the chart can visually confirm the strategy is tracking the same range the upcoming webhook events will reference.
WaitingForBreakout → InPosition
This is the entry trigger: a bar close outside the range, checked only after confirming the session hasn’t already ended:
private void CheckForBreakout()
{
if (Time[0] >= todaySessionEnd)
{
orbState = ORBState.Done;
return;
}
if (Close[0] > rangeHigh)
{
if (State == State.Realtime)
{
if (Position.MarketPosition != MarketPosition.Flat)
return; // safety: don't double-enter
EnterLong(Contracts, "ORBLong");
}
else
{
SimulateEntry("LONG", Close[0]);
}
}
else if (Close[0] < rangeLow)
{
// mirror image for SHORT
}
}Two things worth noting here. First, the flat-check immediately before EnterLong/EnterShort — this guards against the strategy somehow firing twice into the same direction. Second, the State == State.Realtime branch — which is its own topic, covered next.
InPosition → Done
CheckForExit checks for either of the two exit conditions — session end, or a close back through the opposite side of the range — and calls ExitLong/ExitShort accordingly:
private void CheckForExit()
{
if (entryPrice == 0) return;
string exitReason = null;
if (Time[0] >= todaySessionEnd)
exitReason = "SessionEnd";
else if (positionDirection == "LONG" && Close[0] < rangeLow)
exitReason = "RangeStop";
else if (positionDirection == "SHORT" && Close[0] > rangeHigh)
exitReason = "RangeStop";
if (exitReason != null)
{
if (State == State.Realtime)
{
if (Position.MarketPosition == MarketPosition.Long)
ExitLong(exitReason, "ORBLong");
else if (Position.MarketPosition == MarketPosition.Short)
ExitShort(exitReason, "ORBShort");
}
else
{
SimulateExit(Close[0], exitReason);
}
}
}Passing exitReason as the signal name on ExitLong/ExitShort is a small but useful trick — it shows up later as execution.Order.Name in OnExecutionUpdate, so the fill handler can tell why a position closed without tracking any extra state.
Warmup vs. realtime: two execution pathsIntermediate
This section is specific to ORBDemo_Full. If you’re looking at ORBDemo_Simple, the entry and exit methods simply guard on State == State.Realtime and skip anything historical — there’s no parallel simulation path. The rest of the post applies to both.
You’ll notice every entry/exit point in the Full version branches on State == State.Realtime. NinjaTrader strategies run through historical bars during warmup before they ever go live, and EnterLong/ExitLong etc. only generate real orders once the strategy reaches State.Realtime. During warmup, calling them would either no-op or throw, depending on context — so this strategy sidesteps the issue entirely with a parallel simulation path:
private void SimulateEntry(string direction, double price)
{
entryPrice = price;
quantity = Contracts;
positionDirection = direction;
positionId = Guid.NewGuid().ToString("N").Substring(0, 8);
orbState = ORBState.InPosition;
SendPositionOpened();
}SimulateEntry/SimulateExit update exactly the same tracking fields (entryPrice, positionDirection, positionId, orbState) that the real order-fill handlers update, and they call the same webhook senders. That means the webhook events fire consistently whether the strategy is chewing through historical bars or trading live — which matters once you’re trying to demo a dashboard and don’t want to wait for a live breakout to see what an event payload looks like.
Turning fills into position state
Once the strategy is live, real fills come back through OnExecutionUpdate. This strategy uses the order’s Name — the second argument passed to EnterLong/ExitLong — to figure out whether a fill was an entry or an exit, and if an exit, why:
protected override void OnExecutionUpdate(Execution execution, string executionId, double price,
int quantity, MarketPosition marketPosition, string orderId, DateTime time)
{
if (execution.Order == null) return;
if (execution.Order.OrderState != OrderState.Filled &&
execution.Order.OrderState != OrderState.PartFilled) return;
string orderName = execution.Order.Name;
if (orderName == "ORBLong" || orderName == "ORBShort")
{
// entry fill: capture price, direction, generate a position id
entryPrice = execution.Order.AverageFillPrice;
this.quantity = execution.Order.Filled;
positionDirection = orderName == "ORBLong" ? "LONG" : "SHORT";
positionId = Guid.NewGuid().ToString("N").Substring(0, 8);
orbState = ORBState.InPosition;
if (execution.Order.OrderState == OrderState.Filled)
SendPositionOpened();
}
else if (orderName == "SessionEnd" || orderName == "RangeStop")
{
// exit fill: compute P/L, fire the close event
double pnlPts = positionDirection == "LONG" ? (price - entryPrice) : (entryPrice - price);
SendPositionClosed(price, orderName);
ResetPositionTracking();
orbState = ORBState.Done;
}
}The positionId — a short random token generated on entry — is what ties a position_opened event to its eventual position_closed event on the dashboard side, since NinjaTrader doesn’t hand you a stable identifier for a position across its lifetime.
There’s one more wrinkle: positions don’t only close because the strategy asked them to. A fill could get rejected partway, or — more commonly in a demo/sim context — someone could flatten the position by hand from the NinjaTrader interface. OnPositionUpdate catches that case:
protected override void OnPositionUpdate(Position position, double averagePrice,
int quantity, MarketPosition marketPosition)
{
if (State != State.Realtime) return;
if (marketPosition == MarketPosition.Flat && orbState == ORBState.InPosition)
{
double exitPrice = averagePrice > 0 ? averagePrice : Close[0];
SendPositionClosed(exitPrice, "ExternalClose");
ResetPositionTracking();
orbState = ORBState.Done;
}
}Without this, an externally-closed position would leave the strategy stuck thinking it’s still InPosition — and would mean the dashboard never finds out the trade ended.
Safety rails
A couple of guardrails are worth calling out, since they’re easy to miss on a first read and matter if you’re adapting this strategy rather than just running it as-is:
- Sim-account lock. On transition to
State.Realtime, the strategy checksAccount.Nameand refuses to do anything unless it’s“Sim101”. This is a demo strategy; this check exists specifically to stop it from accidentally being run on a funded account. - Flat-check before entry. As shown above,
CheckForBreakoutverifiesPosition.MarketPosition == MarketPosition.Flatimmediately before callingEnterLong/EnterShort. - One trade per session. There’s no path back from
ORBState.Doneto any earlier state within the same session —ResetSession()only runs onState.DataLoaded, i.e. once per strategy start, not once per day. Worth flagging: as written, this means the strategy only resets its daily state on initial load — running it across multiple session days unattended would need a daily reset hook, which isn’t in scope for this demo.
What’s next
The strategy now produces a clean, well-defined sequence of state transitions and order fills. In Part 3, I’ll cover the other half: the WebhookHelper class that turns those transitions into HTTP calls, the actual payloads for the card, position_opened, and position_closed events, and what they look like once they land in the TradeEvents dashboard.