johnamcruz/algoTraderBot

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

See the code

README

algoTraderBot — multi-strategy AI futures bot (3-min, TopstepX)

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.)


Getting started

1. Requirements

  • Python 3.10+ and git (dependencies install the public futures_foundation library from GitHub).
  • Internet — downloads the ~45 MB amazon/chronos-bolt-tiny checkpoint on first run, and the bot reads contract specs from the broker API at startup.
  • A TopstepX account + API key — required for both live and backtest. Tick size / tick value come from the broker API (/Contract/search); there is no offline mode. Backtests still use local CSV bars — only the contract specs come from the API.

2. Install

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 ….

3. Add your credentials (.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
  • Get the API key from your TopstepX dashboard (Settings → API). It is a key, not your account password.
  • Leave 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).
  • Real environment variables (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.

4. Run (live)

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):

nameentry
supertrendSuperTrend flip (period 10, mult 3.0)
ema9/20 EMA crossover, gated to ADX ≥ 18
keltnerKeltner-channel breakout, gated to ADX ≥ 20
bosbreak of the last confirmed swing (break of structure)
orb15-min opening-range breakout (09:30 ET), gated to ADX ≥ 18
cisd_oteCISD displacement → OTE fib-zone pullback (mean-reversion, ICT/SMC)
jeva 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:

deciderruns
jev:jev-latest (default)TypeSafe's hosted Jev — needs TYPESAFE_AI_API_KEY
mlx:mlx-community/Qwen3-0.6B-4bitlocally 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-lm in its own venv (or use Ollama) rather than the bot's: it pulls a newer transformers that 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.

5. Backtest (local bars, live specs)

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.


How it works

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.

  • Entry — when flat, every active strategy gets a chance to 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).
  • Exit — each bar the PPO policy (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.
  • Trailed-stop enforcement (intra-bar). The peak is tracked from each bar's favorable extreme (not just the close), and if a bar's unfavorable wick crosses the trailed stop the bot closes at market (/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.
  • Training = live parity. The policy is trained inside 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:
    • resting-stop fill at the floor — when price crosses the floor that was already resting from a prior bar (the common, slow give-back); accurate.
    • market-close at the bar close — only when a fast spike-and-reverse crosses a floor that was tightened this bar and isn't a resting order yet, so live closes at market (worse than the floor). The old sim recorded this optimistically at the floor; it now fills at the bar close, matching live. Entry and exit are never the same candle (both engines start evaluating exits on the bar after entry). All of this is locked by 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.

Jev: a reasoning model decides from all the context

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:

blockcontents
marketthe 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)
strategiesper 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
yourRecentPicksthe same +2R / −1R record for its own entries
recentDecisionsits 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

  • No hindsight. Nothing is absolute (no prices, dates or raw volume), the context at a bar is identical whether or not later bars exist (tested), and outcomes in the track records and decision history appear only once they've actually happened.
  • No leaked model scores. A strategy model's win probability is withheld (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).
  • One decision per bar. A bar is never decided twice (the live loop also skips a bar the broker returns again during halts/weekends).
  • Stale decisions don't trade. Live, if deciding took longer than JEV_MAX_DECISION_SEC (20 s), the entry is skipped. After any entry, the exit's R accounting is anchored to the actual fill.
  • Failures stay flat. 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.
  • Misconfiguration fails at startup, not mid-session (unknown backend, missing key or mlx-lm).
  • Contract roll resets the track records and decision history (old-contract prices don't carry over).
  • Same data live and in backtests. Both use the newest 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.
  • Decisions are cached in 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).

Order safety (live)

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):

  • Signed bracket ticks. SL/TP distances are signed relative to the fill — a long's stop is negative ticks (below), a short's positive (above), TP the mirror — clamped to the 4-tick broker minimum. (Fixes "Invalid stop loss ticks (57). Ticks should be less than zero when longing.")
  • No orphaned brackets. A market close (/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).
  • Mid-session reconcile. A flat account should have no resting orders, so on every flat bar the bot cancels any strays (an orphaned bracket, a missed exit, a manual order). This is the general safety net: it keeps the account in sync even if a close-time cancel was missed, and is what stops the naked-position scenario above from persisting.
  • No silent exits. The common exit is the resting broker stop filling intra-bar — which happens at the broker, so 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.
  • ORB session gate. The 09:30-ET opening range stays mathematically active until midnight ET; ORB entries are gated to the RTH window [~09:45, 16:00) ET (ORB_CLOSE_MIN) so the bot doesn't take stale overnight breakouts of the morning range.

Architecture

Small, single-responsibility modules:

fileresponsibility
bot.pyentry point — handle_bar (detect → grade → enter → trail) + live loop + CLI
config.pyall settings: strategies, sizing, exit shaping, strategy params
broker_base.pyBrokerClient / OrderRouter — the broker interface
broker.pyTopstepXClient (a BrokerClient) over the ProjectX Gateway API + make_broker()
sim_broker.pySimBroker (an OrderRouter) — fills/stops/trailing against a CSV for backtests
backtest.pydrives handle_bar over history with date-range selection
indicators.pySuperTrend / ATR / ADX / EMA / Keltner / swings / opening range
embedder.py / embed_worker.pywarm 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.pylogging 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.

Retrain the trailing exit (optional)

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.

Tune the exit config (Optuna)

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.

Tests

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 dropped
  • test_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 back
  • test_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 candle
  • test_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 gate
  • test_detect_signal — entry/stop/risk math (risk = STOP_ATR×ATR, stop on the correct side) that drives sizing and the exit
  • test_position_size — fixed vs risk-based sizing, cap and 1-lot floor
  • test_config_micros — micro→parent symbol mapping (MNQ→NQ, …)
  • test_broker_contract — active front-month selection (rollover) and working-stop filtering, against a stubbed gateway
  • test_sim_broker — stop/target/trailing fills, stop-before-target tie, close
  • test_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-restart
  • test_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 knob
  • test_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 rebase

Caveats

  • In-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.)

algo
algotrader
chronos-bolt
jev
jev-ai
jev-model
supertrend

johnamcruz/algoTraderBot

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

See the code

README

algoTraderBot — multi-strategy AI futures bot (3-min, TopstepX)

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.)


Getting started

1. Requirements

  • Python 3.10+ and git (dependencies install the public futures_foundation library from GitHub).
  • Internet — downloads the ~45 MB amazon/chronos-bolt-tiny checkpoint on first run, and the bot reads contract specs from the broker API at startup.
  • A TopstepX account + API key — required for both live and backtest. Tick size / tick value come from the broker API (/Contract/search); there is no offline mode. Backtests still use local CSV bars — only the contract specs come from the API.

2. Install

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 ….

3. Add your credentials (.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
  • Get the API key from your TopstepX dashboard (Settings → API). It is a key, not your account password.
  • Leave 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).
  • Real environment variables (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.

4. Run (live)

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):

nameentry
supertrendSuperTrend flip (period 10, mult 3.0)
ema9/20 EMA crossover, gated to ADX ≥ 18
keltnerKeltner-channel breakout, gated to ADX ≥ 20
bosbreak of the last confirmed swing (break of structure)
orb15-min opening-range breakout (09:30 ET), gated to ADX ≥ 18
cisd_oteCISD displacement → OTE fib-zone pullback (mean-reversion, ICT/SMC)
jeva 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:

deciderruns
jev:jev-latest (default)TypeSafe's hosted Jev — needs TYPESAFE_AI_API_KEY
mlx:mlx-community/Qwen3-0.6B-4bitlocally 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-lm in its own venv (or use Ollama) rather than the bot's: it pulls a newer transformers that 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.

5. Backtest (local bars, live specs)

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.


How it works

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.

  • Entry — when flat, every active strategy gets a chance to 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).
  • Exit — each bar the PPO policy (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.
  • Trailed-stop enforcement (intra-bar). The peak is tracked from each bar's favorable extreme (not just the close), and if a bar's unfavorable wick crosses the trailed stop the bot closes at market (/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.
  • Training = live parity. The policy is trained inside 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:
    • resting-stop fill at the floor — when price crosses the floor that was already resting from a prior bar (the common, slow give-back); accurate.
    • market-close at the bar close — only when a fast spike-and-reverse crosses a floor that was tightened this bar and isn't a resting order yet, so live closes at market (worse than the floor). The old sim recorded this optimistically at the floor; it now fills at the bar close, matching live. Entry and exit are never the same candle (both engines start evaluating exits on the bar after entry). All of this is locked by 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.

Jev: a reasoning model decides from all the context

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:

blockcontents
marketthe 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)
strategiesper 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
yourRecentPicksthe same +2R / −1R record for its own entries
recentDecisionsits 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

  • No hindsight. Nothing is absolute (no prices, dates or raw volume), the context at a bar is identical whether or not later bars exist (tested), and outcomes in the track records and decision history appear only once they've actually happened.
  • No leaked model scores. A strategy model's win probability is withheld (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).
  • One decision per bar. A bar is never decided twice (the live loop also skips a bar the broker returns again during halts/weekends).
  • Stale decisions don't trade. Live, if deciding took longer than JEV_MAX_DECISION_SEC (20 s), the entry is skipped. After any entry, the exit's R accounting is anchored to the actual fill.
  • Failures stay flat. 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.
  • Misconfiguration fails at startup, not mid-session (unknown backend, missing key or mlx-lm).
  • Contract roll resets the track records and decision history (old-contract prices don't carry over).
  • Same data live and in backtests. Both use the newest 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.
  • Decisions are cached in 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).

Order safety (live)

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):

  • Signed bracket ticks. SL/TP distances are signed relative to the fill — a long's stop is negative ticks (below), a short's positive (above), TP the mirror — clamped to the 4-tick broker minimum. (Fixes "Invalid stop loss ticks (57). Ticks should be less than zero when longing.")
  • No orphaned brackets. A market close (/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).
  • Mid-session reconcile. A flat account should have no resting orders, so on every flat bar the bot cancels any strays (an orphaned bracket, a missed exit, a manual order). This is the general safety net: it keeps the account in sync even if a close-time cancel was missed, and is what stops the naked-position scenario above from persisting.
  • No silent exits. The common exit is the resting broker stop filling intra-bar — which happens at the broker, so 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.
  • ORB session gate. The 09:30-ET opening range stays mathematically active until midnight ET; ORB entries are gated to the RTH window [~09:45, 16:00) ET (ORB_CLOSE_MIN) so the bot doesn't take stale overnight breakouts of the morning range.

Architecture

Small, single-responsibility modules:

fileresponsibility
bot.pyentry point — handle_bar (detect → grade → enter → trail) + live loop + CLI
config.pyall settings: strategies, sizing, exit shaping, strategy params
broker_base.pyBrokerClient / OrderRouter — the broker interface
broker.pyTopstepXClient (a BrokerClient) over the ProjectX Gateway API + make_broker()
sim_broker.pySimBroker (an OrderRouter) — fills/stops/trailing against a CSV for backtests
backtest.pydrives handle_bar over history with date-range selection
indicators.pySuperTrend / ATR / ADX / EMA / Keltner / swings / opening range
embedder.py / embed_worker.pywarm 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.pylogging 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.

Retrain the trailing exit (optional)

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.

Tune the exit config (Optuna)

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.

Tests

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 dropped
  • test_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 back
  • test_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 candle
  • test_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 gate
  • test_detect_signal — entry/stop/risk math (risk = STOP_ATR×ATR, stop on the correct side) that drives sizing and the exit
  • test_position_size — fixed vs risk-based sizing, cap and 1-lot floor
  • test_config_micros — micro→parent symbol mapping (MNQ→NQ, …)
  • test_broker_contract — active front-month selection (rollover) and working-stop filtering, against a stubbed gateway
  • test_sim_broker — stop/target/trailing fills, stop-before-target tie, close
  • test_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-restart
  • test_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 knob
  • test_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 rebase

Caveats

  • In-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.)

algo
algotrader
chronos-bolt
jev
jev-ai
jev-model
supertrend