DEV Community

John Doe
John Doe

Posted on

I Thought the Hardest Part of a Trading Bot Was the Strategy

When I started building CryptoBot, I thought the hardest part would be the trading strategy.

RSI, MACD, moving averages, momentum, backtesting...

That was the fun part.

But after spending more time on the project, I started running into a different kind of problem.

The strategy could be completely fine.

The software around it wasn't always fine.

A bot can generate a perfectly reasonable BUY signal and still make the wrong decision because the market data is stale, the exchange connection disappeared, or the application doesn't know whether an order was actually executed.

That's when I started looking at the project differently.

Instead of:

Strategy → Signal → Order

I started thinking about:

Market Data → Validation → Strategy → Risk Management → Execution → Exchange → Reconciliation → Recovery

The strategy is only one part of the system.

The Happy Path Is Easy

The basic flow of a trading bot is almost too simple:

┌──────────────┐
│ Market Data  │
└──────┬───────┘
       ↓
┌──────────────┐
│   Strategy   │
└──────┬───────┘
       ↓
┌──────────────┐
│ Risk Check   │
└──────┬───────┘
       ↓
┌──────────────┐
│    Order     │
└──────┬───────┘
       ↓
┌──────────────┐
│   Exchange   │
└──────────────┘
Enter fullscreen mode Exit fullscreen mode

Everything works.

The data arrives.

The strategy runs.

The order is accepted.

The exchange responds.

Great.

But that's the happy path.

Real systems don't stay there for very long.

Eventually:

  • the WebSocket disconnects;
  • market data becomes stale;
  • an API request times out;
  • an order is partially filled;
  • the application crashes;
  • an event is missed;
  • local state doesn't match the exchange.

And this is where the interesting engineering starts.

A WebSocket Can Reconnect and Still Be Wrong

CryptoBot can use WebSockets for real-time market data.

Normally:

CONNECT
   ↓
RECEIVE DATA
   ↓
PROCESS DATA
   ↓
CONTINUE
Enter fullscreen mode Exit fullscreen mode

Then something happens:

CONNECT
   ↓
RECEIVE DATA
   ↓
CONNECTION LOST
Enter fullscreen mode Exit fullscreen mode

The obvious solution is to reconnect.

But reconnecting isn't the same as recovering.

Imagine the connection was down for ten seconds.

During those ten seconds:

  • the price may have changed;
  • an order may have been filled;
  • an order may have been cancelled;
  • multiple events may have happened.

The WebSocket reconnects.

But what did the bot miss?

That's the important question.

Connected
    ≠
Synchronized
Enter fullscreen mode Exit fullscreen mode

A safer recovery flow looks more like:

Connection Lost
      ↓
   Reconnect
      ↓
  Synchronize
      ↓
 Validate State
      ↓
Resume Trading
Enter fullscreen mode Exit fullscreen mode

The bot shouldn't necessarily start trading immediately after reconnecting.

It first needs to know what happened while it was disconnected.

Stale Data Is More Dangerous Than a Crash

A crash is easy to notice.

The process is dead.

Stale data is different.

The application can be running perfectly while making decisions using old information.

Imagine the last BTC update arrived 30 seconds ago.

The bot still has:

last_price = 105200
Enter fullscreen mode Exit fullscreen mode

So the strategy runs.

Maybe it sees an entry condition.

Maybe it generates:

BUY BTC
Enter fullscreen mode Exit fullscreen mode

But the market data is 30 seconds old.

The actual problem is not necessarily the strategy.

The strategy was given bad input.

A simple freshness check can prevent this:

MAX_DATA_AGE = 5

if time.time() - last_market_update > MAX_DATA_AGE:
    trading_enabled = False
Enter fullscreen mode Exit fullscreen mode

The idea is simple:

          Market Data
               ↓
        Is data fresh?
           /      \
         YES       NO
          ↓         ↓
      Strategy     STOP
          ↓
        Trade
Enter fullscreen mode Exit fullscreen mode

Sometimes the best decision a trading bot can make is:

I don't trust the data, so I'm not trading.

That's not a failure.

That's risk control.

The Worst Case: "Did My Order Happen?"

This is probably the failure mode I find most interesting.

Imagine:

Bot
 ↓
POST /order
 ↓
Exchange
Enter fullscreen mode Exit fullscreen mode

The request is sent.

Then the network disappears.

No response.

Now the bot has a problem.

Did the exchange receive the order?

Maybe.

Did it accept it?

Maybe.

Did it execute it?

Maybe.

The bot doesn't know.

And that's very different from:

Order Failed
Enter fullscreen mode Exit fullscreen mode

A timeout really means:

The client didn't receive a response.
Enter fullscreen mode Exit fullscreen mode

It doesn't necessarily mean:

The exchange didn't process the request.
Enter fullscreen mode Exit fullscreen mode

This is why blindly retrying can be dangerous.

For example:

Create Order
     ↓
   Timeout
     ↓
    Retry
     ↓
Create Order
Enter fullscreen mode Exit fullscreen mode

If the first order actually succeeded, you may now have a duplicate.

A better approach is to treat the result as unknown:

┌─────────────────┐
│  Submit Order   │
└────────┬────────┘
         ↓
      Timeout
         ↓
┌─────────────────┐
│     UNKNOWN     │
└────────┬────────┘
         ↓
   Query Exchange
         ↓
  Determine State
         ↓
 Update Local State
Enter fullscreen mode Exit fullscreen mode

This is one of those concepts that applies far beyond trading.

Whenever you communicate with an external system, there can be a difference between:

"It failed."

and:

"I don't know what happened."

Those are not the same thing.

An Order Is a State Machine

It's also tempting to think of an order as a simple function call:

order = create_order(...)
Enter fullscreen mode Exit fullscreen mode

But in reality, an order has a lifecycle.

For example:

CREATED
   ↓
SUBMITTED
   ↓
ACCEPTED
   ↓
PARTIALLY_FILLED
   ↓
FILLED
Enter fullscreen mode Exit fullscreen mode

It can also go another way:

CREATED
   ↓
REJECTED
Enter fullscreen mode Exit fullscreen mode

Or:

ACCEPTED
   ↓
CANCELLED
Enter fullscreen mode Exit fullscreen mode

Or even:

SUBMITTED
   ↓
UNKNOWN
Enter fullscreen mode Exit fullscreen mode

Partial fills make things even more interesting.

Suppose the bot wants to buy 1.0 BTC.

The exchange might report:

0.2 BTC filled
Enter fullscreen mode Exit fullscreen mode

Then:

0.5 BTC filled
Enter fullscreen mode Exit fullscreen mode

And eventually:

1.0 BTC filled
Enter fullscreen mode Exit fullscreen mode

If the bot misses one event, its local state can become incorrect.

The exchange may know that 0.5 BTC has been filled.

The bot may still think it's 0.2 BTC.

Now the rest of the system is operating on the wrong state.

Local State Is Not Reality

This is probably the most important thing I've learned while working on the project.

Imagine:

LOCAL STATE
BTC Position = 0.0
Enter fullscreen mode Exit fullscreen mode

But the exchange says:

EXCHANGE STATE
BTC Position = 0.1
Enter fullscreen mode Exit fullscreen mode

Which one is correct?

The exchange.

The local state is only the application's current understanding of reality.

It can become wrong because:

Missed Event
     ↓
Incorrect Local State
Enter fullscreen mode Exit fullscreen mode

or:

Network Failure
     ↓
Incomplete State
Enter fullscreen mode Exit fullscreen mode

or:

Application Restart
     ↓
State Must Be Rebuilt
Enter fullscreen mode Exit fullscreen mode

This is why reconciliation matters.

┌───────────────┐
│    Exchange   │
│  Actual State │
└───────┬───────┘
        ↓
┌───────────────┐
│ Reconciliation│
└───────┬───────┘
        ↓
┌───────────────┐
│  Local State  │
└───────┬───────┘
        ↓
┌───────────────┐
│    Strategy   │
└───────────────┘
Enter fullscreen mode Exit fullscreen mode

The goal isn't to eliminate every possible failure.

The goal is to have a way to recover from them.

WebSocket + REST

This is where WebSocket and REST APIs complement each other.

WebSocket is great for fast updates:

WebSocket
    ↓
Real-Time Events
    ↓
Local State
Enter fullscreen mode Exit fullscreen mode

REST can provide a snapshot:

REST API
    ↓
Current State
    ↓
Reconciliation
Enter fullscreen mode Exit fullscreen mode

Together:

WebSocket
    ↓
Fast Updates
    ↓
Something Goes Wrong
    ↓
REST Snapshot
    ↓
Reconciliation
    ↓
Correct State
Enter fullscreen mode Exit fullscreen mode

This pattern is useful because events are fast, but snapshots are useful for recovery.

If the application missed something, it needs another way to find out what the current state actually is.

connected = true Is Not Enough

One of the simplest implementations would be:

if connected:
    trade()
Enter fullscreen mode Exit fullscreen mode

But consider this:

WebSocket:       CONNECTED
Market Data:     STALE
Position:        UNKNOWN
Order State:     UNSYNCHRONIZED
Enter fullscreen mode Exit fullscreen mode

Technically:

connected = True
Enter fullscreen mode Exit fullscreen mode

But should the bot trade?

No.

This is why application state matters.

Instead of a single boolean, I prefer something closer to:

CONNECTING
    ↓
  SYNCING
    ↓
   READY
    ↓
  RUNNING
Enter fullscreen mode Exit fullscreen mode

And if something goes wrong:

RUNNING
    ↓
RECOVERING
    ↓
  SYNCING
    ↓
   READY
    ↓
  RUNNING
Enter fullscreen mode Exit fullscreen mode

There should also be a state where trading is explicitly blocked:

STATE MISMATCH
      ↓
STOP TRADING
      ↓
  RECONCILE
      ↓
   VALIDATE
      ↓
   RESUME
Enter fullscreen mode Exit fullscreen mode

The goal isn't:

Keep the bot running no matter what.

It's:

Keep the bot running when it is safe to do so.

Risk Management Should Be Separate

The strategy might say:

BUY BTC
Enter fullscreen mode Exit fullscreen mode

That doesn't automatically mean the order should be sent.

CryptoBot also includes risk-management concepts such as:

  • stop-loss;
  • take-profit;
  • trailing stops;
  • position sizing;
  • capital allocation;
  • portfolio rebalancing.

The basic idea is:

Strategy
   ↓
  BUY
   ↓
Risk Management
   ↓
 Allowed?
  /    \
YES     NO
 ↓       ↓
Enter fullscreen mode Exit fullscreen mode

ORDER REJECT

I like this separation because the strategy answers:

What do I want to do?

While risk management answers:

Should I actually do it?

Those are different questions.

Backtesting Doesn't Test Reality

CryptoBot also supports backtesting.

That's useful for testing strategies against historical data:

Historical Data
       ↓
    Strategy
       ↓
Simulated Execution
       ↓
     Results
Enter fullscreen mode Exit fullscreen mode

You can analyze things like:

  • profit and loss;
  • win rate;
  • drawdown;
  • number of trades;
  • trading costs;
  • strategy performance.

But there is something a backtest usually doesn't reproduce very well:

the ugly parts of reality.

A backtest doesn't randomly lose a WebSocket connection.

It doesn't receive half of an execution event.

It doesn't restart after an order was filled.

It doesn't have to wonder whether an API request timed out after the exchange accepted it.

So a strategy performing well in a backtest doesn't automatically mean the live trading system is reliable.

These are two different engineering problems.

This Changed How I Look at CryptoBot

When I started CryptoBot, I was mostly interested in strategies.

Now I'm just as interested in what happens around them.

The project includes:

  • automated cryptocurrency trading;
  • real-time market data;
  • technical-analysis strategies;
  • backtesting;
  • risk management;
  • automated execution;
  • multi-exchange support;
  • custom strategies;
  • stop-loss and take-profit;
  • trailing stops;
  • portfolio rebalancing;
  • performance analytics;
  • experimental machine-learning approaches.

But the questions I find most interesting are often simpler:

What happens when the connection disappears?

What happens when the data is stale?

What happens when the response disappears?

What happens when the order actually succeeded?

What happens when the application crashes?

What happens when the local state is wrong?

What happens when the bot simply doesn't know what happened?

These aren't strategy questions.

They're system questions.

And I think they're what separates a trading script from a trading system.

Final Thought

A trading bot isn't just:

Strategy → Order
Enter fullscreen mode Exit fullscreen mode

It's more like:

Market Data
     ↓
  Validation
     ↓
   Strategy
     ↓
Risk Management
     ↓
  Execution
     ↓
   Exchange
     ↓
Reconciliation
     ↓
   Recovery
Enter fullscreen mode Exit fullscreen mode

The strategy decides what the bot wants to do.

The rest of the system has to make sure the bot understands what is actually happening.

And sometimes the safest decision isn't:

BUY
Enter fullscreen mode Exit fullscreen mode

or:

SELL
Enter fullscreen mode Exit fullscreen mode

It's:

STOP
  ↓
RECOVER
  ↓
RECONCILE
  ↓
VALIDATE
  ↓
RESUME
Enter fullscreen mode Exit fullscreen mode

That's probably the biggest lesson I've learned while building CryptoBot.

I'm not trying to build a bot that trades all the time.

I'm trying to build one that knows when it shouldn't trade.

If you're building a trading bot or another event-driven system, what's the failure mode you find hardest to handle?

Stale data?

Duplicate orders?

Lost events?

Exchange outages?

State reconciliation?

Or something completely different?

I'd be interested to hear about it.

CryptoBot

GitHub repository

CryptoBot is an ongoing project focused on cryptocurrency trading automation, algorithmic strategies, market analysis, backtesting, risk management, and experimentation with different approaches to automated trading.

Disclaimer: CryptoBot is provided for development, testing, research, and educational purposes. Cryptocurrency trading involves significant financial risk and can result in the loss of capital. Past backtesting results do not guarantee future performance or profits. The software does not guarantee profits.

Top comments (0)