DEV Community

ssapable
ssapable

Posted on

A 177-line NinjaTrader add-on that only listens: fills to CSV, and what the Python side does with them

My partner trades micro futures on a prop-firm account that bans fully automated trading. I wanted a tool that checks every fill against her own rules the moment it happens. Those two facts decide the design: the NinjaTrader side may read, and it may write files. It may not touch an order.

This post is the add-on, line by line where it matters, the stand-in types I compile it against because the trading PC isn't mine, and the Python loop that follows the files. Honest status up front: it compiles clean against stubs and has not yet run inside a real NinjaTrader 8. If you've shipped an NT8 add-on, the last section is a list of things I'd like you to look at.

What it writes

Three CSV files in Documents\tilt-check, headers written once:

fills.csv      time_utc,account,instrument,signed_qty,price,execution_id,platform_time
orders.csv     time_utc,account,instrument,order_id,action,type,state,qty,stop_price,limit_price
snapshots.csv  time_utc,account,unrealized_usd,open_positions,cash_value
Enter fullscreen mode Exit fullscreen mode

Signed quantity is the one choice I'd defend hardest: +2 for a buy of two, −1 for a sell of one. With that, the Python side rebuilds the position per instrument by summing, and never has to guess which rows belong to which "trade". NinjaTrader's own trade export has one row per exit, and a scaled-out entry becomes several overlapping rows; raw executions don't have that problem.

Two timestamps per fill. time_utc is when the event reached this PC, platform_time is NinjaTrader's own stamp in whatever zone NinjaTrader is set to. When they disagree you learn something about your setup.

The add-on

It's an AddOnBase. Three states matter:

protected override void OnStateChange()
{
    if (State == State.SetDefaults)
    {
        Name        = "TiltCheckFeed";
        Description = "Writes fills, orders and account P&L to Documents\\tilt-check for tilt-check. Never places orders.";
    }
    else if (State == State.Active)
    {
        string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "tilt-check");
        Directory.CreateDirectory(dir);
        // ... create the three files with headers if missing ...
        // Accounts can connect after NinjaTrader starts, so look for new ones every 2 seconds.
        timer = new System.Threading.Timer(OnTimer, null, 0, 2000);
    }
    else if (State == State.Terminated)
    {
        if (timer != null) timer.Dispose();
        lock (subscribed)
        {
            foreach (Account a in subscribed)
            {
                a.ExecutionUpdate -= OnExecutionUpdate;
                a.OrderUpdate -= OnOrderUpdate;
            }
            subscribed.Clear();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The timer does two jobs. It subscribes to any account it hasn't seen yet (accounts can connect after the add-on is already active, so a one-time subscription at startup misses them), and it writes the account snapshot:

private void OnTimer(object state)
{
    try
    {
        List<Account> accounts;
        lock (Account.All)
            accounts = Account.All.ToList();
        foreach (Account a in accounts)
        {
            lock (subscribed)
            {
                if (!subscribed.Contains(a))
                {
                    a.ExecutionUpdate += OnExecutionUpdate;
                    a.OrderUpdate += OnOrderUpdate;
                    subscribed.Add(a);
                }
            }
            int open;
            lock (a.Positions)
                open = a.Positions.Count(p => p.MarketPosition != MarketPosition.Flat);
            DateTime now = DateTime.UtcNow;
            lock (lastFlatSnapshot)
            {
                DateTime last;
                if (open == 0 && lastFlatSnapshot.TryGetValue(a.Name, out last) && (now - last).TotalSeconds < 60)
                    continue;                       // flat: one line a minute is plenty
                if (open == 0)
                    lastFlatSnapshot[a.Name] = now;
            }
            double upl  = a.Get(AccountItem.UnrealizedProfitLoss, Currency.UsDollar);
            double cash = a.Get(AccountItem.CashValue, Currency.UsDollar);
            Append(snapshotsPath, string.Join(",", now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture),
                Clean(a.Name), upl.ToString(CultureInfo.InvariantCulture), open.ToString(CultureInfo.InvariantCulture),
                cash.ToString(CultureInfo.InvariantCulture)));
        }
    }
    catch (Exception)
    {
        // A feed must never take NinjaTrader down. Skip this tick.
    }
}
Enter fullscreen mode Exit fullscreen mode

Every 2 seconds with a position open, every 60 seconds flat. The Python side uses the open P&L for one alert ("past your usual loss": an open trade losing more than her median losing trade for that setup), and the cash value for percent-of-account limits.

The fill handler is the shortest piece and the whole point:

private void OnExecutionUpdate(object sender, ExecutionEventArgs e)
{
    try
    {
        Execution x = e.Execution;
        if (x == null) return;
        int signedQty = e.MarketPosition == MarketPosition.Short ? -e.Quantity : e.Quantity;
        Append(fillsPath, string.Join(",",
            DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture),
            Clean(x.Account.Name), Clean(x.Instrument.FullName),
            signedQty.ToString(CultureInfo.InvariantCulture),
            e.Price.ToString(CultureInfo.InvariantCulture),
            Clean(e.ExecutionId),
            e.Time.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)));
    }
    catch (Exception) { }
}
Enter fullscreen mode Exit fullscreen mode

Append takes a lock and calls File.AppendAllText. Clean replaces commas and newlines in names so an instrument called something odd can't break a row. InvariantCulture everywhere, because the trading PC is Korean and a decimal comma would be a very quiet bug. And every handler swallows exceptions: I would rather lose a line than have a logging add-on crash the platform someone is trading on.

There is no Submit, Change or Cancel anywhere in the file. That's the contract.

Compiling without NinjaTrader

The trading PC isn't mine, so I can't press F5 in the NinjaScript editor. Instead there's an 88-line Stubs.cs with stand-ins for the types the add-ons touch (AddOnBase, Account, Execution, Order, BarsRequest, the enums), with signatures following the NT8 docs, and the .NET Framework compiler that's already on every Windows machine:

"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /target:library /out:check.dll ninjatrader\stubs\Stubs.cs ninjatrader\TiltCheckFeed.cs ninjatrader\TiltCheckBars.cs
Enter fullscreen mode Exit fullscreen mode

No errors means the C# is well-formed against those signatures. It does not mean it runs. If a real NinjaTrader complains, the fix is usually one type or member name, and the first error line is what I'd need.

The Python side: follow the files

python -m tiltcheck watch tails the three files. The tail is deliberately dumb:

class _Tail:
    def __init__(self, path: Path):
        self.path, self.pos = path, 0

    def lines(self) -> list[str]:
        if not self.path.exists():
            return []
        with open(self.path, "rb") as f:
            f.seek(self.pos)
            chunk = f.read()
        end = chunk.rfind(b"\n") + 1               # only whole lines; a half-written one waits
        self.pos += end
        text = chunk[:end].decode("utf-8", errors="replace")
        return [l.strip() for l in text.splitlines() if l.strip() and not l.startswith("time_utc")]
Enter fullscreen mode Exit fullscreen mode

It remembers a byte offset, reads whatever is new, and only consumes up to the last newline, so a line the add-on is halfway through writing waits for the next poll. The loop catches up on today's rows without alerting (you don't want twenty toasts at launch for trades you already took), then alerts on every new one:

def follow(w, feed, poll=0.5, stop=None, bars_dir=None, bars_every=60.0):
    fills, orders, snaps = _Tail(feed / "fills.csv"), _Tail(feed / "orders.csv"), _Tail(feed / "snapshots.csv")
    today = pd.Timestamp.now().normalize()
    for l in fills.lines():
        t, acct, contract, q, px = l.split(",")[:5]
        if _pc(t) >= today:
            w.on_fill(_pc(t), acct, contract, int(q), float(px), quiet=True)
    # ... same for orders and snapshots ...
    while not (stop and stop()):
        for l in orders.lines(): _order(w, l)
        for l in fills.lines():
            t, acct, contract, q, px = l.split(",")[:5]
            w.on_fill(_pc(t), acct, contract, int(q), float(px))
        for l in snaps.lines(): _snapshot(w, l)
        w.tick(pd.Timestamp.now())
        if bars_dir is not None and time.monotonic() - last_bars >= bars_every:
            _reload_bars(w, bars_dir)
            last_bars = time.monotonic()
        time.sleep(poll)
Enter fullscreen mode Exit fullscreen mode

Orders are read before fills on purpose: when a fill arrives, the stop order that belongs to it has usually been logged already, so the "no working stop after 20 seconds" check sees the right state. _pc() turns the UTC stamp into the PC-clock time her history is in, so live fills and past trades compare on the same clock.

The companion: closed bars only

The chart lines at each entry (15-minute 200 EMA with or against, distance from session VWAP) need minute bars. A second add-on, TiltCheckBars.cs, opens a BarsRequest per instrument listed in Documents\tilt-check\bars-watch.txt (re-read every 30 seconds, so you can add a contract without restarting) and appends bars to bars\<instrument>.csv. The one rule: it writes only the bar that has just closed. The forming bar is never written, which is what keeps the live numbers equal to the audit's no-look-ahead numbers. Same status: compiles against stubs, not yet run in a real NinjaTrader.

Did it work on a real day?

Not live yet, but there's a --replay-day flag that turns a past day from her history back into fills and feeds them through the same code path with only the earlier history loaded. October 1 on her account: 26 fills, 10 alerts, 8 quiet lines. Two of the ten she says were fine (warm-up trades of 10 and 20 contracts that the size rule can't tell apart from a real entry). The full log is in the previous post.

What I'd like an NT8 veteran to check

  1. File.AppendAllText from inside ExecutionUpdate and OrderUpdate handlers: is a synchronous file write on those threads acceptable, or should the add-on queue lines and flush from the timer?
  2. lock (Account.All) before copying the list: is that the right lock, or is there an accounts-changed event I should subscribe to instead of polling every 2 seconds?
  3. BarsRequest lifetime and Update subscription in an add-on (not an indicator): anything that leaks or stops firing after a reconnect?
  4. Whether e.MarketPosition on ExecutionEventArgs is the right field for the sign of a fill, or whether I should derive it from the order action.

The code is open source and runs on your machine; the add-on is one file to copy into Documents\NinjaTrader 8\bin\Custom\AddOns\:

GitHub logo ssap-pa / tilt-check

Pre-trade check for futures traders: TabPFN learns from your own NinjaTrader history, Gemma explains it locally. Never places orders.

tilt-check

A pre-trade check for futures traders that learns from your own NinjaTrader history.

TabPFN (open weights) reads your past trades. Gemma (open weights, through Ollama) tells you in plain language what your own numbers say about the trade you're about to take. Everything runs on your machine. It never places an order.

A real check replayed on my partner's history: the groups this trade falls into, an honest model check, and a note from Gemma running locally

I built it for my partner, who trades micro futures (MNQ, MES, MGC) on a prop-firm account and kept asking the same question after a bad session: is this one of my good trades or one of my bad ones?

Want this run on your own export and written up? Your Trading History, Audited: a written report within 48 hours, late means a full refund. The sample is one real account.

What it said about her account

Her numbers, shared with her permission. One account, Sep 1 to Oct 2, 2026:

  • 293 entries over 23…




If you'd rather I run the audit on your export and write it up, the details are here: https://ssap-pa.github.io/tilt-check/

Top comments (0)