Part 3 of 3
Building an Opening Range Breakout System in NinjaTrader with ScalpTrader TradeEvents Integration

Part 3: Closing the Loop

June 30, 2026

·

7 min read

NinjaTrader

In Part 1, I outlined the idea — an Opening Range Breakout strategy built specifically to produce a clean trade lifecycle. In Part 2, I walked through how that lifecycle is implemented as a state machine in NinjaScript. In this last post, I’ll cover the other half: how the strategy reports what it’s doing to ScalpTrader’s TradeEvents dashboard, and what that data looks like once it lands.

Keeping webhook logic out of the trading logic

If you look back at Part 2, none of the state-machine code — CheckForBreakout, CheckForExit, OnExecutionUpdate — talks to a webhook endpoint directly. Instead it calls a handful of small, intention-named methods: SendCard(), SendPositionOpened(), SendPositionClosed(...). Those, in turn, delegate to a WebhookHelper instance:

csharp
private WebhookHelper webhook;

// State.Configure
webhook = new WebhookHelper("ORBDemo_Full", this);
webhook.Version = VERSION;
webhook.Configure(WebhookUrl, SystemName);

The reasoning here is the same as the state-machine split in Part 2: keep each concern in its own lane. The trading logic shouldn’t need to know what an HTTP request looks like, what the JSON schema is, or how retries are handled — it just needs to say “a position opened, here’s the context.” That separation also means the webhook layer can be swapped, extended, or disabled (WebhookEnabled = false) without touching a single line of the actual strategy logic.

WebhookHelper exposes a small surface to the strategy:

  • Configure(url, systemName) — validates and stores the webhook target
  • IsConfigured — whether setup succeeded (used as a guard before every send)
  • FlushWarmupQueue() — sends anything queued up while the strategy was still catching up on historical bars, once it reaches realtime
  • ShouldSendCard() — rate-limits the periodic event to roughly once a minute
  • SendEvent(eventType, jsonPayload, condition) — does the actual send, optionally gated by a condition (this strategy uses it to skip sending stale events for a prior day via IsTodaySession())
  • FP(double) / the static F(double) — price/number formatters, so JSON payloads built as raw strings stay consistently formatted

Wiring up the connection

Getting events flowing from the strategy to the dashboard is just two fields on the strategy:

  1. In ScalpTrader, open the system you want this strategy to report to and copy its Webhook URL — it’ll look like https://tradeevents.scalptrader.com/events/user_xxxx/tl-xxxxxxxx.
  2. Paste that into the strategy’s Webhook URL property.
  3. Set System Name to match the name shown in the ScalpTrader dashboard exactly — events get rejected with a 401 if it doesn’t match.
  4. Flip Webhook Enabled to true.

The auth here is entirely embedded in the URL path (the user_xxxx/tl-xxxxxxxx segment), so there’s no separate API key to manage — which keeps the strategy-side setup to just pasting two values into the property grid.

The event payloads

Three event types fire over the course of a session: one running periodically while the strategy is active, and two marking the start and end of a trade.

card — periodic status

Sent roughly once a minute (gated by ShouldSendCard()), this is what keeps the dashboard “alive” — it’s the only event that fires when the strategy isn’t actively entering or exiting a trade. The payload shape changes a bit depending on what the strategy is doing:

json
{
  "status": "in_position",
  "state_detail": "In LONG — +3.25 pts",
  "symbol": "ES",
  "price": 5432.25,
  "range_high": 5430.00,
  "range_low": 5425.50,
  "direction": "long",
  "quantity": 1,
  "entry_price": 5429.00,
  "unrealized_pnl": 162.50,
  "position_id": "a1b2c3d4"
}

While waiting for the session or still building the range, status and state_detail change accordingly (“waiting_for_session” / “Waiting for NY open”, “active” / “Building opening range (15m)”), and the position-specific fields are simply omitted — the strategy only includes the fields that are actually meaningful for its current state, rather than sending a fixed schema padded with nulls.

position_opened — entry fill

Fired the moment an entry order fills (or, during warmup/backtesting, the moment a simulated entry happens):

json
{
  "strategy": "ORBDemo_Full",
  "symbol": "ES",
  "version": "1.0.0",
  "direction": "LONG",
  "quantity": 1,
  "entry_price": 5429.00,
  "range_high": 5430.00,
  "range_low": 5425.50,
  "position_id": "a1b2c3d4"
}

The range_high/range_low fields are included here specifically so the dashboard can show why the trade was taken without needing to look anything else up — the range that triggered the breakout travels with the event.

position_closed — exit fill

Fired on any exit path — range-flip stop, session close, or an externally detected flatten:

json
{
  "strategy": "ORBDemo_Full",
  "symbol": "ES",
  "version": "1.0.0",
  "direction": "LONG",
  "quantity": 1,
  "entry_price": 5429.00,
  "exit_price": 5425.25,
  "pnl_points": -3.75,
  "pnl_dollars": -187.50,
  "exit_reason": "RangeStop",
  "position_id": "a1b2c3d4"
}

position_id is the same token generated at entry, which is what lets TradeEvents — or any consumer of these events — line a position_closed event up with the position_opened event that started it, since NinjaTrader itself doesn’t expose a stable ID for a position across its lifetime.

exit_reason is worth a second look: it’s literally the signal name passed to ExitLong/ExitShort back in the strategy code (“RangeStop”, “SessionEnd”), plus the synthetic “ExternalClose” value used when OnPositionUpdate detects a flatten the strategy didn’t initiate itself. That one field is enough for the dashboard to distinguish “the strategy’s own stop logic closed this” from “someone closed this by hand.”

Where these calls actually fire from

To tie this back to Part 2’s walkthrough — the send calls live at exactly the points you’d expect:

  • SendCard() — once per bar, gated by ShouldSendCard(), from the tail end of OnBarUpdate
  • SendPositionOpened() — from OnExecutionUpdate on a real entry fill, and from SimulateEntry() during warmup (ORBDemo_Full only)
  • SendPositionClosed() — from OnExecutionUpdate on a real exit fill, from SimulateExit() during warmup (ORBDemo_Full only), and from OnPositionUpdate on an external flatten

Because the same two methods (SendPositionOpened/SendPositionClosed) are called from both the real-fill and simulated-fill code paths, the event stream looks identical whether the strategy is running live or chewing through historical bars — which is what makes it possible to preview a full day’s worth of dashboard activity just by loading the strategy and letting it warm up, without waiting on a live breakout.

Wrapping up

Across this series: Part 1 made the case for Opening Range Breakout as a strategy that’s simple to explain but produces a real trade lifecycle. Part 2 showed how that lifecycle becomes a small, explicit state machine in NinjaScript, with a clean split between real and simulated fills. This post showed the other half — turning those state transitions into a handful of well-defined webhook events, and how TradeEvents turns that into a live picture of what the strategy is doing.

The strategy itself is intentionally simple — that was the point. But the pattern (clear states, one helper class doing all the reporting, a position ID tying opens to closes) scales fine to a strategy with real edge behind it, which is really the takeaway for this whole series: good telemetry is mostly a matter of being deliberate about what a “state” is, and reporting it consistently.