Futures · engineering English
What actually breaks when you run a bot on a prop firm evaluation account
1,384 words · 約 6 分鐘
Market data died for 7 days because the trading account blew up. A UI toggle silently dropped our API stop parameters. A retired systemd unit kept trading.
We have been running an automated futures strategy on a prop firm evaluation account since 2026-07. This is a list of things that actually broke, all of them first hand, none of them taken from a rules page or a forum thread.
The strategy results are elsewhere. On the same account we decomposed the execution gap and found it split 82% timing against 18% cost, taking the win rate from 60% in the model down to 51% live: Your backtest win rate is 60%. Live is 51%.
The line itself is still unproven, at t=0.79 with n=53. None of what follows changes that. It is the part of the work that nobody writes down.
Market data permission is tied to having a live account
On 2026-07-27 the evaluation account blew up. On 2026-07-29 the market data recorder went dark.
What we observed while debugging:
| Check | Result |
|---|---|
| API login | succeeded, token returned |
| Contract search | returned an empty array |
| Account list | every account canTrade=false |
Our mental model had been that a market data subscription is an account level entitlement, so a dead trading account should not affect it. That model was wrong. With no active tradeable account, the data permission goes away, and the API tells you this by returning nothing rather than by returning an error.
The recorder stayed dark for 7 days. It recovered within minutes of us buying a new evaluation account, with no code change at all.
That was the whole fix. A new account, and the data came back.
This is the cleanest example we have of the general failure mode on these platforms: the documentation describes the endpoints, not the entitlements. You find out what your subscription is actually attached to on the day it detaches.
A web UI toggle can silently drop your API parameters
The platform’s web interface has an Auto OCO Brackets setting. With it switched off, stop loss bracket parameters sent through the API are silently ignored.
Nothing fails. The order request returns success. The protective order simply does not exist.
We found out by reconciling positions, not by reading an error.
Our incident log marks this one as an old trap, meaning we hit it more than once. It belongs to the same family as everything in 檢查因為錯誤的理由而通過 (in Chinese): the check passes, and it passes for the wrong reason. A success response is a statement about the request, not about the state of the account.
The lesson we now apply: after placing a bracketed entry, read the open orders back and assert that the protective order is there. Do not trust the acknowledgement.
Automated trading is allowed, and we were wrong about this at first
We initially believed the common claim that bot trading on prop firm evaluations sits in a grey area, tolerated but not sanctioned. On 2026-08-13 we checked it properly and withdrew that belief.
- Bot and API trading is explicitly permitted.
- There is an official API subscription at $29 per month, which we obtained at $14.50 per month, a 50% discount, with a code.
- We found no requirement for a human to supervise the session.
A lot of what is written about this online is old. We repeated it before we checked it, which is exactly the mistake this site is about, so it goes in the list.
Eight integration traps in the API
All eight cost us real debugging time. The endpoints referenced are documented in the ProjectX Gateway documentation (checked 2026-08-27).
| # | Trap | Why it hurts |
|---|---|---|
| 1 | Searching contracts by product symbol returns both the full size and the micro contract | You can silently subscribe to the wrong instrument. Data keeps arriving, so nothing looks broken |
| 2 | Hardcoding the contract month means the subscription dies silently at roll | The failed subscription raises nothing. Files keep being written because other instruments still stream |
| 3 | Fill timestamps carry five fractional second digits | datetime.fromisoformat on Python 3.10 raises ValueError on them |
| 4 | The entry fill has a null profit field, only the exit fill carries a value | You need this to pair entries with exits |
| 5 | Commissions arrive in two separate fields | Counting one of them understated our costs by about $7.5 over three days |
| 6 | Session tokens expire after 24 hours | Our recorder restarts and re-authenticates every 20 hours, inside the 24 hour window |
| 7 | The realtime hub may need a direct websocket attempt with negotiation skipped, falling back to normal negotiation | Both paths have to exist in the client |
| 8 | Account queries return simulated: true for evaluation accounts | Useful, and it means your logs can prove which kind of account produced a number |
Trap 3 has a documented cause. The Python standard library notes that before version 3.11, fromisoformat only supported the formats that isoformat() itself emits (datetime documentation, checked 2026-08-27), which means three or six fractional digits. Five digits is valid ISO 8601 and was not accepted.
Traps 1 and 2 are the expensive pair, because both of them fail by continuing to work. A subscription to the wrong contract looks exactly like a subscription to the right one until you reconcile the data against something else. We lost six trading days to trap 2 on a different instrument, described in 我以為它過了,多五天資料後沒過 (in Chinese).
The zombie bot
On 2026-08-11 two of our bots traded the same session. 2 orders went out that we did not intend, and that day’s data was contaminated.
One line of systemctl state, one contaminated trading day.
The cause was a systemd unit left over from July, still enabled with automatic restart. We had moved on to a new deployment and stopped thinking about the old one.
Our debugging order was: rule out the scheduler, rule out the monitoring script, rule out the watchdog, and only then discover that a retired unit was quietly alive.
The manual is explicit about the part we got wrong: systemctl disable “does not implicitly stop the units that are being disabled” (systemctl man page, checked 2026-08-27). Enabled, disabled, running and stopped are two independent pairs, and we had been treating them as one.
We now disable the unit, rename the file, and point the old path at a null device, so the process cannot come back by accident. Three steps instead of one, because the first two are each individually reversible by a stray deploy script, and the failure they prevent cost us a trading day and 2 unintended orders.
When you deploy a new version, the old version’s resident services do not disappear just because you stopped using them.
The same shape appears in 一個算損益給人看的函式,讓賣出持倉這件事沒有發生 (in Chinese): something you believed was inert turned out to be on the critical path.
What we are not claiming
- We have no first hand evidence about consistency rules, single day profit caps, or any behavioural difference between evaluation and funded accounts, so we make no claims about them.
- We never hit a rate limit, so we cannot tell you where it is.
- Every figure here comes from an evaluation account. Our real money lines and their amounts are on the performance page.
FAQ
Is any of this specific to one prop firm? The eight API traps are specific to this gateway. The two structural lessons are not: entitlements are undocumented until they fail, and a success response is not a state check.
Would a different language have avoided trap 3? Yes, and that is the point. It was a standard library parsing limitation, not an API bug. The API emitted valid ISO 8601 that our runtime declined to read.
Why publish integration details at all? Because they are engineering, not strategy. Anyone can copy our deployment practices and still have no idea what our entry conditions are. That boundary is described on the methodology page (in Chinese).
How much of this would monitoring have caught? The zombie bot, yes, with a check for how many processes hold the trading lock. The market data outage, no. Our recorder was reporting healthy because it was running fine, it simply had nothing to record.