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 functionsDone 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-miniand dropped the daily budget to $1. SetLLM_DAILY_BUDGET_USDbefore 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.
- 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).
- Take the shim
weltiq_ccxt.pyand hang it offccxtunder the nameweltiq. - Set
EXCHANGE_NAME=weltiqand start the bot. If the first fill's order id isdq_…rather thanpaper_…, the swap took. - On the next cycle, when the bot calls
fetch_balanceandfetch_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
- 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.
- 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.
- There are 621 tests. They run in 23 seconds and touch no network, so they give the same result on someone else's machine.
- 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
- 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.
- 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.
- 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.
- 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/mcpOne 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.