Analyzing NinjaTrader Trades in Python: From CSV Export to Expectancy and MAE Tables
NinjaTrader's Trade Performance window answers the questions it was built for. Python answers yours. A tested pandas script that reads the NT8 trade export (commas or semicolons, dollars or decimal commas) and prints expectancy in R, max drawdown, MAE by outcome and results by entry hour.
NinjaTrader's Trade Performance window answers the questions it was built for: net profit, win rate, a few ratios. It won't tell you your expectancy in R or whether your losers blow past the stop, and its hour-of-day view stays inside the window. Those take a few lines of Python once the trades are out of NT8 and in a CSV file.
This guide assumes NT8 is already set up and you have trades on an account, sim or live. If you're still at the first step, the NT8 chart setup guide comes first.
What you need
Python 3 and pandas (pip install pandas). The script uses format="mixed" for dates, which needs pandas 2.0 or newer. Nothing else: no notebook, no plotting library.
Step 1: export the trades
In the Control Center, open New › Trade Performance, pick the account and date range, set the display to Trades with the units on Currency (the script works in dollars) and click Generate. Right-click the grid and choose Export, then save as CSV. The export guide walks through the same steps with screenshots and covers the other export options.
Before you run anything, open the file in a text editor (not Excel) and look at two things. Is the separator a comma or a semicolon? Are the dates written 11/13/2026 or 13.11.2026? NT8 follows your Windows regional settings, so a European PC produces semicolons, decimal commas and day-first dates. The script handles both, but it needs to know about the dates.
Step 2: run the script
Save this as analyze_trades.py next to your CSV, set the four values at the top, and run python analyze_trades.py.
import re
import pandas as pd
CSV_PATH = "trades.csv"
RISK_PER_TRADE = 100.0 # your planned risk per trade in dollars (1R)
DAYFIRST = False # True if your export shows dates as 13.11.2026
SUBTRACT_COMMISSION = False # True if the Profit column is before commission
MAE_COLUMN = "MAE" # point this at your own MAE column if you keep one
def money(value):
"""Turn '$1,234.50', '($80.00)' or '-80,00 $' into a float."""
if pd.isna(value):
return 0.0
text = str(value).strip()
negative = text.startswith(("(", "-", "−"))
text = re.sub(r"[^\d,.]", "", text)
if "," in text and "." in text:
if text.rfind(",") > text.rfind("."): # 1.234,50
text = text.replace(".", "").replace(",", ".")
else: # 1,234.50
text = text.replace(",", "")
elif "," in text: # 80,00
text = text.replace(",", ".")
number = float(text) if text else 0.0
return -number if negative else number
def usd(x):
return f"-${-x:,.2f}" if x < 0 else f"${x:,.2f}"
# NinjaTrader follows the Windows regional settings, so let pandas detect the separator
df = pd.read_csv(CSV_PATH, sep=None, engine="python", dtype=str)
df.columns = [c.strip() for c in df.columns]
for col in ["Profit", "Commission", MAE_COLUMN, "MFE"]:
if col in df.columns:
df[col] = df[col].map(money)
if SUBTRACT_COMMISSION and "Commission" in df.columns:
df["Profit"] = df["Profit"] - df["Commission"]
df["Entry time"] = pd.to_datetime(df["Entry time"], dayfirst=DAYFIRST, format="mixed")
profit = df["Profit"]
wins, losses = profit[profit > 0], profit[profit <= 0]
equity = profit.cumsum()
print(f"Trades: {len(profit)}")
print(f"Win rate: {len(wins) / len(profit):.1%}")
print(f"Average win: {usd(wins.mean())}")
print(f"Average loss: {usd(losses.mean())}")
print(f"Expectancy: {usd(profit.mean())} per trade "
f"({profit.mean() / RISK_PER_TRADE:+.2f}R)")
print(f"Profit factor: {wins.sum() / -losses.sum():.2f}")
print(f"Max drawdown: {usd((equity.cummax() - equity).max())}")
# How far did trades go against you before they ended, in R?
df["MAE (R)"] = df[MAE_COLUMN].abs() / RISK_PER_TRADE
bins = [0, 0.25, 0.5, 0.75, 1.1, float("inf")] # 1.1 leaves room for stop slippage
labels = ["0-0.25R", "0.25-0.5R", "0.5-0.75R", "0.75-1.1R", "over 1.1R"]
df["MAE bucket"] = pd.cut(df["MAE (R)"], bins=bins, labels=labels, include_lowest=True)
df["Result"] = profit.gt(0).map({True: "Winners", False: "Losers"})
print("\nMAE by outcome:")
print(pd.crosstab(df["MAE bucket"], df["Result"]))
# Expectancy by the hour the trade was opened
df["Hour"] = df["Entry time"].dt.hour
by_hour = df.groupby("Hour")["Profit"].agg(["count", "mean"]).round(2)
print("\nBy entry hour:")
print(by_hour)
RISK_PER_TRADE is what turns dollars into R. If you risk the same amount on every trade, put it there. If your risk varies, the R figure is only approximate; the better fix is a risk column from your journal, divided row by row.
What the output looks like
Here's the result on a generated sample of 40 MNQ trades (made-up data, not anyone's account), run once in US format and once as a European export with semicolons and decimal commas. Both gave identical output:
Trades: 40
Win rate: 47.5%
Average win: $154.30
Average loss: -$107.53
Expectancy: $16.84 per trade (+0.17R)
Profit factor: 1.30
Max drawdown: $461.47
MAE by outcome:
Result Losers Winners
MAE bucket
0-0.25R 0 4
0.25-0.5R 0 8
0.5-0.75R 0 3
0.75-1.1R 18 4
over 1.1R 3 0
By entry hour:
count mean
Hour
9 9 57.32
10 13 11.42
11 6 30.39
13 6 -27.35
14 6 -1.51
The MAE distribution is the part NT8 doesn't give you. Most winners in the sample never went more than half a stop against the entry, so a stop at 1R had room to spare. Eighteen losers ended at about 1R, which is what a stop that does its job looks like. The three losers beyond 1.1R are the row to look for in your own data: either the stop slipped badly or it was moved. MAE and MFE explained covers how to read the full distribution.
The hour table is a starting point, not a verdict, and its hours follow the time zone set in NinjaTrader. Six trades at 1 PM prove nothing; you need far more before an hour earns a rule, and the expectancy and risk of ruin guide explains why small samples swing so much.
Three things that break the numbers
Split targets. An ATM strategy with two targets closes one position in two pieces, and the export lists each piece as its own trade. Your trade count doubles and your win rate shifts. To count positions instead of pieces, sum the rows that share an entry time:
positions = df.groupby(["Instrument", "Entry time"], as_index=False)["Profit"].sum()
Run the profit statistics on positions instead of df. MAE per piece doesn't add up cleanly, so keep the MAE table on the original rows.
Commission. Check one trade by hand against your statement. If the Profit column already matches the net figure, leave SUBTRACT_COMMISSION off. If it's gross, turn it on, and note that NT8 takes commission from its own commission template, which may not match your firm's fees. Exports from version 8.1.6 on also carry separate fee columns (clearing, exchange, NFA), which the script leaves alone.
MAE on live accounts. NT8 has a known issue where MAE and MFE come out identical on some live-account exports. If the two columns match row after row, the MAE/MFE export post explains the cause and the workaround. If you log MAE in your own journal, point MAE_COLUMN at that.
Educational content, not investment advice. Futures trading involves substantial risk of loss. Examples are for illustration only. Read the full Risk Disclosure.