Live TopstepX bot trading on futures, AI-graded entries (Chronos+XGBoost) with a PPO-learned trailing-stop exit.
Python
10
53 commits
updated Oct 2, 2026
A live TopstepX bot that trades mechanical entries graded by AI, with a
reinforcement-learned trailing exit — for 3-min futures (NQ, ES, RTY,
YM, GC).
⚠️ Educational — live mode places LIVE orders. Run it on a practice/evaluation account first. (Backtests place no orders, but still need credentials — contract specs are fetched from the broker API.)
futures_foundation
library from GitHub).amazon/chronos-bolt-tiny checkpoint on
first run, and the bot reads contract specs from the broker API at startup./Contract/search); there is
no offline mode. Backtests still use local CSV bars — only the contract
specs come from the API.git clone https://github.com/johnamcruz/algoTraderBot.git
cd algoTraderBot
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
macOS note: if you hit a segfault (the torch/xgboost OpenMP clash), prefix commands with
KMP_DUPLICATE_LIB_OK=TRUE, e.g.KMP_DUPLICATE_LIB_OK=TRUE python bot.py ….
.env)Credentials live in a gitignored .env file. Copy the template:
cp .env.example .env
Open .env and fill in your TopstepX details:
TOPSTEPX_USERNAME=your_login # your TopstepX login
TOPSTEPX_API_KEY=your_api_key # API KEY from the dashboard — NOT your password
TOPSTEPX_ACCOUNT= # blank = first tradable account, or an id/name
TOPSTEPX_ACCOUNT blank to use your first tradable account — the bot
prints every account on startup, so you can copy an id or name here to pin
a specific one (do this to be sure it's your practice account).export TOPSTEPX_API_KEY=…) override .env if
set — handy for CI or secrets managers.Verify the install with a short backtest (uses your creds for the contract spec; the first run also downloads the Chronos checkpoint, so give it a minute):
python bot.py --backtest --symbol NQ --start 2026-05-23 --end 2026-05-25
You should see candles/signals scroll past and a BACKTEST NQ | trades=… win=…
summary at the end. If you get that, you're ready.
python bot.py # config defaults
python bot.py --strategy ema # run one strategy
python bot.py --strategy ema keltner bos # run several (highest proba wins)
python bot.py --size 3 # fixed: 3 contracts per trade
python bot.py --risk 500 # risk-based: ~$500 risked per trade
python bot.py --risk 500 --max-contracts 5
python bot.py --proba-floor 0.45 # only take entries graded ≥ 0.45 confidence
Strategies (--strategy overrides config.ACTIVE_STRATEGIES; pick one or
several — when more than one fires on a bar, the highest-proba signal wins):
| name | entry |
|---|---|
supertrend | SuperTrend flip (period 10, mult 3.0) |
ema | 9/20 EMA crossover, gated to ADX ≥ 18 |
keltner | Keltner-channel breakout, gated to ADX ≥ 20 |
bos | break of the last confirmed swing (break of structure) |
orb | 15-min opening-range breakout (09:30 ET), gated to ADX ≥ 18 |
cisd_ote | CISD displacement → OTE fib-zone pullback (mean-reversion, ICT/SMC) |
jev | a reasoning model is the strategy: each flat bar it decides long / short / none itself, from all the context the six strategies have (market structure, SuperTrend, EMA, ADX, Keltner, ORB, CISD, their signals and track records) plus its last 8 decisions — the six never trade under jev. See Jev (opt-in; swappable model) |
Jev (--strategy jev, opt-in, never the default) — a reasoning model is the
strategy. The six strategies don't trade; they become its context. On every flat
bar it reads the whole market picture (trend, structure, momentum, volatility,
volume, session, liquidity sweeps, 1h/4h), what every strategy currently sees, any
signals they fired with their model scores, how each one's recent signals actually
played out, and its own last 8 decisions — then decides long, short or none.
Its decision is the entry (no --proba-floor), with the standard STOP_ATR × ATR
stop; the PPO exit manages it. Run it on its own (it can't be combined with other
strategies). See Jev: a reasoning model decides from all the context.
The model is swappable via JEV_DECIDER (.env) as backend:model:
| decider | runs |
|---|---|
jev:jev-latest (default) | TypeSafe's hosted Jev — needs TYPESAFE_AI_API_KEY |
mlx:mlx-community/Qwen3-0.6B-4bit | locally in-process on Apple silicon (pip install mlx-lm) |
openai:<model> | any OpenAI-compatible server at JEV_OPENAI_URL (Ollama, LM Studio, mlx_lm.server, llama.cpp) |
python bot.py --strategy jev # live, with JEV_DECIDER from .env
# backtest with a local Qwen served by mlx_lm.server (thinking off for speed)
mlx_lm.server --model Qwen/Qwen3-4B-MLX-4bit --port 8080
JEV_DECIDER=openai:Qwen/Qwen3-4B-MLX-4bit JEV_OPENAI_URL=http://localhost:8080/v1 \
JEV_LLM_THINK=0 python bot.py --backtest --symbol NQ --strategy jev --start 2026-06-03 --end 2026-06-04
Install
mlx-lmin its own venv (or use Ollama) rather than the bot's: it pulls a newertransformersthat the Chronos embedding may not support.
Timeframe (--timeframe MIN, default 3): the bar interval. Models are
per-timeframe and there is no cross-timeframe fallback — only strategies with a
model for the chosen interval are allowed, so a 3-min model is never run on 1-min
bars. Today: 3-min has all six strategies; 1-min has supertrend only
(jev works on either — its panel is whichever strategies the timeframe has).
python bot.py --strategy supertrend --timeframe 1 # 1-min SuperTrend
python bot.py --strategy ema --timeframe 1 # error: no 1-min ema model
A non-3-min run loads data/<symbol>_<tf>min.csv for backtests and the
<model>_<tf>min.joblib entry bundle (e.g. supertrend_chronos_1min.joblib). The
PPO exit is per-timeframe too — if no policy exists for the chosen timeframe yet,
the bot flags it and trains one automatically (python -m ppo_exit.train_ppo_exit --timeframe <tf>,
run once in a subprocess) before trading; it falls back to the fixed-RR exit only if
that training can't produce a policy.
On startup the bot prints your tradable accounts and a banner —
✅ <account> | <contract> | 3-min | [ema] | conf≥0.35 | exit: PPO stop-reprice | size: fixed 1. Confirm it picked your practice/eval account. From there,
every candle, every graded signal (with a TAKE/skip flag), each entry, and
every stop move are logged to the console and log/bot.log. Stop with
Ctrl-C.
Pick strategies and exit shaping in config.py:
ACTIVE_STRATEGIES = ["ema"] # any of: supertrend, ema, keltner, bos, orb (or --strategy)
PROBA_FLOOR = 0.35 # min entry confidence (or pass --proba-floor)
USE_PPO_EXIT = True # False → simple fixed-RR bracket instead
ACTIVATE_R = 2.0 # default exit shaping; the ACTUAL per-timeframe
GIVEBACK_R = 0.75 # ACTIVATE_R/GIVEBACK_R/STOP_ATR live in
# ppo_exit/exit_configs.json (see Tune the exit config)
Position sizing — use a fixed size or risk-based sizing (not both).
With --risk (or config.RISK_PER_TRADE), contracts are sized from the stop so
each trade risks roughly the same dollars:
contracts = min(MAX_CONTRACTS, max(1, floor(risk_$ / (stop_ticks × tick_value))))
tick_value is read from the broker contract (/Contract/search → tickValue,
e.g. NQ ≈ $5/tick, MNQ ≈ $0.50/tick). A tighter stop ⇒ more contracts, a wider
stop ⇒ fewer — so dollar risk stays roughly constant, which pairs naturally with
the 0.5×ATR stop. Micros (MNQ, MES, MGC, M2K, MYM) just work — same models
and bars as their parent, sized at the micro's smaller tickValue.
Backtesting runs the exact live logic — same strategies, grading, and PPO trailing exit — over a local CSV, with a simulated broker filling entries/stops/trailing against history. Contract specs (tick size / value) are still looked up from the broker API, so credentials are required.
# one month of NQ
python bot.py --backtest --symbol NQ --start 2026-05-01 --end 2026-06-01
# a micro and a different ticker
python bot.py --backtest --symbol MNQ --start 2026-05-01 --end 2026-06-01
python bot.py --backtest --symbol ES --risk 500
--symbol reads data/<symbol>_<timeframe>min.csv (ships with NQ, ES, RTY, YM, GC at 3-min);
micros use their parent's bars (MNQ → NQ) at the micro's tick value.--start / --end are YYYY-MM-DD (start inclusive, end exclusive); omit
either to run from the warmup point / to the end of the file.--size, --risk, --proba-floor and the config.py knobs all apply, so you
can A/B a setting by re-running.It prints a summary — trades, win rate, mean/sum R, profit factor, plus MFE and
per-strategy / per-exit breakdowns — and writes every trade to
log/backtest_<symbol>.csv. Entries fill at the signal bar's close; when a bar
straddles both stop and target the stop is assumed first. Grading embeds each
signal through Chronos, so longer ranges take a few minutes.
each bar ─► every active strategy detects its entry
─► its model grades the signal → proba = P(win)
─► best signal with proba ≥ floor is taken (highest proba wins)
─► one shared Chronos embedding per bar feeds every strategy's grade
─► PPO policy trails the stop bar-by-bar until exit
Each strategy is a thin signal generator paired with its own Chronos+XGBoost model; the model decides which signals to take, and a PPO policy decides when to get out. The entry models are trained on multiple 3-min futures (NQ, ES, RTY, YM, GC) and generalize across them; the framework is ticker- and broker-agnostic.
detect() +
grade(). Signals with proba ≥ PROBA_FLOOR are candidates; the highest
proba wins (one position per contract). The trade enters at market with a
protective stop at 0.5×ATR(20) — exactly how the models scored the trade.
With --strategy jev, a reasoning model decides long / short / none itself from
all the strategies' context instead (see
Jev).ppo_exit/policies/) reads the open
trade's R-state (unrealized R, MFE, stop distance, ATR/risk, time, momentum) —
strategy-agnostic, it never sees how the trade was entered, so one policy
fits every strategy on the standard 0.5×ATR(20) stop. It computes a
trailing-stop level and reprices the live stop to it via /Order/modify
(ratcheting only in your favor). Two knobs shape it:
ACTIVATE_R (hold the initial 1R stop until the peak reaches +2R, so
winners survive early pullbacks) and GIVEBACK_R (once trailing, the stop
never sits more than 0.75R below the running peak). So a trade risks 1R, and
once it's up +2R it locks in ≥ +1.25R and rides, giving back ≤ 0.75R from the
best point. The policy forward-pass is pure numpy, so the bot never loads
torch/SB3 next to xgboost./Position/closeContract)
rather than rely on a resting broker stop — which the broker rejects ("Invalid
stop price") when a fast reversal puts the lock level on the wrong side of the
market. This fixes a bug where a big winner could ride all the way back to the
initial −1R stop because every stop-modify was rejected. Stops are also
direction-aware tick-snapped (floor longs / ceil shorts) so rounding never
lands them on the wrong side.TrailExitSim
(ppo_exit/trail_exit_env.py) and run live by ppo_exit/exit_manager.py; the two
implement the same give-back logic — activation gate, peak from the bar's
favorable extreme, give-back cap, and the MAX_HOLD force-exit (the policy
observes bars_held/MAX_HOLD, so live force-exits at the same horizon it was
trained on). Crucially they share the same two-tier fill model (the design
algoTraderAI uses): the protective stop is a resting broker stop order kept at
the give-back floor, repriced each bar. A give-back exit therefore fills one
of two ways, and the sim models both so the policy trains on realistic prices:
tests/test_train_live_parity.py, which drives the same trade through both
engines and asserts an identical exit bar and price, including the
spike-and-reverse market-close case. The PPO was retrained on this corrected sim.With --strategy jev a reasoning model is the strategy. The six mechanical
strategies never place a trade — they run every bar purely to give the model their
context, and the model decides on its own whether to go long, short or stay flat.
each flat bar ─► all six strategies run (views + any signals, graded by their models)
─► the consolidated context is built (below) + the last 8 decisions
─► the decider answers: long / short / none
─► long/short enters at market, stop STOP_ATR × ATR; PPO trails the exit
What it sees — every fact once, in readable units (ATR relative to the current close, US Eastern time), consolidated so nothing is duplicated:
| block | contents |
|---|---|
market | the shared picture: time (session, ET time, weekday) · trend (ADX + slope, swing structure, 1h / 4h move and range position, whether 1h and 4h agree) · momentum (returns over 1/5/20/60 bars, last 30 closes, speed, consistency) · volatility (ATR in bps, vs its median, z-score, range ratios, realized vol) · volume (vs averages, buy/sell pressure, absorption, whether it confirms the move) · lastBar (candle type, body/wicks, rejection) · session (distance from open/high/low/VWAP) · range (10/20-bar high/low/position) · liquiditySweeps (1h/4h bullish/bearish, age, size) |
strategies | per strategy, only what's unique to it: its view (SuperTrend bias + bars since flip, EMA bias/spread/slope, Keltner position, swing highs/lows, opening range, CISD 1h EMA trend + recent setup, whether each ADX gate is open); its signal if it fired this bar (side, stop, its model's win probability + expected R, setup details like break strength or sweep/displacement); its track record — every signal it fires is followed to +2R / −1R and Jev sees the average R, win rate and last outcomes, so it knows which setups are working now |
yourRecentPicks | the same +2R / −1R record for its own entries |
recentDecisions | its last JEV_HISTORY_KEEP (8) decisions as worked examples: the situation, which signals fired, what it chose, and how both a long and a short entered there turned out — so a missed winner or a dodged loser is visible. Quiet bars with no signal and no entry aren't logged. |
The question tells it the outcome being judged (+2R before the −1R stop within 20 bars), the timing (fill at the close, stop size, trailing exit after entry), and the economics (2:1 payoff ⇒ worth taking above ~0.33), and explains every field.
Guards
modelInSample: true) on bars inside that model's training span — there it would
be a leaked label. The backtester also warns loudly when a test range overlaps any
model's training (today bos, ema and orb are trained through the end of the
shipped data, so honest tests of those scores need data after 2026-06-04).JEV_MAX_DECISION_SEC (20 s), the entry is skipped. After any entry, the exit's R
accounting is anchored to the actual fill.none, a timeout, an error, or a malformed answer
(validated: the choice must be one of the options) → no trade. Hosted Jev calls time
out after 5 s and retry rate limits / 5xx twice.mlx-lm).BARS_WINDOW (500)
completed bars; the TopstepX fetch pads its request across the daily halt and
weekends so it always returns a full window of OHLCV.log/jev_cache.jsonl keyed by the model, state,
question and the backend's prompt/settings — re-running a backtest makes no model
calls, and changing a prompt or setting never reuses old answers.The decision is only as good as the model. In testing, a tiny local model
(Qwen3-0.6B) entered almost every time and lost money, while a larger one used the
track records and declined weak setups. Before trading any decider, backtest it
against the plain multi-strategy baseline (--strategy supertrend ema keltner bos orb cisd_ote) over several out-of-sample weeks, and treat any dates you tuned prompts on
as used.
Knobs (config.py): JEV_DECIDER; JEV_STRATEGIES (whose context it gets; [] = all
with a model for the timeframe); JEV_HISTORY_KEEP (past decisions fed back, 8);
JEV_ONLY_ON_SIGNALS (ask only on bars where a strategy fired — fewer calls for a slow
local model); JEV_MEMORY_TARGET_R / JEV_MEMORY_MAX_BARS / JEV_MEMORY_KEEP (track
record); JEV_MAX_DECISION_SEC, JEV_API_TIMEOUT, JEV_RETRIES, JEV_TIMEOUT;
JEV_LLM_THINK / JEV_LLM_MAX_TOKENS (chat models).
Trading real orders has sharp edges; these are handled so a desync can't leave an unmanaged position. Each is covered by a test (see Tests):
/Position/closeContract) does not
fire the OCO, so the broker leaves the protective stop working. close_position
therefore sweeps and cancels every resting order for the contract — otherwise a
stray stop could later fill and open a brand-new naked position the bot never
opened (seen live: a +0.63R short close left its buy-stop, which filled into a
naked long at the exact stop price).manage_trail never runs and the
close was previously unlogged (a −1R stop-out just vanished from the logs). The
bot now detects the in-position→flat transition and logs the exit with its
realized R, inferred from the level the stop rested at, so every close is on the
record.[~09:45, 16:00) ET
(ORB_CLOSE_MIN) so the bot doesn't take stale overnight breakouts of the
morning range.Small, single-responsibility modules:
| file | responsibility |
|---|---|
bot.py | entry point — handle_bar (detect → grade → enter → trail) + live loop + CLI |
config.py | all settings: strategies, sizing, exit shaping, strategy params |
broker_base.py | BrokerClient / OrderRouter — the broker interface |
broker.py | TopstepXClient (a BrokerClient) over the ProjectX Gateway API + make_broker() |
sim_broker.py | SimBroker (an OrderRouter) — fills/stops/trailing against a CSV for backtests |
backtest.py | drives handle_bar over history with date-range selection |
indicators.py | SuperTrend / ATR / ADX / EMA / Keltner / swings / opening range |
embedder.py / embed_worker.py | warm Chronos embedding worker — model loaded once per session |
strategies/ | the pluggable strategies (one file each) + shared base |
ppo_exit/ | the whole PPO trailing-exit subsystem (see below) |
logsetup.py | logging to log/bot.log |
models/ | the entry models (joblib) + FFM feature order |
strategies/ ppo_exit/ (the PPO trailing-exit subsystem)
base.py Strategy ABC exit_manager.py live exit management
supertrend.py → supertrend trail_exit_env.py training env/sim + numpy policy loader
ema_cross.py → ema train_ppo_exit.py trainer (python -m ppo_exit.train_ppo_exit)
keltner.py → keltner optimize_exit.py Optuna config search
bos.py → bos precompute_proba.py entry-grading for training
orb.py → orb exit_configs.json per-timeframe ACTIVATE_R/GIVEBACK_R/STOP_ATR
cisd_ote.py → cisd_ote policies/ the trained .npz policies (per timeframe)
jev/ → jev (a reasoning model decides long/short/none from the others' context)
strategy.py runs the panel each bar, asks the decider, turns the answer into the entry
state.py the consolidated context + the question (what the decider sees)
memory.py track records (+2R / stop) and the last-8-decisions log
deciders.py Decider interface + jev / mlx / openai backends
models/ supertrend_chronos.joblib + _1min ema_cross / keltner_adx / bos / orb _chronos.joblib
ffm_feature_columns.json (FFM feature order)
The bot depends only on the public futures_foundation library (Chronos
embedding + the model head classes + indicator primitives) — no proprietary
code. The joblib bundles run inference directly; the FFM feature block is
computed live via futures_foundation.features.derive_features.
Embeddings stay warm. Chronos runs in a persistent subprocess
(embed_worker.py) that loads the model once per session — torch isolated
from xgboost, model never reloaded. A grade drops from ~3–4 s (cold reload each
call) to ~0.03 s, so backtests/retrains run in minutes and live signal bars are
near-instant. Falls back to the one-shot library path if the worker can't start.
Adding a strategy = one new file in strategies/ implementing _fired() /
_hand_features(), plus its joblib model in models/, then register it in
strategies/__init__.py. The strategy-agnostic exit applies automatically — no
exit work per strategy. Six ship today (supertrend, ema, keltner, bos, orb,
cisd_ote), and a new strategy automatically becomes context for jev too (add a
view in strategies/jev/state.py, and its setup features to SETUP_FEATURES, to give
Jev its full read of the market).
Adding a decider = subclass Decider (or ChatDecider for a chat LLM) in
strategies/jev/deciders.py and register it in DECIDERS; select it with
JEV_DECIDER=<backend>:<model>.
Adding a broker = implement BrokerClient (broker_base.py) in a new module
and add a case to broker.make_broker() + config.BROKER. The bar loop, sizing,
and exit all go through that interface, so nothing else changes — e.g. a Rithmic
client would just provide the same account / market-data / order methods.
python bot.py --retrain-exit # full retrain, then exit
python bot.py --retrain-exit --quick # fast smoke retrain
python -m ppo_exit.train_ppo_exit # same thing, standalone
Catalogs a representative set of entry points in data/NQ_3min.csv, keeps the
ones the bot would enter (proba ≥ 0.35, cached per-timeframe in ppo_exit/proba_cache*.npz), simulates
each trade from the live 0.5×ATR(20) stop with the ACTIVATE_R/GIVEBACK_R
shaping while the agent learns the trail, then benchmarks vs fixed-RR /
constant-trail baselines and writes the policy into ppo_exit/policies/. The
exit is strategy-agnostic (it only sees the trade's R-state), so the same policy
serves every strategy. The printed holdout table is the source of truth for
current performance.
The exit's behaviour is set by ACTIVATE_R / GIVEBACK_R / STOP_ATR — and the
PPO trail collapses to that give-back cap, so the config is the lever. Search it:
python -m ppo_exit.optimize_exit --timeframe 3 --tickers NQ --trials 200 --save
python -m ppo_exit.optimize_exit --timeframe 1 --tickers NQ ES RTY YM GC --trials 300 --save
It replays the exact give-back sim (TrailExitSim) per config — no PPO retrain per
trial — scoring expectancy on a validation slice and reporting the winner on a
held-out test slice, so the chosen config isn't overfit to one window. Pool
multiple tickers with --tickers for more data. It prints the best
ACTIVATE_R/GIVEBACK_R/STOP_ATR (and whether it beats the current config on
test); with --save it writes the winner to that timeframe's entry in
ppo_exit/exit_configs.json. Then retrain so the policy matches the new shaping:
python -m ppo_exit.train_ppo_exit --timeframe <tf>. (1-min CSVs are local-only —
see Backtest.)
Per-timeframe configs. ppo_exit/exit_configs.json holds the exit shaping
(ACTIVATE_R/GIVEBACK_R/STOP_ATR) keyed by timeframe — 1-min and 3-min
bars want different settings (1-min is noisier, so it whipsaws into the stop far
more; a wider STOP_ATR helps). The active timeframe's config is applied at
runtime to both the live exit and the training sim, so training stays equal to
live. Defaults (no JSON entry) fall back to config.py's ACTIVATE_R/GIVEBACK_R.
pytest tests/ # unit + end-to-end, ~1s
git config core.hooksPath .githooks # once per clone: run tests on every commit
The versioned .githooks/pre-commit runs the suite before each commit and aborts
if anything fails (git commit --no-verify skips it). Enable it once per clone
with the command above.
No network, broker, or Chronos needed — everything runs against the SimBroker
and lightweight fakes. Coverage focuses on the order/exit money paths:
test_bracket_ticks — SL/TP ticks signed by direction (the order-rejection fix)test_close_cancels_brackets / test_no_orphan_orders — no orphaned SL or
TP: against a stateful gateway, after a market close, a give-back exit
(manage_trail), or a flat reconcile, zero protective orders are left working
for the contract (and other contracts are untouched)test_reconcile — a flat account is swept of stray orders every bar; an
in-position bar never cancels its live protective stop (mid-session reconcile)test_stop_fill_exit — a broker-stop fill (in-position→flat with no bot close)
is logged with its realized R, not silently droppedtest_exit_manager — PPO give-back: activation gate, give-back cap, and the
intra-bar wick-cross that closes a winner at market instead of riding it backtest_train_live_parity — the trained sim (TrailExitSim) and the live exit
(manage_trail) exit on the same bar at the same price (give-back long/short,
MAX_HOLD timeout), and never on the entry candletest_orb_gate — ORB only fires during the RTH window (no overnight breakouts)test_indicators — indicator correctness + strict causality (no look-ahead in
EMA / Keltner / opening-range / confirmed swings — they feed every feature)test_strategy_triggers — each strategy's _fired fires long/short on the
right pattern and respects its ADX/trend gatetest_detect_signal — entry/stop/risk math (risk = STOP_ATR×ATR, stop on the
correct side) that drives sizing and the exittest_position_size — fixed vs risk-based sizing, cap and 1-lot floortest_config_micros — micro→parent symbol mapping (MNQ→NQ, …)test_broker_contract — active front-month selection (rollover) and
working-stop filtering, against a stubbed gatewaytest_sim_broker — stop/target/trailing fills, stop-before-target tie, closetest_e2e_trade — full lifecycles through backtest.drive → bot.handle_bar
(the real driver): long/short give-back winners, stop-out loser, fixed-RR
target + stop, the highest-proba resolver, the proba floor, and
reconstruct-on-restarttest_timeframe — per-timeframe model resolution + gating (only strategies with
a model for the timeframe are allowed; no cross-timeframe fallback)test_ensure_policy — auto-train the PPO exit when a timeframe has no policy
(and fall back to fixed-RR if training can't produce one)test_exit_configs — per-timeframe exit config load/save (exit_configs.json)test_optimize_exit — the Optuna scanner's metric/split math and that the
give-back replay is genuinely sensitive to each config knobtest_jev_strategy — the Jev strategy: decider wire format, answer validation,
retries, cache key and reply parsing; causal track records and decision history;
the consolidated context (no duplicates, readable labels, no lookahead, no prices or
dates, in-sample model scores withheld); long/short/none entries with the standard
stop; one decision per bar; stale-decision skip; startup checks; fill rebaseIn-sample models: bos, ema and orb were trained on the shipped data
through 2026-06-04 (no holdout), and the cisd_ote training pipeline peeks up to
~21 min ahead when filtering zones. Their scores are optimistic on any shipped
date; retrain with a holdout (in the Futures-Foundation-Model repo) for honest
backtests. jev withholds in-sample scores from its decider.
Scope: entry models are trained on NQ/ES/RTY/YM/GC 3-min UTC bars and generalize across them; other tickers/timeframes are out of distribution until retrained.
Feature fidelity: 68 of 76 FFM features are computed live; the 8 session/time columns the current library doesn't emit are left NaN (XGBoost handles missing natively) — faithful but not bit-identical to training.
PPO exit: one strategy-agnostic policy on the standard 0.5×ATR(20)
stop — it sees only the trade's R-state, so it applies identically to every
strategy (it trains on a representative catalog of NQ entry points).
With GIVEBACK_R = 0.75 the give-back cap dominates, so the exit is
effectively a deterministic "trail 0.75R from peak" — loosen it to let
trend-riding matter more, tighten it for consistency.
Exit mode: default USE_TRAILING_STOP = False is the PPO-driven reprice
(what it's trained for). True uses the ProjectX native trailing bracket
(type 5); the PPO can only tighten that, so it mostly sits idle.
Contract rollover: live trading always uses the broker's active front
month (/Contract/search), and the bot re-resolves it once a day while flat,
so a long-running session follows the quarterly roll to the new contract — and
its clean warmup history — without a restart. The API is the source of truth;
there is no roll calendar to drift. (Backtests use continuous CSV history, so
they're unaffected.)
Online only: the broker API is the single source of truth for contract specs (tick size / value) — there is no hard-coded fallback, so the bot needs credentials + connectivity at startup for both live and backtest. (The Chronos checkpoint itself is cached after the first download.)
Live TopstepX bot trading on futures, AI-graded entries (Chronos+XGBoost) with a PPO-learned trailing-stop exit.
Python
10
53 commits
updated Oct 2, 2026
A live TopstepX bot that trades mechanical entries graded by AI, with a
reinforcement-learned trailing exit — for 3-min futures (NQ, ES, RTY,
YM, GC).
⚠️ Educational — live mode places LIVE orders. Run it on a practice/evaluation account first. (Backtests place no orders, but still need credentials — contract specs are fetched from the broker API.)
futures_foundation
library from GitHub).amazon/chronos-bolt-tiny checkpoint on
first run, and the bot reads contract specs from the broker API at startup./Contract/search); there is
no offline mode. Backtests still use local CSV bars — only the contract
specs come from the API.git clone https://github.com/johnamcruz/algoTraderBot.git
cd algoTraderBot
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
macOS note: if you hit a segfault (the torch/xgboost OpenMP clash), prefix commands with
KMP_DUPLICATE_LIB_OK=TRUE, e.g.KMP_DUPLICATE_LIB_OK=TRUE python bot.py ….
.env)Credentials live in a gitignored .env file. Copy the template:
cp .env.example .env
Open .env and fill in your TopstepX details:
TOPSTEPX_USERNAME=your_login # your TopstepX login
TOPSTEPX_API_KEY=your_api_key # API KEY from the dashboard — NOT your password
TOPSTEPX_ACCOUNT= # blank = first tradable account, or an id/name
TOPSTEPX_ACCOUNT blank to use your first tradable account — the bot
prints every account on startup, so you can copy an id or name here to pin
a specific one (do this to be sure it's your practice account).export TOPSTEPX_API_KEY=…) override .env if
set — handy for CI or secrets managers.Verify the install with a short backtest (uses your creds for the contract spec; the first run also downloads the Chronos checkpoint, so give it a minute):
python bot.py --backtest --symbol NQ --start 2026-05-23 --end 2026-05-25
You should see candles/signals scroll past and a BACKTEST NQ | trades=… win=…
summary at the end. If you get that, you're ready.
python bot.py # config defaults
python bot.py --strategy ema # run one strategy
python bot.py --strategy ema keltner bos # run several (highest proba wins)
python bot.py --size 3 # fixed: 3 contracts per trade
python bot.py --risk 500 # risk-based: ~$500 risked per trade
python bot.py --risk 500 --max-contracts 5
python bot.py --proba-floor 0.45 # only take entries graded ≥ 0.45 confidence
Strategies (--strategy overrides config.ACTIVE_STRATEGIES; pick one or
several — when more than one fires on a bar, the highest-proba signal wins):
| name | entry |
|---|---|
supertrend | SuperTrend flip (period 10, mult 3.0) |
ema | 9/20 EMA crossover, gated to ADX ≥ 18 |
keltner | Keltner-channel breakout, gated to ADX ≥ 20 |
bos | break of the last confirmed swing (break of structure) |
orb | 15-min opening-range breakout (09:30 ET), gated to ADX ≥ 18 |
cisd_ote | CISD displacement → OTE fib-zone pullback (mean-reversion, ICT/SMC) |
jev | a reasoning model is the strategy: each flat bar it decides long / short / none itself, from all the context the six strategies have (market structure, SuperTrend, EMA, ADX, Keltner, ORB, CISD, their signals and track records) plus its last 8 decisions — the six never trade under jev. See Jev (opt-in; swappable model) |
Jev (--strategy jev, opt-in, never the default) — a reasoning model is the
strategy. The six strategies don't trade; they become its context. On every flat
bar it reads the whole market picture (trend, structure, momentum, volatility,
volume, session, liquidity sweeps, 1h/4h), what every strategy currently sees, any
signals they fired with their model scores, how each one's recent signals actually
played out, and its own last 8 decisions — then decides long, short or none.
Its decision is the entry (no --proba-floor), with the standard STOP_ATR × ATR
stop; the PPO exit manages it. Run it on its own (it can't be combined with other
strategies). See Jev: a reasoning model decides from all the context.
The model is swappable via JEV_DECIDER (.env) as backend:model:
| decider | runs |
|---|---|
jev:jev-latest (default) | TypeSafe's hosted Jev — needs TYPESAFE_AI_API_KEY |
mlx:mlx-community/Qwen3-0.6B-4bit | locally in-process on Apple silicon (pip install mlx-lm) |
openai:<model> | any OpenAI-compatible server at JEV_OPENAI_URL (Ollama, LM Studio, mlx_lm.server, llama.cpp) |
python bot.py --strategy jev # live, with JEV_DECIDER from .env
# backtest with a local Qwen served by mlx_lm.server (thinking off for speed)
mlx_lm.server --model Qwen/Qwen3-4B-MLX-4bit --port 8080
JEV_DECIDER=openai:Qwen/Qwen3-4B-MLX-4bit JEV_OPENAI_URL=http://localhost:8080/v1 \
JEV_LLM_THINK=0 python bot.py --backtest --symbol NQ --strategy jev --start 2026-06-03 --end 2026-06-04
Install
mlx-lmin its own venv (or use Ollama) rather than the bot's: it pulls a newertransformersthat the Chronos embedding may not support.
Timeframe (--timeframe MIN, default 3): the bar interval. Models are
per-timeframe and there is no cross-timeframe fallback — only strategies with a
model for the chosen interval are allowed, so a 3-min model is never run on 1-min
bars. Today: 3-min has all six strategies; 1-min has supertrend only
(jev works on either — its panel is whichever strategies the timeframe has).
python bot.py --strategy supertrend --timeframe 1 # 1-min SuperTrend
python bot.py --strategy ema --timeframe 1 # error: no 1-min ema model
A non-3-min run loads data/<symbol>_<tf>min.csv for backtests and the
<model>_<tf>min.joblib entry bundle (e.g. supertrend_chronos_1min.joblib). The
PPO exit is per-timeframe too — if no policy exists for the chosen timeframe yet,
the bot flags it and trains one automatically (python -m ppo_exit.train_ppo_exit --timeframe <tf>,
run once in a subprocess) before trading; it falls back to the fixed-RR exit only if
that training can't produce a policy.
On startup the bot prints your tradable accounts and a banner —
✅ <account> | <contract> | 3-min | [ema] | conf≥0.35 | exit: PPO stop-reprice | size: fixed 1. Confirm it picked your practice/eval account. From there,
every candle, every graded signal (with a TAKE/skip flag), each entry, and
every stop move are logged to the console and log/bot.log. Stop with
Ctrl-C.
Pick strategies and exit shaping in config.py:
ACTIVE_STRATEGIES = ["ema"] # any of: supertrend, ema, keltner, bos, orb (or --strategy)
PROBA_FLOOR = 0.35 # min entry confidence (or pass --proba-floor)
USE_PPO_EXIT = True # False → simple fixed-RR bracket instead
ACTIVATE_R = 2.0 # default exit shaping; the ACTUAL per-timeframe
GIVEBACK_R = 0.75 # ACTIVATE_R/GIVEBACK_R/STOP_ATR live in
# ppo_exit/exit_configs.json (see Tune the exit config)
Position sizing — use a fixed size or risk-based sizing (not both).
With --risk (or config.RISK_PER_TRADE), contracts are sized from the stop so
each trade risks roughly the same dollars:
contracts = min(MAX_CONTRACTS, max(1, floor(risk_$ / (stop_ticks × tick_value))))
tick_value is read from the broker contract (/Contract/search → tickValue,
e.g. NQ ≈ $5/tick, MNQ ≈ $0.50/tick). A tighter stop ⇒ more contracts, a wider
stop ⇒ fewer — so dollar risk stays roughly constant, which pairs naturally with
the 0.5×ATR stop. Micros (MNQ, MES, MGC, M2K, MYM) just work — same models
and bars as their parent, sized at the micro's smaller tickValue.
Backtesting runs the exact live logic — same strategies, grading, and PPO trailing exit — over a local CSV, with a simulated broker filling entries/stops/trailing against history. Contract specs (tick size / value) are still looked up from the broker API, so credentials are required.
# one month of NQ
python bot.py --backtest --symbol NQ --start 2026-05-01 --end 2026-06-01
# a micro and a different ticker
python bot.py --backtest --symbol MNQ --start 2026-05-01 --end 2026-06-01
python bot.py --backtest --symbol ES --risk 500
--symbol reads data/<symbol>_<timeframe>min.csv (ships with NQ, ES, RTY, YM, GC at 3-min);
micros use their parent's bars (MNQ → NQ) at the micro's tick value.--start / --end are YYYY-MM-DD (start inclusive, end exclusive); omit
either to run from the warmup point / to the end of the file.--size, --risk, --proba-floor and the config.py knobs all apply, so you
can A/B a setting by re-running.It prints a summary — trades, win rate, mean/sum R, profit factor, plus MFE and
per-strategy / per-exit breakdowns — and writes every trade to
log/backtest_<symbol>.csv. Entries fill at the signal bar's close; when a bar
straddles both stop and target the stop is assumed first. Grading embeds each
signal through Chronos, so longer ranges take a few minutes.
each bar ─► every active strategy detects its entry
─► its model grades the signal → proba = P(win)
─► best signal with proba ≥ floor is taken (highest proba wins)
─► one shared Chronos embedding per bar feeds every strategy's grade
─► PPO policy trails the stop bar-by-bar until exit
Each strategy is a thin signal generator paired with its own Chronos+XGBoost model; the model decides which signals to take, and a PPO policy decides when to get out. The entry models are trained on multiple 3-min futures (NQ, ES, RTY, YM, GC) and generalize across them; the framework is ticker- and broker-agnostic.
detect() +
grade(). Signals with proba ≥ PROBA_FLOOR are candidates; the highest
proba wins (one position per contract). The trade enters at market with a
protective stop at 0.5×ATR(20) — exactly how the models scored the trade.
With --strategy jev, a reasoning model decides long / short / none itself from
all the strategies' context instead (see
Jev).ppo_exit/policies/) reads the open
trade's R-state (unrealized R, MFE, stop distance, ATR/risk, time, momentum) —
strategy-agnostic, it never sees how the trade was entered, so one policy
fits every strategy on the standard 0.5×ATR(20) stop. It computes a
trailing-stop level and reprices the live stop to it via /Order/modify
(ratcheting only in your favor). Two knobs shape it:
ACTIVATE_R (hold the initial 1R stop until the peak reaches +2R, so
winners survive early pullbacks) and GIVEBACK_R (once trailing, the stop
never sits more than 0.75R below the running peak). So a trade risks 1R, and
once it's up +2R it locks in ≥ +1.25R and rides, giving back ≤ 0.75R from the
best point. The policy forward-pass is pure numpy, so the bot never loads
torch/SB3 next to xgboost./Position/closeContract)
rather than rely on a resting broker stop — which the broker rejects ("Invalid
stop price") when a fast reversal puts the lock level on the wrong side of the
market. This fixes a bug where a big winner could ride all the way back to the
initial −1R stop because every stop-modify was rejected. Stops are also
direction-aware tick-snapped (floor longs / ceil shorts) so rounding never
lands them on the wrong side.TrailExitSim
(ppo_exit/trail_exit_env.py) and run live by ppo_exit/exit_manager.py; the two
implement the same give-back logic — activation gate, peak from the bar's
favorable extreme, give-back cap, and the MAX_HOLD force-exit (the policy
observes bars_held/MAX_HOLD, so live force-exits at the same horizon it was
trained on). Crucially they share the same two-tier fill model (the design
algoTraderAI uses): the protective stop is a resting broker stop order kept at
the give-back floor, repriced each bar. A give-back exit therefore fills one
of two ways, and the sim models both so the policy trains on realistic prices:
tests/test_train_live_parity.py, which drives the same trade through both
engines and asserts an identical exit bar and price, including the
spike-and-reverse market-close case. The PPO was retrained on this corrected sim.With --strategy jev a reasoning model is the strategy. The six mechanical
strategies never place a trade — they run every bar purely to give the model their
context, and the model decides on its own whether to go long, short or stay flat.
each flat bar ─► all six strategies run (views + any signals, graded by their models)
─► the consolidated context is built (below) + the last 8 decisions
─► the decider answers: long / short / none
─► long/short enters at market, stop STOP_ATR × ATR; PPO trails the exit
What it sees — every fact once, in readable units (ATR relative to the current close, US Eastern time), consolidated so nothing is duplicated:
| block | contents |
|---|---|
market | the shared picture: time (session, ET time, weekday) · trend (ADX + slope, swing structure, 1h / 4h move and range position, whether 1h and 4h agree) · momentum (returns over 1/5/20/60 bars, last 30 closes, speed, consistency) · volatility (ATR in bps, vs its median, z-score, range ratios, realized vol) · volume (vs averages, buy/sell pressure, absorption, whether it confirms the move) · lastBar (candle type, body/wicks, rejection) · session (distance from open/high/low/VWAP) · range (10/20-bar high/low/position) · liquiditySweeps (1h/4h bullish/bearish, age, size) |
strategies | per strategy, only what's unique to it: its view (SuperTrend bias + bars since flip, EMA bias/spread/slope, Keltner position, swing highs/lows, opening range, CISD 1h EMA trend + recent setup, whether each ADX gate is open); its signal if it fired this bar (side, stop, its model's win probability + expected R, setup details like break strength or sweep/displacement); its track record — every signal it fires is followed to +2R / −1R and Jev sees the average R, win rate and last outcomes, so it knows which setups are working now |
yourRecentPicks | the same +2R / −1R record for its own entries |
recentDecisions | its last JEV_HISTORY_KEEP (8) decisions as worked examples: the situation, which signals fired, what it chose, and how both a long and a short entered there turned out — so a missed winner or a dodged loser is visible. Quiet bars with no signal and no entry aren't logged. |
The question tells it the outcome being judged (+2R before the −1R stop within 20 bars), the timing (fill at the close, stop size, trailing exit after entry), and the economics (2:1 payoff ⇒ worth taking above ~0.33), and explains every field.
Guards
modelInSample: true) on bars inside that model's training span — there it would
be a leaked label. The backtester also warns loudly when a test range overlaps any
model's training (today bos, ema and orb are trained through the end of the
shipped data, so honest tests of those scores need data after 2026-06-04).JEV_MAX_DECISION_SEC (20 s), the entry is skipped. After any entry, the exit's R
accounting is anchored to the actual fill.none, a timeout, an error, or a malformed answer
(validated: the choice must be one of the options) → no trade. Hosted Jev calls time
out after 5 s and retry rate limits / 5xx twice.mlx-lm).BARS_WINDOW (500)
completed bars; the TopstepX fetch pads its request across the daily halt and
weekends so it always returns a full window of OHLCV.log/jev_cache.jsonl keyed by the model, state,
question and the backend's prompt/settings — re-running a backtest makes no model
calls, and changing a prompt or setting never reuses old answers.The decision is only as good as the model. In testing, a tiny local model
(Qwen3-0.6B) entered almost every time and lost money, while a larger one used the
track records and declined weak setups. Before trading any decider, backtest it
against the plain multi-strategy baseline (--strategy supertrend ema keltner bos orb cisd_ote) over several out-of-sample weeks, and treat any dates you tuned prompts on
as used.
Knobs (config.py): JEV_DECIDER; JEV_STRATEGIES (whose context it gets; [] = all
with a model for the timeframe); JEV_HISTORY_KEEP (past decisions fed back, 8);
JEV_ONLY_ON_SIGNALS (ask only on bars where a strategy fired — fewer calls for a slow
local model); JEV_MEMORY_TARGET_R / JEV_MEMORY_MAX_BARS / JEV_MEMORY_KEEP (track
record); JEV_MAX_DECISION_SEC, JEV_API_TIMEOUT, JEV_RETRIES, JEV_TIMEOUT;
JEV_LLM_THINK / JEV_LLM_MAX_TOKENS (chat models).
Trading real orders has sharp edges; these are handled so a desync can't leave an unmanaged position. Each is covered by a test (see Tests):
/Position/closeContract) does not
fire the OCO, so the broker leaves the protective stop working. close_position
therefore sweeps and cancels every resting order for the contract — otherwise a
stray stop could later fill and open a brand-new naked position the bot never
opened (seen live: a +0.63R short close left its buy-stop, which filled into a
naked long at the exact stop price).manage_trail never runs and the
close was previously unlogged (a −1R stop-out just vanished from the logs). The
bot now detects the in-position→flat transition and logs the exit with its
realized R, inferred from the level the stop rested at, so every close is on the
record.[~09:45, 16:00) ET
(ORB_CLOSE_MIN) so the bot doesn't take stale overnight breakouts of the
morning range.Small, single-responsibility modules:
| file | responsibility |
|---|---|
bot.py | entry point — handle_bar (detect → grade → enter → trail) + live loop + CLI |
config.py | all settings: strategies, sizing, exit shaping, strategy params |
broker_base.py | BrokerClient / OrderRouter — the broker interface |
broker.py | TopstepXClient (a BrokerClient) over the ProjectX Gateway API + make_broker() |
sim_broker.py | SimBroker (an OrderRouter) — fills/stops/trailing against a CSV for backtests |
backtest.py | drives handle_bar over history with date-range selection |
indicators.py | SuperTrend / ATR / ADX / EMA / Keltner / swings / opening range |
embedder.py / embed_worker.py | warm Chronos embedding worker — model loaded once per session |
strategies/ | the pluggable strategies (one file each) + shared base |
ppo_exit/ | the whole PPO trailing-exit subsystem (see below) |
logsetup.py | logging to log/bot.log |
models/ | the entry models (joblib) + FFM feature order |
strategies/ ppo_exit/ (the PPO trailing-exit subsystem)
base.py Strategy ABC exit_manager.py live exit management
supertrend.py → supertrend trail_exit_env.py training env/sim + numpy policy loader
ema_cross.py → ema train_ppo_exit.py trainer (python -m ppo_exit.train_ppo_exit)
keltner.py → keltner optimize_exit.py Optuna config search
bos.py → bos precompute_proba.py entry-grading for training
orb.py → orb exit_configs.json per-timeframe ACTIVATE_R/GIVEBACK_R/STOP_ATR
cisd_ote.py → cisd_ote policies/ the trained .npz policies (per timeframe)
jev/ → jev (a reasoning model decides long/short/none from the others' context)
strategy.py runs the panel each bar, asks the decider, turns the answer into the entry
state.py the consolidated context + the question (what the decider sees)
memory.py track records (+2R / stop) and the last-8-decisions log
deciders.py Decider interface + jev / mlx / openai backends
models/ supertrend_chronos.joblib + _1min ema_cross / keltner_adx / bos / orb _chronos.joblib
ffm_feature_columns.json (FFM feature order)
The bot depends only on the public futures_foundation library (Chronos
embedding + the model head classes + indicator primitives) — no proprietary
code. The joblib bundles run inference directly; the FFM feature block is
computed live via futures_foundation.features.derive_features.
Embeddings stay warm. Chronos runs in a persistent subprocess
(embed_worker.py) that loads the model once per session — torch isolated
from xgboost, model never reloaded. A grade drops from ~3–4 s (cold reload each
call) to ~0.03 s, so backtests/retrains run in minutes and live signal bars are
near-instant. Falls back to the one-shot library path if the worker can't start.
Adding a strategy = one new file in strategies/ implementing _fired() /
_hand_features(), plus its joblib model in models/, then register it in
strategies/__init__.py. The strategy-agnostic exit applies automatically — no
exit work per strategy. Six ship today (supertrend, ema, keltner, bos, orb,
cisd_ote), and a new strategy automatically becomes context for jev too (add a
view in strategies/jev/state.py, and its setup features to SETUP_FEATURES, to give
Jev its full read of the market).
Adding a decider = subclass Decider (or ChatDecider for a chat LLM) in
strategies/jev/deciders.py and register it in DECIDERS; select it with
JEV_DECIDER=<backend>:<model>.
Adding a broker = implement BrokerClient (broker_base.py) in a new module
and add a case to broker.make_broker() + config.BROKER. The bar loop, sizing,
and exit all go through that interface, so nothing else changes — e.g. a Rithmic
client would just provide the same account / market-data / order methods.
python bot.py --retrain-exit # full retrain, then exit
python bot.py --retrain-exit --quick # fast smoke retrain
python -m ppo_exit.train_ppo_exit # same thing, standalone
Catalogs a representative set of entry points in data/NQ_3min.csv, keeps the
ones the bot would enter (proba ≥ 0.35, cached per-timeframe in ppo_exit/proba_cache*.npz), simulates
each trade from the live 0.5×ATR(20) stop with the ACTIVATE_R/GIVEBACK_R
shaping while the agent learns the trail, then benchmarks vs fixed-RR /
constant-trail baselines and writes the policy into ppo_exit/policies/. The
exit is strategy-agnostic (it only sees the trade's R-state), so the same policy
serves every strategy. The printed holdout table is the source of truth for
current performance.
The exit's behaviour is set by ACTIVATE_R / GIVEBACK_R / STOP_ATR — and the
PPO trail collapses to that give-back cap, so the config is the lever. Search it:
python -m ppo_exit.optimize_exit --timeframe 3 --tickers NQ --trials 200 --save
python -m ppo_exit.optimize_exit --timeframe 1 --tickers NQ ES RTY YM GC --trials 300 --save
It replays the exact give-back sim (TrailExitSim) per config — no PPO retrain per
trial — scoring expectancy on a validation slice and reporting the winner on a
held-out test slice, so the chosen config isn't overfit to one window. Pool
multiple tickers with --tickers for more data. It prints the best
ACTIVATE_R/GIVEBACK_R/STOP_ATR (and whether it beats the current config on
test); with --save it writes the winner to that timeframe's entry in
ppo_exit/exit_configs.json. Then retrain so the policy matches the new shaping:
python -m ppo_exit.train_ppo_exit --timeframe <tf>. (1-min CSVs are local-only —
see Backtest.)
Per-timeframe configs. ppo_exit/exit_configs.json holds the exit shaping
(ACTIVATE_R/GIVEBACK_R/STOP_ATR) keyed by timeframe — 1-min and 3-min
bars want different settings (1-min is noisier, so it whipsaws into the stop far
more; a wider STOP_ATR helps). The active timeframe's config is applied at
runtime to both the live exit and the training sim, so training stays equal to
live. Defaults (no JSON entry) fall back to config.py's ACTIVATE_R/GIVEBACK_R.
pytest tests/ # unit + end-to-end, ~1s
git config core.hooksPath .githooks # once per clone: run tests on every commit
The versioned .githooks/pre-commit runs the suite before each commit and aborts
if anything fails (git commit --no-verify skips it). Enable it once per clone
with the command above.
No network, broker, or Chronos needed — everything runs against the SimBroker
and lightweight fakes. Coverage focuses on the order/exit money paths:
test_bracket_ticks — SL/TP ticks signed by direction (the order-rejection fix)test_close_cancels_brackets / test_no_orphan_orders — no orphaned SL or
TP: against a stateful gateway, after a market close, a give-back exit
(manage_trail), or a flat reconcile, zero protective orders are left working
for the contract (and other contracts are untouched)test_reconcile — a flat account is swept of stray orders every bar; an
in-position bar never cancels its live protective stop (mid-session reconcile)test_stop_fill_exit — a broker-stop fill (in-position→flat with no bot close)
is logged with its realized R, not silently droppedtest_exit_manager — PPO give-back: activation gate, give-back cap, and the
intra-bar wick-cross that closes a winner at market instead of riding it backtest_train_live_parity — the trained sim (TrailExitSim) and the live exit
(manage_trail) exit on the same bar at the same price (give-back long/short,
MAX_HOLD timeout), and never on the entry candletest_orb_gate — ORB only fires during the RTH window (no overnight breakouts)test_indicators — indicator correctness + strict causality (no look-ahead in
EMA / Keltner / opening-range / confirmed swings — they feed every feature)test_strategy_triggers — each strategy's _fired fires long/short on the
right pattern and respects its ADX/trend gatetest_detect_signal — entry/stop/risk math (risk = STOP_ATR×ATR, stop on the
correct side) that drives sizing and the exittest_position_size — fixed vs risk-based sizing, cap and 1-lot floortest_config_micros — micro→parent symbol mapping (MNQ→NQ, …)test_broker_contract — active front-month selection (rollover) and
working-stop filtering, against a stubbed gatewaytest_sim_broker — stop/target/trailing fills, stop-before-target tie, closetest_e2e_trade — full lifecycles through backtest.drive → bot.handle_bar
(the real driver): long/short give-back winners, stop-out loser, fixed-RR
target + stop, the highest-proba resolver, the proba floor, and
reconstruct-on-restarttest_timeframe — per-timeframe model resolution + gating (only strategies with
a model for the timeframe are allowed; no cross-timeframe fallback)test_ensure_policy — auto-train the PPO exit when a timeframe has no policy
(and fall back to fixed-RR if training can't produce one)test_exit_configs — per-timeframe exit config load/save (exit_configs.json)test_optimize_exit — the Optuna scanner's metric/split math and that the
give-back replay is genuinely sensitive to each config knobtest_jev_strategy — the Jev strategy: decider wire format, answer validation,
retries, cache key and reply parsing; causal track records and decision history;
the consolidated context (no duplicates, readable labels, no lookahead, no prices or
dates, in-sample model scores withheld); long/short/none entries with the standard
stop; one decision per bar; stale-decision skip; startup checks; fill rebaseIn-sample models: bos, ema and orb were trained on the shipped data
through 2026-06-04 (no holdout), and the cisd_ote training pipeline peeks up to
~21 min ahead when filtering zones. Their scores are optimistic on any shipped
date; retrain with a holdout (in the Futures-Foundation-Model repo) for honest
backtests. jev withholds in-sample scores from its decider.
Scope: entry models are trained on NQ/ES/RTY/YM/GC 3-min UTC bars and generalize across them; other tickers/timeframes are out of distribution until retrained.
Feature fidelity: 68 of 76 FFM features are computed live; the 8 session/time columns the current library doesn't emit are left NaN (XGBoost handles missing natively) — faithful but not bit-identical to training.
PPO exit: one strategy-agnostic policy on the standard 0.5×ATR(20)
stop — it sees only the trade's R-state, so it applies identically to every
strategy (it trains on a representative catalog of NQ entry points).
With GIVEBACK_R = 0.75 the give-back cap dominates, so the exit is
effectively a deterministic "trail 0.75R from peak" — loosen it to let
trend-riding matter more, tighten it for consistency.
Exit mode: default USE_TRAILING_STOP = False is the PPO-driven reprice
(what it's trained for). True uses the ProjectX native trailing bracket
(type 5); the PPO can only tighten that, so it mostly sits idle.
Contract rollover: live trading always uses the broker's active front
month (/Contract/search), and the bot re-resolves it once a day while flat,
so a long-running session follows the quarterly roll to the new contract — and
its clean warmup history — without a restart. The API is the source of truth;
there is no roll calendar to drift. (Backtests use continuous CSV history, so
they're unaffected.)
Online only: the broker API is the single source of truth for contract specs (tick size / value) — there is no hard-coded fallback, so the bot needs credentials + connectivity at startup for both live and backtest. (The Chronos checkpoint itself is cached after the first download.)