DEV Community

Даниил Шпытко
Даниил Шпытко

Posted on

Designing Safe API Boundaries for a Multi-Pair Spot Trading Bot

I’m building NOVA, a Telegram-based system for automating repeatable Spot trading cycles.

The project did not start as an attempt to predict the market or generate trading signals. The original problem was much more practical:

How can I reliably execute the same user-defined sequence of actions many times without losing track of orders, duplicating requests, or coupling the entire system to one exchange API?

At first glance, a Spot trading cycle looks simple:

  1. Buy an asset for a specified amount.
  2. Place a limit sell order above the entry price.
  3. Wait until the sell order is filled.
  4. Start the next cycle.

In production, however, every arrow in this sequence can fail.

An HTTP response can be lost after the exchange accepted an order. A WebSocket connection can disconnect. The process can restart between the BUY and SELL. A user can update API credentials while an order is still open. A subscription can expire while the bot is tracking an already placed SELL.

This article describes the architecture I ended up using in NOVA and the lessons I learned while separating exchange-specific behavior from the trading core.


What NOVA actually does

NOVA automates Spot trading rules configured by the user.

It does not decide whether an asset is a good investment. It does not forecast price movements. It does not use futures, leverage, or margin in the workflow described here.

For each connected market pair, the user defines parameters such as:

  • the purchase amount;
  • the target percentage for the limit sell;
  • the price range in which new cycles are allowed;
  • the capital limit;
  • optional DCA parameters;
  • optional averaging behavior.

The funds remain in the user’s exchange account.

The API credentials used by the system need permission to read account and order information and to perform Spot trading. Withdrawal permission is not required and should not be enabled.

One design decision is especially important:

One market pair is isolated into one runtime process and uses its own API credential context.

This creates some operational overhead, but it also gives strong fault isolation.

A problem in one market process does not have to stop all other markets.


The danger of coupling the core to an exchange

The first version of many trading systems usually spreads exchange-specific details across the codebase:

  • raw symbols such as BTCUSDT;
  • REST endpoint names;
  • request parameter names;
  • status strings such as NEW, FILLED, or CANCELED;
  • WebSocket channel names;
  • listen-key lifecycle;
  • precision and minimum-order rules;
  • raw JSON or protobuf fields;
  • exchange-specific retry codes.

That approach works until a second exchange needs to be added.

Then every business module implicitly depends on the first exchange.

The problem is not only maintainability. It also makes recovery logic dangerous.

If the trading core directly interprets raw exchange responses, a transport change can silently affect business transitions.

I wanted the core to operate with concepts such as:

  • market identity;
  • order side;
  • order type;
  • normalized order status;
  • quantity;
  • executed quantity;
  • price;
  • account balance;
  • market rules.

It should not need to know how MEXC spells a field, constructs a symbol, opens a private stream, or signs a request.


The neutral exchange runtime

The current architecture uses a neutral runtime bundle.

A simplified version looks like this:

from typing import Protocol


class ExchangeRuntimeBundle(Protocol):
    descriptor: "ExchangeRuntimeDescriptor"
    spot_client: "NovaSpotExchangeClient"
    order_gateway: "NovaRestOrderGateway"
    account_client: "NovaBalanceReader"
    fee_client: "NovaFeeRateReader"
    credential_validator: "NovaCredentialValidator"
    market_metadata: "NovaMarketMetadataProvider"
    error_policy: "NovaExchangeErrorPolicy"
    public_market_stream: "NovaPublicMarketStream"

    def create_private_order_session(
        self,
        user_context: "UserContext",
    ) -> "NovaPrivateOrderSession":
        ...

    def close(self) -> None:
        ...
Enter fullscreen mode Exit fullscreen mode

The trading core receives this runtime through a factory.

Today, the active factory supports MEXC only. Unsupported exchange codes fail closed.

That is intentional.

The goal was not to pretend that multi-exchange support already exists. The goal was to make the trading core independent enough that a second adapter can later implement the same contracts.

The MEXC boundary owns:

  • public and private WebSocket URLs;
  • raw market symbols;
  • subscription channels;
  • listen-key creation and refresh;
  • request signing and headers;
  • REST endpoint details;
  • raw status conversion;
  • precision and market metadata;
  • exchange-specific error classification.

The core owns:

  • trading decisions;
  • state transitions;
  • capital constraints;
  • lifecycle ownership;
  • recovery policy;
  • notifications;
  • persistence.

A normalized order model

Raw responses are converted into a neutral order snapshot before they reach business logic.

A simplified model might look like this:

@dataclass(frozen=True)
class NovaOrderSnapshot:
    market_id: str
    side: NovaOrderSide | None
    order_type: NovaOrderType | None
    nova_order_status: NovaOrderStatus
    exchange_order_id: str | None = None
    nova_client_order_id: str | None = None
    price: Decimal | None = None
    orig_base_quantity: Decimal | None = None
    filled_base: Decimal | None = None
    filled_quote: Decimal | None = None
    avg_price: Decimal | None = None
    status_present: bool = False
    matches_request_identity: bool = True
Enter fullscreen mode Exit fullscreen mode

The business layer works only with normalized enums such as:

class NovaOrderStatus(Enum):
    NEW = "NEW"
    PARTIALLY_FILLED = "PARTIALLY_FILLED"
    FILLED = "FILLED"
    CANCELED = "CANCELED"
    REJECTED = "REJECTED"
    EXPIRED = "EXPIRED"
    UNKNOWN = "UNKNOWN"
Enter fullscreen mode Exit fullscreen mode

This prevents raw transport fields from becoming part of the business contract.

The same principle is used for private WebSocket events.

The MEXC adapter parses the raw frame and produces a neutral private-order event. Deduplication and lifecycle processing happen only after normalization.


The trading state machine

The normal automated cycle can be represented like this:

READY
  ↓
BUY_SUBMITTING
  ↓
BUY_CONFIRMED
  ↓
SELL_SUBMITTING
  ↓
WAITING_FOR_SELL
  ↓
SELL_FILLED
  ↓
FINALIZING
  ↓
READY
Enter fullscreen mode Exit fullscreen mode

But a production state machine also needs recovery transitions:

BUY_SUBMITTING
  ├─ response received
  ├─ response lost, order found by client ID
  └─ no order found, safe failure

SELL_SUBMITTING
  ├─ response received
  ├─ response lost, order found by client ID
  └─ ambiguous state requiring reconciliation

WAITING_FOR_SELL
  ├─ private event: FILLED
  ├─ private event: CANCELED
  ├─ private stream disconnect
  ├─ process restart
  └─ bounded REST reconciliation
Enter fullscreen mode Exit fullscreen mode

The important idea is that an HTTP response is not always the source of truth.

The exchange may accept an order even when the application never receives the response.

For that reason, orders use stable client-generated identifiers. After a timeout, the system queries the exchange using the same identifier before deciding whether a retry is safe.


Private WebSocket as the primary signal

Originally, the system checked order state in PostgreSQL every second.

Two separate loops repeatedly read:

  • the user record;
  • the subscription;
  • the current task;
  • the trade state.

To keep the improvement measurable, I added a 15-user, 60-second regression model. The baseline for the previous polling pattern is 98.2 PostgreSQL transactions per second, even when almost nothing changes.

The architecture was changed so that private order events are now the primary terminal signal.

The normal path is:

Exchange sends FILLED
  ↓
Private adapter normalizes the event
  ↓
Lifecycle owner receives it
  ↓
Terminal transition is persisted
  ↓
Blocker is released
  ↓
The next cycle is allowed to continue
Enter fullscreen mode Exit fullscreen mode

REST and database reconciliation still exist, but only as a safety mechanism.

The current behavior uses:

  • a normal reconciliation interval of approximately 60 seconds;
  • a shorter interval when the private stream is degraded;
  • startup reconciliation after a process restart;
  • reconciliation after a private-stream reconnect.

The current regression check reports 1.25 transactions per second with a healthy private stream and 3.75 transactions per second in the degraded reconciliation model: a 98.73% reduction against the baseline. These are contract-model figures, not an exchange throughput benchmark.

More importantly, order completion became event-driven instead of waiting for the next polling interval.


Exactly-once is a business problem, not a WebSocket problem

An exchange can replay events. A reconnect can deliver the same terminal status again. REST reconciliation can discover the same result that was already received over WebSocket.

Therefore, the event handler cannot simply trust that every event is new.

A simplified terminal handler looks like this:

async def handle_terminal_event(event: OrderEvent) -> None:
    async with order_lock(event.order_id):
        if await trades.is_complete(event.order_id):
            return

        await trades.mark_complete(
            order_id=event.order_id,
            executed_quantity=event.executed_quantity,
            average_price=event.average_price,
        )

        await lifecycle.release_current_blocker(event.order_id)
        lifecycle.wake_next_cycle()
Enter fullscreen mode Exit fullscreen mode

The real implementation has more checks, but the important protections are:

  • one lifecycle owner per order;
  • a per-order lock;
  • duplicate-event suppression;
  • a database completion check;
  • compare-and-set behavior when releasing the current blocker;
  • reconciliation that uses the same normalized lifecycle path.

This does not magically provide distributed exactly-once delivery.

It does make the business transition idempotent under the failure modes the system currently supports.


Subscription expiry must not abandon an existing order

Subscription logic is another area where a simple implementation can become unsafe.

A tempting rule is:

subscription inactive → stop everything
Enter fullscreen mode Exit fullscreen mode

That is incorrect when the system already placed a SELL order.

The safer rule is:

inactive subscription:
    track and finalize existing orders
    block new BUY placements
Enter fullscreen mode Exit fullscreen mode

In NOVA, an existing SELL continues to be monitored even if the subscription expires.

When the SELL is filled:

  • the result is persisted;
  • the trade is finalized;
  • the user can be notified;
  • a new BUY is not created unless the subscription is active again.

The same distinction applies to credentials.

A credentials problem must prevent new exchange actions, but it must not erase the fact that an existing remote order may still need reconciliation.


Credentials should be validated at lifecycle boundaries

Repeatedly reading the same API key from the database does not prove that the key is valid.

Credential safety is handled through lifecycle gates:

  • when a user runtime is created;
  • after credentials are updated;
  • before a new placement;
  • after an authentication error;
  • when the credential version no longer matches the active runtime.

The runtime stores a credential version rather than treating a connection as valid forever.

Before a new BUY, the system checks that:

  • the subscription snapshot is fresh and active;
  • credentials are actionable;
  • the credential version matches the runtime;
  • the user context is not waiting for a rebuild;
  • no conflicting order lifecycle is active.

This removes constant database polling without weakening placement safety.


Why one process per market pair?

NOVA currently runs a separate worker process for each supported market profile.

This has clear advantages:

  • a crash is isolated to one market;
  • each process has independent public and private stream ownership;
  • logs are easier to attribute;
  • deployment can be rolled out one market at a time;
  • recovery can be verified with a canary process;
  • one problematic market does not stop the whole fleet.

It also has costs:

  • more WebSocket connections;
  • more database pools;
  • more schedulers;
  • more process supervision;
  • more operational configuration.

For a small system, the isolation benefits currently outweigh the additional resource use.

I would not automatically recommend this topology for every trading platform. It is a deliberate trade-off, not a universal pattern.


Recovery after restart

A process restart is normal in production. It should not be treated as an exceptional event.

During startup, the worker restores state before starting ordinary trading.

It checks:

  • the persisted trading task;
  • the current blocker;
  • locally known open orders;
  • exchange order status through the neutral REST gateway;
  • private-session availability;
  • whether a new BUY is allowed.

A key rule is:

Restarting an active automated cycle must not disable it.

If a SELL was filled while the process was offline, reconciliation completes the terminal transition. If the automated strategy is still active and the fresh gates pass, the next cycle can continue.

If the strategy was explicitly stopped, the existing SELL remains tracked but a new cycle does not begin.


What the architecture still does not solve perfectly

Separating the exchange boundary improved the system, but it did not eliminate every failure window.

Some remaining engineering problems are:

Durable placement intent

There is still a difficult crash window:

exchange accepts order
→ process crashes
→ local persistence has not happened yet
Enter fullscreen mode Exit fullscreen mode

Client-generated order IDs reduce this risk during a live request. The auto-averaging executor already persists placement intent before its aggregated SELL, but the ordinary BUY → SELL cycle does not yet generalize the same durable intent model.

Extending that model adds schema and state-machine complexity, so it should be introduced as a deliberate migration rather than a small refactor.

Partial-fill accounting

The exchange boundary already normalizes PARTIALLY_FILLED, and several recovery paths account for it. A partially filled order that later becomes canceled is still more complicated than a simple CANCELED state.

The executed portion still matters for balance, cost basis, and the next recovery action.

This deserves dedicated behavioral and database integration tests.

Notification delivery

Trade persistence should not depend on Telegram availability.

A durable outbox would improve notification reliability and remove messaging from the critical trading path, but it also introduces another state machine and worker.

For the current scale, this is an explicit trade-off rather than an invisible assumption.


The main lessons

The most useful lessons from building this system were not related to indicators or market prediction.

They were architectural:

  1. Do not let raw exchange payloads enter business logic.
  2. Treat WebSocket events as signals, not automatically trusted commands.
  3. Keep reconciliation even after moving to event-driven processing.
  4. Separate tracking an existing order from permission to place a new one.
  5. Generate stable client order IDs before sending requests.
  6. Design restart recovery before adding more strategies.
  7. Measure database behavior before adding indexes or larger infrastructure.
  8. Fail closed when the runtime cannot prove that a new placement is safe.

The exchange adapter is only one part of the system.

The more important boundary is between:

  • remote exchange state;
  • durable local state;
  • business intent;
  • side effects such as notifications.

That boundary determines whether a trading bot can recover from failures without silently creating a second order or forgetting an existing one.


Closing question

I’m continuing to improve NOVA as a practical case study in event-driven Spot trading automation.

I would be interested in hearing how other developers handle the hardest boundary:

How do you persist order intent so that a crash after exchange acceptance cannot produce either a lost remote order or an unsafe duplicate retry?


Disclosure: This article was prepared with AI assistance and then reviewed, corrected, and approved by the author based on his own development and production experience.

Top comments (2)

Collapse
 
topstar_ai profile image
Luis Cruz

I appreciate how you've isolated each market pair into its own runtime process with a dedicated API credential context, which provides strong fault isolation and prevents issues in one market from affecting others. The use of a neutral exchange runtime bundle, as defined by the ExchangeRuntimeBundle protocol, also helps to decouple the trading core from exchange-specific details. This approach reminds me of the Adapter pattern, where the exchange-specific implementation is wrapped by a standardized interface, making it easier to add support for new exchanges without modifying the core logic. Have you considered using a similar pattern to standardize the handling of different order types, such as stop-loss or take-profit orders, across various exchanges?

Collapse
 
weekendly profile image
Даниил Шпытко

Thanks — that is a useful distinction.

In NOVA's current strategy, the profit exit is already a regular limit SELL placed above the average entry price. In that sense, it serves the role of a take-profit without depending on an exchange-specific conditional order.

A stop-loss is intentionally not part of the current workflow. NOVA operates exclusively on the Spot market and does not automatically close a position at a loss. When the price moves down, the current mechanics are built around holding the asset, optional DCA, and averaging existing SELL positions.

At the code level, the neutral contract currently supports MARKET and LIMIT orders, which is enough for the production cycle. I would extend it only when a new strategy actually requires conditional orders. At that point, I would model a semantic exit intent together with exchange capability checks rather than simply add universal STOP_LOSS or TAKE_PROFIT values, because trigger semantics and Spot support differ across exchanges.

So yes, the Adapter pattern can be extended, but I see stop-loss primarily as a trading-strategy decision rather than just another exchange order type.