LLM-Auto-Trader from install to a real fill — the crypto bot where the LLM proposes and the rules dispose

A single shim implementing thirteen CCXT functions redirects this bot's entire execution. Install, first order, one full loop and a kill-and-reconnect, all meas

Part 3. Erfaniaa/LLM-Auto-Trader — a bot whose contact surface is thirteen CCXT functions

Done when: you throw a set of ridiculous proposals at the rules layer and read, on your own screen, what gets cut at which layer. It takes 0 LLM calls and costs nothing.

What it does Every few minutes it scrapes prices, order books, funding, on-chain data and news, asks an LLM "what should I do right now," runs the returned JSON through a deterministic rules layer, and sends only what survives to the exchange
Who it fits People who want crypto run on LLM judgement but with the limits enforced by code. If you are working out how to write your own strategy's risk rules, reading src/risk/ alone pays for the trip
Who it doesn't fit Anyone who needs Korean equities (crypto only) · anyone who needs something that runs the moment it is installed (not one order goes out on the default settings) · anyone who needs the stop honoured while the bot is off
What you end up with A one-page verdict table of what the rules layer cuts and at which layer, plus a screen of this bot's order filled in a weltiq paper account
What you need · cost Code free (MIT) · Model one LLM key, required even in paper mode. This whole article cost $0.1313 · Market data free (Binance public data) · weltiq only if you follow section 5
Time · difficulty 20 seconds to install, a few minutes for the first cycle. Difficulty medium — three bugs have to be stepped over before the first run
Relationship to weltiq It connects. The place it touches the exchange is thirteen CCXT functions, so one file moves the whole of execution
Test environment · date macOS 26.6 · Apple Silicon · Python 3.12.13 · 2026-09-21 21:00–22:30 KST (some screens re-run on 2026-09-22)
Disclosure weltiq pays me to write this series. Everything said about the tool comes from runs I did myself

1. What this is

Erfaniaa/LLM-Auto-Trader is a crypto trading bot. The README's own summary is accurate — "The LLM proposes; the risk layer disposes." The LLM does the judging, but there is no LLM on the path an order takes. On that path there are only stop-loss checks, exposure limits and circuit breakers.

The rules layer is the main body. src/risk/ alone holds 9 modules (bet sizing, circuit breakers, cooldown, intraday peak, margin monitoring, position sizer, stop loss, volatility targeting, and the risk_manager that gathers all of it), and on the backtest side there is even CPCV (combinatorial purged cross-validation) and CSCV (probability of backtest overfitting). The shape of the answer the LLM may give is nailed down too — size_pct 25 or under, leverage 1 to 5, a stop loss required on new positions. Not a request: pydantic cuts them at the parsing stage (src/llm/response_parser.py:49-50). The lower bound, though, is not enforced — the floor is 1, so a proposal like "2% of capital" simply passes. The side that gets cut is always the top.

Item Value How it was checked
URL github.com/Erfaniaa/LLM-Auto-Trader · not on PyPI
License MIT (LICENSE file). Though the GitHub API reads it as NOASSERTION the file itself + API
Stars ★40 · 7 forks · 1 open issue (observed 2026-09-21) GitHub API
Last push 2026-07-21 (created 2026-03-17) GitHub API pushed_at
Size 98 Python files under src/ · 16,659 lines · 59 test files measured

The only place it touches the exchange

This is the premise of the article. I counted every call to a CCXT object across the repository.

$ grep -rn "exchange\.\w*(" src/ scripts/ | grep -oE "exchange\.[a-z_]+\(" | sort | uniq -c | sort -rn
   5 exchange.create_order(       2 exchange.fetch_ohlcv(        1 exchange.fetch_positions(
   3 exchange.close(              2 exchange.fetch_funding_rate( 1 exchange.fetch_order_book(
   2 exchange.market(             1 exchange.watch_ticker(       1 exchange.fetch_open_interest(
   2 exchange.load_markets(       1 exchange.set_leverage(       1 exchange.fetch_balance(
                                  1 exchange.fetch_ticker(

Thirteen. That is the surface area through which a 16,659-line repository touches the outside world. And it looks the exchange object up by name (src/data/market.py:101).

cls = getattr(ccxt, exchange_id)          # exchange_id = the EXCHANGE_NAME env var

So if you hang a different class off the ccxt module under the name weltiq and set EXCHANGE_NAME=weltiq, this bot's orders go somewhere else without one line of the repository being changed. Section 5 is that story.


2. Install

This is the path I verified.

git clone --depth 1 https://github.com/Erfaniaa/LLM-Auto-Trader.git
python3.12 -m venv /private/tmp/tut-venvs/llm-auto-trader
/private/tmp/tut-venvs/llm-auto-trader/bin/pip install -e "./LLM-Auto-Trader[local]"

51 packages install in 19.76 seconds (one of them is the project itself; ccxt 4.5.81, pandas 3.0.6 and statsmodels 0.15.0 are in there). I ran the tests too.

$ pytest -q
621 passed, 3 skipped, 15 warnings in 23.31s

621 tests pass in 23 seconds. They are deterministic and touch no network, which is why. Seeing a number like that on a 40-star repository is rare.

The screen after install — four version lines and 621 tests

If ccxt 4.5.81 shows up in the four lines at the top, the exchange-facing side is ready, and if 621 passed, 3 skipped comes out at the bottom, the repository is sound. Ignore the seconds — this screen is a re-run with a warm cache, which gave 5.72 seconds; the 23.31 seconds above is the first-run measurement.

⚠ This tool costs money. It calls an LLM every cycle, and the default is Claude Opus with extended thinking and a 1M context on a $5 daily budget. Leave that on and it can reach $150 a month. This article switched to OpenRouter's openai/gpt-5-mini and dropped the daily budget to $1. Set LLM_DAILY_BUDGET_USD before anything else.

Where it gets stuck

Eight places. The top three mean the first run does not start at all; the bottom five mean it starts but no order goes out.

Symptom Cause What to do
No module named 'yfinance' src/data/macro.py:10 imports it unconditionally. But yfinance is an optional extra pip install yfinance
No module named 'telegram' src/telegram/reporter.py:7 is the same story pip install python-telegram-bot
module 'logging' has no attribute 'handlers' src/utils/logger.py:21 uses logging.handlers.RotatingFileHandler without import logging.handlers import it once before running
TypeError: … NoneType.__format__ every cycle ccxt returns Binance perpetuals' openInterestValue as None. The key exists, so the default in .get(…, 0) (market.py:225) never applies, and context_builder.py:469 formats that None with :,.0f. It dies every time before the LLM is called turn off open-interest collection or filter the None
nothing but Position size too small after rounding src/bot.py:690 sums order-book depth in BTC and passes it as orderbook_depth_usdt. 20 BTC reads as "20 dollars", so the size shrinks to 0.001%. With $10,000 of capital it cannot buy a single lot of BTC multiply the depth by the price before passing it
every entry refused with R:R ratio 1.5 below minimum 1.5 if the model aims at exactly 1.5, floating point drops it just under (src/risk/stop_loss.py:94) lower the threshold to 1.45
capital is 0 even in paper paper mode still reads the capital it sizes with from the exchange (src/bot.py:284). No key means 0, and 0 clears no minimum the README's "paper needs no exchange key" applies only to the LLM loop
it only runs once every 30 minutes with no position the default interval is 1,800 seconds, and the next_check_minutes the LLM writes into its answer takes precedence there is no env var; it has to be changed on the settings object

The middle two (open interest, order-book depth) mean not one order goes out on the default settings. The code that works around them without touching the repository is in lat_workarounds.py, with comments.


3. First result

There is no dashboard. There is no web UI, no TUI, no chart, and streamlit, fastapi and flask appear neither in the dependencies nor in the code. There are only three windows onto the outside — the structured JSON log on the terminal (the default), logs/bot.log, and Telegram (it turns on if you fill in both the bot token and the chat ID; I did not turn it on for this article). That JSON runs past 250 characters a line and cannot be read as it is — the screens in this article have been folded down to the event name and its values, and the folding script is lat_view.py. It drops no lines, only width.

The smallest example is running its own paper mode for one cycle. No order goes outside; it creates one fake fill in memory.

What you say

Switch to OpenRouter's gpt-5-mini, look only at Binance public market data, and run one cycle in the tool's own paper mode. Assume $10,000 of capital.

And this comes back (several lines gathered into one block, logs shortened)

exchange=binance  mode=paper  model=openai/gpt-5-mini  equity=10000.0
trading_exchange_ready  exchange=binance markets=4640
snapshot_collected      symbols=4 has_onchain=true has_sentiment=true has_macro=true errors=0
llm_call_cost           cost_usd=0.0043 input_tokens=4059 output_tokens=1652
llm_analysis_complete   actions=["open_long"] trend=bullish confidence=0.76 next_check=15
risk_validation         action=open_long symbol=BTC/USDT:USDT approved=true reason=OK

[FILL] ok=True open_long BTC/USDT:USDT long size=0.01 price=85237.8
       fees=0.9146604869258574 id=paper_1789994466

This is what success looks like. The LLM was called once for less than half a cent, the rules layer let it through, and one paper fill exists.

One full cycle — from data collection to the fill

Watch the order coming down the screen rather than the numbers — snapshot_collected → llm_call_cost → llm_analysis_complete → risk_validation approved=True → [ASK ] (what it proposed) → [FILL] (the fill). On the bottom line, price=85806.1 and current_price=85806.1 being identical is point ① just below. The five macro_ticker_failed lines and top_traders_api_error in the middle were not removed — that is what the default settings produce. (This screen is the same command re-run on 22 September, so its prices and its verdict differ from the output above. The order is the same.)

Everything else in this article is inside that one-line fill.

① The fill price matches the current price to the decimal. price=85237.8, and the current price on the same cycle was 85237.8 too. Zero slippage. The README says "paper fills at mid plus an estimated slippage," but the actual code (src/execution/executor.py:141-158) fills at the current price and only deducts the fee. The slippage model exists only on the backtest side (src/backtest/fill_model.py, 5bp by default).

② The fee is not based on what was actually filled. The intended notional was $914.66, but the quantity rounded down to the minimum lot and $852.38 was actually filled. Yet the fee 0.9147 is based on 914.66. On the filled notional it should be 0.8524. Small, but it has a direction.

③ The position is recorded nowhere. Kill the bot and open the SQLite file: decisions: 1 rows, llm_calls: 1 rows, and there is no table for open positions at all (trades: 0 rows). When the process dies, so does the position.


4. The best way to use it — hit the rules layer before you ever call the LLM

There is a reason. src/risk/ imports independently of the bot and needs no LLM key and no network. You can invent the most outrageous proposals, throw them in, and read what gets cut. It is free, it is deterministic, and once you have seen it you know whether to trust this tool.

What you say

Don't call the LLM — just throw ridiculous proposals at the rules layer. Capital 10,000, BTC at 85,000.

And this comes back

proposal                                      verdict          reason / adjustment
normal entry (10% · 2x · RR 3:1)              approved         size adjusted → 3.37%  [AFML bet: ×0.34 (confidence 0.70)]
size over the cap (40%)                       schema refusal   Input should be less than or equal to 25
leverage over the cap (10x)                   schema refusal   Input should be less than or equal to 5
no stop loss                                  schema refusal   Value error, stop_loss is required for new positions
reward/risk 0.5:1 (stop -2%, target +1%)      refused          SL/TP invalid: R:R ratio 0.5 below minimum 1.5
stop above entry (on a long)                  refused          SL/TP invalid: SL must be below entry for long
already holding 3 symbols — a 4th             refused          Already 3 positions (max 3)
gross exposure over 60% (5,500 held + 2,500 new)  approved     size adjusted → 5.00%  [Size…]
symbol outside the allow list                 refused          Symbol DOGE/USDT:USDT not in allowed list

--- account-level circuit breakers (warn 4.5% · halt 5%) ---
equity   9800  (-2.0%)   fired=-                              trading_allowed=True
equity   9600  (-4.0%)   fired=daily_loss:halt_new            trading_allowed=False
equity   9550  (-4.5%)   fired=drawdown:close_all, daily_loss trading_allowed=False
equity   9400  (-6.0%)   fired=drawdown:close_all, daily_loss trading_allowed=False

the same entry proposal after the breaker fired → approved=False  Circuit breaker: Trading halted

Throwing ridiculous proposals at the rules layer

The screen from hitting only the rules layer, with the LLM taken out. The middle column is the verdict, and the key point is that the three schema refusals and the four rule refusals were cut at different layers. On the staircase below, look at the point from the equity 9600 row where it flips to trading not allowed — from there on it will not take even a sound proposal. As the command on the top line says, the LLM is called 0 times, so it costs nothing at all.

Three things to read here. ① There are two layers — the schema cuts first (size, leverage, whether there is a stop) and the rules cut next (reward/risk, direction, count, exposure). ② Even what passes is trimmed — a 10% proposal became 3.37% because of a 0.70 confidence. ③ Account-level circuit breakers sit above the per-order checks — once one fires, even sound proposals are all blocked.

It is not the prompt but this table that is what this repository is worth. The reproduction script is scenario_d_riskgate.py.

Whose 4.93bp is that?

Send execution outside (section 5) and slippage appears. The bot saw 85,388.835 and the fill came back at 85,430.92, a gap of +4.93bp. If all of that were weltiq's fill model, it would be a dishonest instrument. So I separated it out — I read the quote and threw the minimum quantity from that exact spot, three times.

quote: bid=85172.87 ask=85172.88 mid=85172.875 spread_bp=0.00 age=0.1s
fill : 85181.3972875  vs mid = +1.00 bp   fee=0.00851814
quote: bid=85172.87 ask=85172.88 mid=85172.875 spread_bp=0.00 age=0.2s
fill : 85181.3972875  vs mid = +1.00 bp   fee=0.00851814
quote: bid=85159.37 ask=85159.38 mid=85159.375 spread_bp=0.00 age=0.4s
fill : 85167.8959375  vs mid = +1.00 bp   fee=0.00851679

Exactly +1.00bp all three times. The fill model is 0.5 × spread + 1bp, and the spread was zero, so it is 1bp on the nose. The remaining 3.93bp is the price moving while the LLM thinks — it takes a snapshot, the model answers for 20 to 30 seconds, it clears the rules, and only then does the order go out. On the second order, +9.09bp.

The tool's own paper counts that share as zero. It fills at the snapshot price. That assumes the judgement delay is free, and in a bot driven by an LLM that may be the biggest leak of all. To measure it, execution has to happen outside.

Everything I ran, at a glance

These were all run with the section 5 shim in place. The raw logs are in the verification record.

What Result (all measured)
Placing one order The LLM proposed "12% · 3x · stop 82,800 · target 89,400" → the rules cut it 12% → 4.51% → 0.00528 BTC filled at 85,430.9222375, fee 0.45107527, order id dq_…. From intake to fill, 7 milliseconds
One turn of the loop On the next cycle the bot re-read balance and positions and placed a second order. Its capital then was equity=9997.293865902 — not a number the bot invented but one carrying the ledger's fills and fees. 0.00395 BTC @ 85,294.8286295
The rules refusing the LLM It tried to widen the stop to 80,000 and was refused with Cannot move SL further from entry (increases risk). On the next attempt it narrowed to 83,900 and passed
Firing a stop condition The order envelope was revoked from the session → the same order came back 422 envelope_required. Trying to create an envelope with the API key gives 403 session_required — an agent cannot widen its own boundary. Unverified: I could not catch the bot hitting that wall mid-cycle
Kill and reconnect After SIGKILL, the restart recovered positions=1 · equity=9998.421395316 (section 5)
Re-pricing the fees The same fill stream (47 fills · 95,023.40 turnover · 94.69 in actual fees) recalculated at other rates — spot 0.1% 95.02, perpetuals 0.05% 47.51 (−49.8%), and 19.00 had everything been maker. The ledger is not touched. But the scope is the whole account, so those 47 fills include things other than this bot

5. Moving it to weltiq — one CCXT shim

The thirteen functions counted in section 1 are all of it. So you write one CCXT-shaped class that implements those thirteen against the weltiq API, hang it off the ccxt module by name, and that is the whole job. The other repository is not edited at all.

  1. Sign up for weltiq ({{CTA_URL}}) → My → Connections, and issue an API key. Then create, from the session, the order envelope that key will work inside (I used: the symbol, $3,000 per order, $20,000 per day, valid one day).
  2. Take the shim weltiq_ccxt.py and hang it off ccxt under the name weltiq.
  3. Set EXCHANGE_NAME=weltiq and start the bot. If the first fill's order id is dq_… rather than paper_…, the swap took.
  4. On the next cycle, when the bot calls fetch_balance and fetch_positions, the shim rebuilds its own book from the weltiq order record and hands it back. That is where the loop closes.

① Make it findable by name

def install() -> None:
    import ccxt, ccxt.async_support as ccxt_async
    for mod in (ccxt, ccxt_async):
        setattr(mod, "weltiq", weltiq)
    import ccxt.pro as ccxtpro
    setattr(ccxtpro, "weltiq", weltiq)     # watch_ticker lands on the same class

② Route one order through weltiq's intake path

async def create_order(self, symbol, type, side, amount, price=None, params=None):
    body = {"venue": self.venue, "symbol": to_weltiq(symbol), "side": side,
            "type": type, "qty": _num(amount), "target": "paper"}
    if not params.get("reduceOnly"):
        # weltiq refuses an entry with no invalidation. The bot's stop never comes
        # through create_order, so the shim builds one from the current price.
        ref = (await self.fetch_ticker(symbol))["last"]
        body["invalidation"] = {"px_below": _num(ref * (1 - self.inval_pct / 100))}
    status, resp = await self._post("/v1/orders/oneoff", body)
    return await self._await_fill(resp["queue_id"], symbol, side, amount, type)

Refusals are re-raised as real ccxt exception types. insufficient_cash becomes InsufficientFunds; lot_size and min_notional become InvalidOrder. The bot already catches those, so the swap is invisible to it.

③ Draw a line so it cannot touch someone else's position

weltiq gives a user one simulated account per market. A resident strategy container and this bot both write to the same book, and the account summary comes back collapsed to one row per symbol — an outside agent cannot tell how much of that row is its own. Leave it alone and the bot reads "my position is 0.54 BTC" and sells someone else's holdings. So the shim builds its own book from only the orders this API key placed after a set time, and puts a hard guard on sells.

if side == "sell":
    own, _, _ = await self._own_book()
    if float(amount) > own.get(wq, {}).get("qty", 0.0) + 1e-12:
        raise InsufficientFunds("sell … > own inventory (the rest belongs to the resident container)")

Because it is rebuilt from the server record, it survives the process dying. One ceiling, though: the order list returns only the most recent 30 with no cursor, so after a few days the shim stops seeing older fills. That one is weltiq's homework, and it caught other parts of this series at the same spot — the investigation is in the verification record.

What happened when I killed it and brought it back

With one position open I killed the bot with SIGKILL and restarted it on the same settings.

13:04:32.077  bot_initialized   equity=9998.42  positions=1
13:04:32.093  circuit_breaker_connectivity  CRITICAL  seconds=1789995872.09
13:04:32.165  emergency_close   symbol=BTC/USDT:USDT side=long

positions=1. The bot cannot remember its own position, and yet it had it back the moment it restarted, because the shim read the weltiq order record and rebuilt it. On its own paper it would have been positions=0 — there is no table for open positions at all.

And 80 milliseconds later the bot's own bug closed that position. seconds=1789995872 — that is a Unix timestamp, untouched. When WebSocketManager.seconds_since_last_update has never been updated it returns time.time() as is, and the breaker reads that as "1.7 billion seconds since the last quote update" and calls it a disconnect (the limit is 60 seconds). The verdict is CLOSE_ALL. The round trip closed like this — bought at 85,430.92, sold at 85,208.84826250, fees 0.45107527 + 0.44990272, P&L −2.07 USDT.

That is the most valuable line in this article. On the tool's own paper there would have been neither a recovery nor a record of the liquidation; because execution happens outside, both are there to the microsecond.

One turn of the loop — one order and one refused stop move

One turn of the loop in a single screen. In the middle, id=dq_… is where the bot's judgement became a weltiq order — not paper_…. Below it, approved=False reason=Cannot move SL further from entry is the rules layer refusing the LLM, and four lines further down the same LLM comes back with sl=83900.0 and passes. positions=1 on the top line means the position was still there after the restart.

That order as it sits in the weltiq ledger

What an order that came through the shim looks like on the weltiq side. Read created_at and processed_at side by side — .948906 and .955931, 7 milliseconds. Below them origin is who placed it (the API key given to this bot alone), and fee_profile on the bottom line is the rule used when re-pricing at another venue's rates. The bot's own record has none of these fields — there it was one line, paper_1789994466.

So what changed

Same bot, same model, same capital. Only where the order goes was changed.

The tool's own paper weltiq paper
Fill price the current price, as is (zero slippage) +4.93bp against the snapshot · +9.09bp on the second order
Fee based on the intended notional — different from what was actually filled based on the filled notional, the amount actually deducted in the ledger. Can be re-priced at other rates
Refusal reasons none — there are no refusals on the paper path actually received: direction_conflict, lot_size, min_notional, no_market_data, envelope_required, session_required
Order record paper_1789994466 (f"paper_{int(time.time())}") dq_… · quote source · intake and processing times to the microsecond · who placed it
Open position a Python object. Gone with the process the server ledger. Recovered as positions=1 on restart after SIGKILL
A boundary a human sets none (a config file, at most) the order envelope. An agent cannot widen its own limit (403)
LLM prompt input 4,005–4,059 tokens input 2,203–2,749 tokens

That last row is a cell weltiq loses. The prompt got shorter not because that is good, but because weltiq does not supply funding, open interest or cross-exchange quotes. The shim returns those three as NotSupported and the bot quietly skips them. The order book only emits the top level too.

What it does not do is worth stating. The bot's stop and target do not reach weltiq — CCXT's create_order has no place for them. While the bot is off, the only thing being honoured is the percentage invalidation the shim inserted. "The rules keep running while the agent sleeps" is only half true in this combination. weltiq is simulated-only right now — the live order path is not open.

(Two settings differed. Only on the shim runs did I shorten the LLM call interval to 150 seconds and lower the minimum reward/risk threshold to 1.45 — respectively "to see several turns in one sitting" and "because of the R:R threshold bug in section 2". Neither affects fill price or size calculation.)


6. Having used it

What was good

  1. Judgement and execution really are separated. The exchange-facing surface is thirteen functions, so swapping it took a little over 300 lines. Repositories like that are rarer than you would think.
  2. The rules layer actually blocks. Not a marketing line — I watched it. The LLM tried to widen the stop, was refused, and came back narrower. Size 25%, leverage 5x and a mandatory stop are cut even earlier, at the schema.
  3. There are 621 tests. They run in 23 seconds and touch no network, so they give the same result on someone else's machine.
  4. It is useful without the LLM. Import src/risk/ on its own and use it as a hygiene checker for your own strategy (section 4).

What was disappointing

  1. Not one order goes out on the default settings. It dies every cycle on the open-interest None, and the order-book depth unit bug drives the size to 0. With no push since 2026-07-21, it will stay that way for a while.
  2. The paper mode is optimistic. It fills at the current price, has no refusals, no partial fills, and never looks at the order book. The README says it adds slippage, but not on the live paper path.
  3. Open positions and stops both vanish with the process. This is a thing meant to run 24 hours a day and it has no restart recovery, and the stop is a number in the bot's memory — when the bot sleeps, the stop sleeps.
  4. Do not trust its cost accounting as a bill. Across the 11 calls still in the logs the bot counted $0.0489, while actual OpenRouter usage over the same window — including discarded attempts — went up by $0.1313. As a budget ceiling it is good enough.

Cost

Item Value
Code · market data free (MIT · Binance public data)
Model one LLM key required. This whole article, $0.1313. Left on the default (Opus with extended thinking) the daily budget is $5, which reaches $150 a month
Time install 19.76s · tests 23.31s · a few minutes per cycle

7. What to do next

Start by pulling out src/risk/ on its own and reproducing the section 4 table with your own hands. It costs nothing, and whether you keep using this tool is decided right there. Then work around the two spots — open interest and order-book depth — and run one cycle.

The next part is freqtrade, 54,000 stars and the most widely used crypto bot in the field, pointing the opposite way from this one: instead of an LLM judging afresh every cycle, strategy code a human wrote does the judging, and the word that product puts on the first line of its documentation is Dry-Run.

weltiq setup — 3 lines 1. Sign up: {{CTA_URL}} 2. My → Connections → issue an API key 3. claude mcp add weltiq --header "Authorization: Bearer <key>" https://weltiq.ai/mcp

One shim puts this bot's orders into a simulated account you can run around the clock.


Every command, output and number in this article was actually run on 2026-09-21. Only public market data was queried from the exchange, and no order involved real money. None of it is trading advice. The trial and error, raw logs and defect list not written up here are in the verification record.