Crypto

My Trading Bot Tried to Buy 1,000 Bitcoin

One silent API field change and my bot nearly placed an order worth tens of millions of dollars. The code compiled clean, tests passed, and every checklist was green. Here's the unit-semantics bug that almost ended Invictus — and the one rule that now protects every live cutover.

May 30, 2026
11 min read
#trading bots#crypto#phemex
My Trading Bot Tried to Buy 1,000 Bitcoin⊕ zoom
Share

The number looked wrong the moment I saw it.

Not "probably wrong." Not "let me double-check." Obviously, catastrophically, stomach-dropping wrong.

I was reviewing the order submission logic for Invictus — my live-money bot that trades USDT-margined perpetual futures on Phemex — right before a production cutover to their new v4 /g-orders endpoint. I had just traced through the quantity calculation one more time, and there it was: under the new endpoint, a qty=1 submission wouldn't mean "1 contract." It would mean 1 BTC.

One. Bitcoin.

Not one contract denominated in some notional unit. One entire bitcoin. At roughly $65,000 per coin at that moment, a position I'd sized for a $65 entry would instead open at $65,000. And this is a futures bot. Leverage multiplies that further. The margin requirement alone would have been catastrophic — and that's if the order even filled partially before the exchange risk engine caught it.

I closed the laptop and sat with that for a minute.


How Invictus Works

Invictus is the bot I built to trade BTC, ETH, and SOL on Phemex USDT linear perpetuals. It runs 24/7 on my trading machine, manages its own position sizing, handles stop-losses, and executes entries based on technical signal stacks I've spent months calibrating.

The entire system is USDT-margined. That matters because in the perpetuals world, "contracts" and "base coin" are not always the same thing — and whether a given API expects one or the other is not obvious from the field name alone. The field might be called orderQty or quantity or qty in both versions of an API, but the unit it expects can change between versions, and the API will silently accept either.

That silence is the trap.


The Phemex v4 Cutover

Phemex deprecated their legacy order endpoints and replaced them with /g-orders — a unified, more capable interface. The migration looked clean on paper. The request and response schemas were familiar. The field names mostly carried over. Internally, we were already using something called orderQtyRq, which is how Phemex denotes "the quantity of the order."

What changed: the unit.

In the legacy endpoint, quantity was specified in contracts. Phemex perpetual contracts have a fixed notional value — for BTC/USDT, one contract is worth $1. So if I want to open a $100 position, I send orderQtyRq=100. Simple, integer-friendly, easy to reason about.

In the new /g-orders endpoint, the same field — same name, same position in the payload — now expects base coin quantity. For BTC/USDT, that means actual BTC. So if I want to open a $100 position at a BTC price of $65,000, I need to send orderQtyRq=0.00154. Completely different unit. Same field name. No compile-time error. No type mismatch. No schema validation failure.

The bot sent qty=1 intending "1 contract" — a $1 position. The new endpoint read it as 1 BTC — a $65,000 position. That's a roughly 1,000x error.

Unit Semantics Mismatch
Phemex /g-orders v4 — same payload, different unit interpretation
ORDER PAYLOAD
qty = 1
Identical field. Identical value.
SEND TO ENDPOINT...
LEGACY ENDPOINT
reads qty as
1 contract
~$100 notional
What the bot meant. ✓
NEW /g-orders (v4)
reads qty as
1 BTC
~$100,000 notional
~1000× LARGER
What Phemex heard. ✗ ~1000× over-order
Same field name. Same value. Different unit.
The code compiled. The tests passed.
Only a $1 live round-trip would have caught it.

Why Tests Passed

This is the part that should haunt every engineer who works with external APIs.

The code was correct. Not "correct in the old paradigm" — correct. The quantity calculation logic was sound. The parameter was passed properly. The request was well-formed JSON. The endpoint accepted it without complaint. There were no exceptions, no error codes, no warnings in the logs.

My unit tests passed because they tested the bot's internal logic — position sizing, signal validation, request construction. They confirmed that given a $65 position size, the bot correctly computed qty=1. That's true. The bot did compute that correctly. What the tests couldn't know is that the downstream endpoint had changed its interpretation of what qty=1 means.

This is the brutal reality of integration contracts at protocol boundaries: your tests live on your side of the line, and the semantic meaning of a field lives on their side. When that meaning changes, your tests see nothing. Your linter sees nothing. Your type checker sees nothing. The code compiles clean, ships clean, and then detonates at runtime with real money.

Every engineer who has spent time building on third-party APIs knows this pattern. The field name is the same. The documentation says it's the same parameter. But "same" is a dangerous word when what changed is the unit of measurement. A temperature field that used to report Celsius now reports Fahrenheit has the same name, same type, same range. Everything looks fine until someone takes your weather bot's output seriously.

For a trading bot, the stakes are a bit higher than incorrect clothing choices.


The Near-Miss Anatomy

Let me make the failure mode precise, because it's important to understand exactly what would have happened had I not caught this before the cutover.

A balance scale violently tipped out of balance — one small unit registering as an impossibly heavy load.⊕ zoom

The bot enters a BTC long on a signal. Position sizing logic runs — say it decides on a $100 notional position at the current BTC price. It computes the contract quantity: qty = 1 (legacy: 1 contract = $1 × 100 contracts, but let's simplify for illustration). It constructs the order payload, fires the request to the new /g-orders endpoint.

Phemex receives qty=1, reads it as "1 BTC." At $65,000/BTC, that's a $65,000 notional long. With any leverage, the margin requirement could exceed the entire funded wallet.

One of three things happens:

  1. The order partially fills before Phemex's risk engine rejects the remainder. Now I have an unintended massive long that I have to manually unwind at potentially terrible prices.
  2. The order is outright rejected for insufficient margin. That's the lucky case. But the bot's error handling tries again. Or retries with a different order type. The retry logic compounds the confusion.
  3. I don't notice immediately because the bot logs the order acceptance (the request was valid — Phemex accepted the payload shape), and the failure only surfaces later in the position tracking reconciliation.

None of those outcomes are acceptable. The only acceptable outcome is that this never reaches production without explicit human verification of the actual units.


The Rule That Emerged

After I traced through this, I wrote a new rule into my operational playbook. It applies to every integration cutover, every new endpoint, every time I'm migrating to a new version of an exchange's API:

A $1 live round-trip before any "live" declaration.

That's it. Before I flip the production switch on any new endpoint, I place one real order with a deliberately tiny size — $1, minimum notional, whatever the exchange allows as its floor. Then I verify:

  • Did the fill quantity match what I intended?
  • Did the executed notional match what I expected?
  • Does my bot's internal state reconcile correctly with what the exchange reports?

Not a simulated order. Not a paper trade. Not a testnet round-trip. A real order, with real money, however small, that I actually watch fill in real time against my expectations.

This sounds obvious in retrospect. It's the equivalent of a pilot doing a walk-around before takeoff rather than just reading the maintenance checklist. The checklist tells you what should be true. The walk-around tells you what is true.

Write-path verification beats read-path verification every time. Reading the documentation, reading the endpoint spec, reading the changelog — that's all read-path. It tells you what the API claims to do. The only way to know what the API actually does is to send a real request and inspect the real response.

A checklist does not catch unit-semantics changes. A $1 order does.


The Broader Pattern

This isn't a Phemex problem. It's not a v4-versus-legacy problem. It's a category of bug that lives at every API integration boundary, and it gets more dangerous as the system on your side gets more sophisticated.

The more complex and well-tested your internal logic is, the more confident you feel about a migration. You've verified the calculation. You've verified the request construction. You've verified the error handling. Everything looks good. The confidence itself is the danger — because that confidence is earned from tests that can only see what's on your side of the wall.

I've seen this pattern kill features in non-trading contexts too: a currency conversion API that changed from cents to dollars between versions; a messaging platform that changed character limits and started silently truncating; a mapping service that changed coordinate precision between SDK versions. Same field name. New meaning. Silent in testing. Explosive in production.

In trading, the blast radius is just more literal.

This is part of why I've been building supporting infrastructure around Invictus — things like signal validation layers and reconciliation checks that compare the bot's internal model of the world against what the exchange actually reports. Tools like Tesseract Intelligence help me reason about market state, but no amount of upstream signal quality protects you from a downstream integration that silently changes its contract.

That's a different class of problem, and it requires a different class of solution.


The $1 Protocol

A single small sphere passing through a glowing verification gate — one tiny probe checked before the rest follow.⊕ zoom

Here's how I run it now, concretely, for every API version migration on a live-money integration:

1. Freeze the production bot. No new positions during cutover. If positions are open, I wait for clean closure before migrating.

2. Wire the new endpoint in a dedicated test branch. Run all existing unit tests to confirm no regressions in internal logic. This is still useful — it just doesn't catch unit-semantics changes.

3. Place a $1 live order on the new endpoint, manually. Smallest lot size the exchange allows. Watch the fill in real time. Inspect: fill price, fill quantity, executed notional, fee charged. Reconcile against my expectations line by line.

4. Verify the bot's internal reconciliation. After the $1 order fills and closes, does the bot correctly account for it? Does the position size reported by the bot match what the exchange shows? This catches a second class of bug — reconciliation logic that breaks on the new response schema.

5. Only then flip production.

The whole protocol takes maybe 15 minutes. It catches the class of bugs that months of unit testing cannot.

I've started advocating for this pattern at work too — any time we're integrating a new third-party API or migrating to a new version at SAS. The principle generalizes: don't declare an integration complete until you've verified the full round-trip with a real request, at the real endpoint, and watched the output match your expectations. Spec compliance and actual behavior are different claims.


What I Actually Learned

The near-miss with Invictus didn't come from bad engineering. The quantity calculation was correct. The test coverage was solid. The code review was thorough.

It came from a confidence gap — the gap between "my side of the integration is correct" and "the full round-trip is correct." Those are not the same claim, and in fast-moving API ecosystems with version migrations and deprecation timelines, the gap opens constantly and silently.

The rule is simple: at an API cutover, your gate is a $1 real order, not a checklist.

Read the docs. Read the changelog. Run your unit tests. Then run the $1 order. In that order. All four steps. Never skip the last one.

My bot did not buy 1,000 Bitcoin that day. But it would have, happily and correctly according to its own internal logic, if I'd shipped the cutover without asking the one question that only a real order can answer.

That question is: does this field still mean what I think it means?

Ask it with money. Ask it cheap. Ask it before it gets expensive.

Go deeper in the AcademyElite

The analytical frameworks behind this signal are taught in the Competitive Intelligence track. 8 lessons.

Start the Competitive Intelligence track →

Explore the Invictus Labs Ecosystem

// Join the Network

Follow the Signal

If this was useful, follow along. Daily intelligence across AI, crypto, and strategy — before the mainstream catches on.

No spam. Unsubscribe anytime.

Share
// More SignalsAll Posts →