DEV Community

Cover image for Designing a Modular Trade Execution Engine in Python
PyDev
PyDev

Posted on

Designing a Modular Trade Execution Engine in Python

Series: Engineering an Algorithmic Trading Platform in Python

Part: 2 of 18

About this series

This series documents the engineering decisions behind building a production-ready algorithmic trading platform in Python. The focus is not on trading strategies or profit claims. It is on software architecture, trade execution, state management, real-time data processing, and system reliability.


Introduction

In the first article, I described how a simple TradingView webhook project gradually evolved into a modular algorithmic trading platform.

The first version was straightforward:

TradingView
      │
      ▼
FastAPI Webhook
      │
      ▼
Bybit API
Enter fullscreen mode Exit fullscreen mode

A signal arrived, Python parsed the payload, and the application submitted an order.

That flow was enough to prove that the integration worked.

It was not enough to build reliable trading software.

As soon as the platform needed to support Spot orders, Futures short positions, reverse signals, disabled pairs, position limits, restart recovery, partial exits, and multiple signal sources, one component became unavoidable:

the Trade Execution Engine

The Trade Execution Engine is the layer that converts external intent into controlled trading actions.

It receives normalized signals from TradingView or a local Python signal engine, checks the current system state, validates the request against configuration, decides whether execution is allowed, and routes the action to the correct exchange method.

That sounds simple when written as one sentence.

In practice, this layer becomes the boundary between untrusted external input and real exchange activity.

A mistake here does not produce a broken page or a failed background job.

It can open the wrong market, use the wrong position side, execute the same signal twice, or create a position that the rest of the platform cannot manage correctly.

This article explores the architecture of a modular Python Trade Execution Engine, including:

  • signal normalization;
  • action routing;
  • pair configuration;
  • Spot and Futures separation;
  • state-aware decisions;
  • reverse signals;
  • duplicate protection;
  • order execution flow;
  • error boundaries;
  • persistent position state;
  • Dry Run, Testnet, and Mainnet modes;
  • trade-offs discovered during development.

The examples are based on a real Python algorithmic trading platform built with FastAPI, TradingView webhooks, the Bybit API, WebSockets, SQLite analytics, and Telegram control.


What the Trade Execution Engine Actually Does

The name can be misleading.

A Trade Execution Engine should not be one large function that contains the complete trading platform.

It should not:

  • calculate EMA, RSI, or MACD;
  • receive raw HTTP requests;
  • maintain Telegram menus;
  • calculate monthly trading analytics;
  • subscribe directly to every WebSocket stream;
  • format user notifications;
  • contain exchange credentials;
  • store unrelated application settings.

Its responsibility is narrower:

Accept a validated trading signal, evaluate whether the action is allowed, dispatch the correct execution request, and synchronize the resulting position state.

A simplified flow looks like this:

Normalized Signal
       │
       ▼
Trade Execution Engine
       │
       ├── Validate symbol
       ├── Validate action
       ├── Load pair configuration
       ├── Check enabled state
       ├── Check execution mode
       ├── Inspect open positions
       ├── Apply signal rules
       └── Dispatch execution
                 │
                 ▼
        Exchange Client
                 │
                 ▼
        Position Manager
                 │
                 ▼
          Persistent State
Enter fullscreen mode Exit fullscreen mode

This boundary is important because signals and orders are not the same thing.

A signal expresses intent:

{
  "symbol": "BTCUSDT",
  "action": "SELL_FUTURES",
  "price": 62480.5
}
Enter fullscreen mode Exit fullscreen mode

An exchange order requires operational details:

{
  "category": "linear",
  "symbol": "BTCUSDT",
  "side": "Sell",
  "orderType": "Market",
  "qty": "0.001"
}
Enter fullscreen mode Exit fullscreen mode

The Trade Execution Engine is responsible for translating one into the other without allowing infrastructure details to leak into signal sources.


Engineering decision

TradingView should describe what the strategy wants to do. It should not need to know how the exchange API expects that action to be executed.


Why the Webhook Should Not Contain Trading Logic

It is tempting to process everything directly inside the FastAPI route.

A minimal example often looks like this:

@app.post("/webhook")
def webhook(payload: dict):
    if payload["action"] == "BUY":
        bybit.place_spot_buy(
            symbol=payload["symbol"],
            qty=payload["qty"],
        )

    return {"ok": True}
Enter fullscreen mode Exit fullscreen mode

This works until the application needs any real rules.

For example:

  • the pair may be disabled;
  • the signal may use an unsupported action;
  • another position may already be open;
  • the same webhook may arrive twice;
  • the platform may be in Dry Run mode;
  • Spot buying may be disabled while Futures trading remains enabled;
  • the symbol may not exist in the local order configuration;
  • a reverse signal may require closing one position before opening another;
  • the exchange may accept the order but return incomplete execution data;
  • position state may fail to persist after the order succeeds.

If all of those checks live inside the webhook endpoint, the API layer becomes tightly coupled to trading behaviour.

That creates several problems.

It becomes difficult to test

Testing an HTTP route should not require a live exchange client.

It becomes difficult to reuse

A local Python signal engine cannot reuse webhook-only logic cleanly.

It becomes difficult to control

Telegram actions, TradingView webhooks, and internal signals start following different execution paths.

It becomes difficult to recover

If order execution and persistence fail at different stages, there is no single component responsible for resolving the inconsistency.

A cleaner webhook endpoint only validates and forwards the request.

from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.post("/webhook")
def webhook(payload: dict):
    if payload.get("secret") != settings.webhook_secret:
        raise HTTPException(status_code=403, detail="Invalid secret")

    signal = {
        "symbol": str(payload.get("symbol", "")).upper(),
        "action": str(payload.get("action", "")).upper(),
        "price": float(payload.get("price", 0)),
        "source": "tradingview",
    }

    return trade_engine.process_signal(signal)
Enter fullscreen mode Exit fullscreen mode

The HTTP layer remains stateless.

The Trade Execution Engine owns the trading decision.


Designing a Stable Signal Contract

Before the engine can route anything, it needs a stable signal format.

Different signal sources often produce different payloads.

TradingView may send:

{
  "secret": "test123",
  "strategy": "ema-cross",
  "exchange": "BYBIT",
  "symbol": "BTCUSDT",
  "interval": "15",
  "price": "62480.5",
  "time": "2026-07-15T10:30:00Z",
  "action": "SELL_FUTURES"
}
Enter fullscreen mode Exit fullscreen mode

A local signal engine may produce:

{
    "symbol": "BTCUSDT",
    "action": "SELL_FUTURES",
    "price": 62480.5,
    "strategy": "ema_rsi_macd",
    "interval": "15",
    "source": "pydev_signal_engine",
}
Enter fullscreen mode Exit fullscreen mode

A Telegram emergency action may not have a market price at all:

{
    "symbol": "BTCUSDT",
    "action": "CLOSE_ALL",
    "source": "telegram",
}
Enter fullscreen mode Exit fullscreen mode

If every source uses a different internal representation, the engine becomes full of special cases.

The solution is signal normalization.

from typing import Any

SUPPORTED_ACTIONS = {
    "BUY_SPOT",
    "SELL_FUTURES",
    "CLOSE_ALL",
}

def normalize_signal(payload: dict[str, Any]) -> dict[str, Any]:
    symbol = str(payload.get("symbol", "")).strip().upper()
    action = str(payload.get("action", "")).strip().upper()

    price_raw = payload.get("price")
    price = float(price_raw) if price_raw not in (None, "") else None

    return {
        "symbol": symbol,
        "action": action,
        "price": price,
        "strategy": payload.get("strategy"),
        "interval": str(payload.get("interval", "")),
        "source": payload.get("source", "unknown"),
        "signal_id": payload.get("signal_id"),
        "received_at": payload.get("received_at"),
    }
Enter fullscreen mode Exit fullscreen mode

Every source is converted into the same contract before reaching the Trade Execution Engine.

That gives the engine a predictable input.


Key takeaway

A stable internal signal contract is more important than the original payload format. External integrations can change without forcing the core engine to change with them.


Explicit Trading Actions Are Better Than Ambiguous BUY and SELL

Generic BUY and SELL actions are often insufficient when one platform manages both Spot and Futures.

Consider the word SELL.

It may mean:

  • sell an existing Spot balance;
  • open a Futures short position;
  • close a Futures long position;
  • reduce a short position;
  • close the entire position;
  • execute a partial take profit.

Those are completely different operations.

For this reason, the platform uses explicit actions:

BUY_SPOT
SELL_FUTURES
CLOSE_ALL
Enter fullscreen mode Exit fullscreen mode

The same principle can be extended later with actions such as:

SELL_SPOT
BUY_FUTURES
CLOSE_SPOT
CLOSE_FUTURES
REDUCE_POSITION
Enter fullscreen mode Exit fullscreen mode

The exact action set depends on platform scope.

The important part is that the command should describe intent clearly enough that the engine does not need to guess.

A basic validation step looks like this:

def validate_action(action: str) -> tuple[bool, str | None]:
    allowed = {
        "BUY_SPOT",
        "SELL_FUTURES",
        "CLOSE_ALL",
    }

    if action not in allowed:
        return False, f"unsupported_action_{action}"

    return True, None
Enter fullscreen mode Exit fullscreen mode

This makes logs and analytics easier to interpret.

unsupported_action_SELL
Enter fullscreen mode Exit fullscreen mode

is much more useful than silently executing the wrong operation.


Loading Pair Configuration

The Trade Execution Engine should not hardcode trading amounts, leverage, take-profit settings, or pair permissions.

Those values belong in pair configuration.

A simplified orders.ini section may look like this:

[BTCUSDT]
bybit_pair = BTCUSDT
enabled = True
spot_amount = 100.0
futures_qty = 0.001
take_profit_1_percent = 5.0
take_profit_2_percent = 10.0
stop_loss_percent = 5.0
close_percent_on_tp1 = 50.0
close_percent_on_tp2 = 50.0
move_sl_to_breakeven = True
trailing_percent = 3.0
allow_spot_buy = True
allow_futures_short = True
max_open_positions = 1
leverage = 10
Enter fullscreen mode Exit fullscreen mode

The engine receives only the symbol.

It loads everything else from the platform configuration.

def get_order_by_symbol(self, symbol: str) -> dict | None:
    for section_name, order in self.orders.items():
        if order.get("bybit_pair", "").upper() == symbol:
            return order

    return None
Enter fullscreen mode Exit fullscreen mode

This creates a clean separation:

Signal Source
    │
    └── symbol + action

Trade Execution Engine
    │
    └── configuration lookup

Execution Layer
    │
    └── exchange-specific parameters
Enter fullscreen mode Exit fullscreen mode

The signal source does not control leverage or quantity unless the system explicitly allows it.

That is a safer default for automated trading infrastructure.


Rejecting Unknown and Disabled Pairs

A production engine should fail closed.

If a symbol is not configured, it should not improvise.

order_config = self.get_order_by_symbol(symbol)

if not order_config:
    print(f"[REJECT] Pair not found in orders.ini: {symbol}")

    return {
        "ok": False,
        "reason": "pair_not_found",
        "symbol": symbol,
    }
Enter fullscreen mode Exit fullscreen mode

The same applies to disabled pairs.

if not order_config.get("enabled", False):
    print(f"[REJECT] Pair disabled: {symbol}")

    return {
        "ok": False,
        "reason": "pair_disabled",
        "symbol": symbol,
    }
Enter fullscreen mode Exit fullscreen mode

This may look like a small detail, but it prevents an external strategy from activating symbols that the platform operator did not approve.

The configuration is the authority.

The signal is only a request.


Common pitfall

Treating every valid-looking webhook as permission to trade. A syntactically valid signal can still violate local configuration and risk rules.


Separating Spot and Futures Execution

Spot and Futures orders may look similar at first.

Both submit market orders.

Both use a symbol and quantity.

Both return an exchange order ID.

But their semantics are different.

Spot BUY

A Spot buy typically:

  • spends quote currency;
  • acquires base currency;
  • does not use leverage;
  • cannot normally create a negative asset balance;
  • may use quote amount or base quantity depending on API parameters.

A Bybit Spot market buy can be represented like this:

result = session.place_order(
    category="spot",
    symbol=symbol,
    side="Buy",
    orderType="Market",
    marketUnit="BaseCoin",
    qty=str(qty),
)
Enter fullscreen mode Exit fullscreen mode

Futures short

A Futures short typically:

  • opens a derivative position;
  • uses leverage;
  • has liquidation risk;
  • can incur funding;
  • requires reduce-only logic when closing;
  • must distinguish between opening and closing sides.

A Bybit linear Futures short can be represented like this:

result = session.place_order(
    category="linear",
    symbol=symbol,
    side="Sell",
    orderType="Market",
    qty=str(qty),
)
Enter fullscreen mode Exit fullscreen mode

Using one generic place_order() call for both markets can hide important differences.

The engine should route them explicitly.

if action == "BUY_SPOT":
    return self._execute_spot_buy(
        symbol=symbol,
        order_config=order_config,
        signal=signal,
    )

if action == "SELL_FUTURES":
    return self._execute_futures_short(
        symbol=symbol,
        order_config=order_config,
        signal=signal,
    )
Enter fullscreen mode Exit fullscreen mode

This keeps market-specific rules isolated.


A Practical process_signal() Structure

The public engine method should remain readable.

It should describe the execution pipeline without containing every implementation detail.

def process_signal(self, signal: dict) -> dict:
    symbol = str(signal.get("symbol", "")).upper()
    action = str(signal.get("action", "")).upper()

    print(f"[TRADE ENGINE] signal={signal}")

    if not symbol and action != "CLOSE_ALL":
        return {
            "ok": False,
            "reason": "empty_symbol",
        }

    if action not in {
        "BUY_SPOT",
        "SELL_FUTURES",
        "CLOSE_ALL",
    }:
        return {
            "ok": False,
            "reason": f"unsupported_action_{action}",
        }

    if action == "CLOSE_ALL":
        return self.close_all_positions(
            source=signal.get("source", "unknown"),
        )

    order_config = self.get_order_by_symbol(symbol)

    if not order_config:
        return {
            "ok": False,
            "reason": "pair_not_found",
            "symbol": symbol,
        }

    if not order_config.get("enabled", False):
        return {
            "ok": False,
            "reason": "pair_disabled",
            "symbol": symbol,
        }

    if action == "BUY_SPOT":
        return self._handle_spot_buy(
            signal=signal,
            order_config=order_config,
        )

    return self._handle_futures_short(
        signal=signal,
        order_config=order_config,
    )
Enter fullscreen mode Exit fullscreen mode

This method acts as an orchestration layer.

It does not need to know every exchange parameter or every persistence field.

Those details belong in smaller components.


State Awareness Before Execution

A trading action should never be evaluated in isolation.

The engine needs to inspect current platform state.

Suppose SELL_FUTURES arrives for BTCUSDT.

The correct response depends on what already exists.

No open position

The engine may open a new short.

Existing short position

The engine may reject the duplicate, ignore it, or add to the position depending on platform policy.

Existing Spot position

The engine may allow both positions, or it may treat the signal as a reverse action.

Position already closing

The engine should avoid starting another conflicting transition.

Pair disabled after the signal was generated

The engine should reject the action.

This is why persistent position state is not just an analytics feature.

It is part of execution safety.

A simplified check may look like this:

position = storage.get_open_position(symbol)

if position:
    current_side = position.get("side")
    current_market = position.get("market")

    if action == "SELL_FUTURES":
        if current_market == "futures" and current_side == "short":
            return {
                "ok": False,
                "reason": "short_position_already_open",
                "symbol": symbol,
            }
Enter fullscreen mode Exit fullscreen mode

The exact policy may differ between strategies.

What matters is that it is explicit.


Reverse Signals Need a Defined State Transition

Reverse signals are more complicated than they appear.

A naive implementation might do this:

if signal == "SELL":
    close_buy()
    open_short()
Enter fullscreen mode Exit fullscreen mode

But several questions remain.

  • Was the first position fully closed?
  • Was it partially filled?
  • Did the close request fail?
  • Is the remaining quantity zero?
  • Should the new position open immediately?
  • Should the engine wait for exchange confirmation?
  • What happens if a second reverse signal arrives during the transition?

A safer model treats reversal as two separate state transitions.

OPEN SPOT POSITION
        │
        ▼
REVERSE SIGNAL RECEIVED
        │
        ▼
CLOSE CURRENT POSITION
        │
        ▼
VERIFY CLOSED STATE
        │
        ▼
OPEN FUTURES SHORT
Enter fullscreen mode Exit fullscreen mode

The second action should not occur until the first one is confirmed.

A high-level implementation may look like this:

def handle_reverse_signal(
    self,
    symbol: str,
    target_action: str,
    signal: dict,
) -> dict:
    close_result = self.close_position(
        symbol=symbol,
        reason="reverse_signal",
    )

    if not close_result.get("ok"):
        return {
            "ok": False,
            "reason": "reverse_close_failed",
            "details": close_result,
        }

    if target_action == "SELL_FUTURES":
        return self._handle_futures_short(
            signal=signal,
            order_config=self.get_order_by_symbol(symbol),
        )

    return {
        "ok": False,
        "reason": "unsupported_reverse_target",
    }
Enter fullscreen mode Exit fullscreen mode

In a more advanced implementation, the engine may wait for execution confirmation before opening the opposite position.

That avoids overlapping exposure.


Engineering note

A reverse signal is not one order. It is a coordinated workflow with at least two exchange operations and one persistent state transition.


Duplicate Signal Protection

Duplicate signals are an important risk in webhook-driven trading systems.

Duplicates can happen because of:

  • TradingView alert configuration;
  • webhook retries;
  • network timeouts;
  • manual resubmission;
  • strategy bugs;
  • multiple signal sources producing the same action;
  • application restart during request processing.

There are several ways to handle this.

Signal ID

The best option is a unique identifier generated by the source.

{
  "signal_id": "ema-cross-BTCUSDT-15m-20260715T103000Z",
  "symbol": "BTCUSDT",
  "action": "SELL_FUTURES"
}
Enter fullscreen mode Exit fullscreen mode

The engine stores processed IDs.

if storage.signal_exists(signal_id):
    return {
        "ok": False,
        "reason": "duplicate_signal",
        "signal_id": signal_id,
    }
Enter fullscreen mode Exit fullscreen mode

Time-window fingerprint

When the source cannot provide a unique ID, the platform can derive a fingerprint.

import hashlib
import json

def build_signal_fingerprint(signal: dict) -> str:
    payload = {
        "symbol": signal.get("symbol"),
        "action": signal.get("action"),
        "strategy": signal.get("strategy"),
        "interval": signal.get("interval"),
        "time": signal.get("time"),
    }

    encoded = json.dumps(
        payload,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")

    return hashlib.sha256(encoded).hexdigest()
Enter fullscreen mode Exit fullscreen mode

The fingerprint can be stored with a short expiration window.

State-based rejection

Even without a signal ID, current position state may reject repeated actions.

For example, if a short is already open, another identical SELL_FUTURES signal can be ignored.

State-based protection is useful, but it is not a complete substitute for idempotency.

Two duplicate requests can arrive close enough together that both inspect the state before either one persists the new position.

That creates a race condition.


Idempotency and Race Conditions

This is where duplicate protection becomes a concurrency problem.

Consider two identical webhook requests:

Request A                 Request B
    │                         │
    ▼                         ▼
Check position           Check position
No position              No position
    │                         │
    ▼                         ▼
Place short              Place short
Enter fullscreen mode Exit fullscreen mode

Both requests passed validation.

Both saw the same state.

Both submitted an order.

The result is double exposure.

A reliable implementation needs an atomic boundary around the critical section.

For a single-process platform, a per-symbol lock may be enough.

from collections import defaultdict
from threading import Lock

class SymbolLockRegistry:
    def __init__(self) -> None:
        self._locks: dict[str, Lock] = defaultdict(Lock)

    def get(self, symbol: str) -> Lock:
        return self._locks[symbol]
Enter fullscreen mode Exit fullscreen mode

Then:

lock = self.symbol_locks.get(symbol)

with lock:
    position = storage.get_open_position(symbol)

    if position:
        return {
            "ok": False,
            "reason": "position_already_open",
        }

    return self._execute_futures_short(
        symbol=symbol,
        order_config=order_config,
        signal=signal,
    )
Enter fullscreen mode Exit fullscreen mode

For a distributed architecture with multiple workers, an in-memory lock is not enough.

The platform would need a database transaction, distributed lock, queue partitioning, or another atomic coordination mechanism.

The current platform is self-hosted and single-user, so the simpler approach is appropriate.

That may change if the architecture becomes multi-worker or multi-user.


Trade-off

The simplest concurrency model that matches the deployment environment is usually better than adding distributed infrastructure prematurely.


Execution Modes: Dry Run, Testnet, and Mainnet

A Trade Execution Engine should not assume every validated signal must reach a live exchange account.

The platform supports three operational modes.

Dry Run

No exchange order is sent.

The engine simulates execution and creates internal position state.

This is useful for:

  • testing signal routing;
  • validating risk logic;
  • testing Telegram workflows;
  • testing analytics;
  • reproducing state transitions safely.

Testnet

The engine submits real API requests to the exchange test environment.

This validates:

  • API authentication;
  • order parameters;
  • exchange responses;
  • execution IDs;
  • WebSocket subscriptions;
  • position lifecycle logic.

Mainnet

The engine submits live orders.

This mode should require explicit configuration and clear UI status.

A clean execution abstraction may look like this:

def execute_order(self, request: dict) -> dict:
    if self.mode == "dry_run":
        return self.dry_run_executor.execute(request)

    if self.mode == "testnet":
        return self.testnet_client.execute(request)

    if self.mode == "mainnet":
        return self.mainnet_client.execute(request)

    return {
        "ok": False,
        "reason": f"unsupported_mode_{self.mode}",
    }
Enter fullscreen mode Exit fullscreen mode

The Trade Execution Engine should not duplicate all logic three times.

Only the final execution adapter should change.


Order Execution and Position Persistence

Submitting the order is not the final step.

The platform must also create persistent position state.

A simplified Futures position record may contain:

{
  "symbol": "BTCUSDT",
  "market": "futures",
  "side": "short",
  "entry_price": 62480.5,
  "qty": 0.001,
  "remaining_qty": 0.001,
  "tp1_price": 59356.475,
  "tp2_price": 56232.45,
  "stop_loss_price": 65604.525,
  "breakeven_price": 62480.5,
  "tp1_hit": false,
  "tp2_hit": false,
  "trailing_active": false,
  "leverage": 10,
  "strategy": "ema_rsi_macd",
  "interval": "15",
  "signal_source": "tradingview",
  "open_order_id": "123456789",
  "status": "open",
  "mode": "testnet",
  "created_at": "2026-07-15T10:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

This state is later used by:

  • the Position Manager;
  • the WebSocket price handler;
  • take-profit logic;
  • stop-loss logic;
  • restart recovery;
  • Telegram reports;
  • analytics after closure.

The execution flow becomes:

Signal accepted
      │
      ▼
Order request built
      │
      ▼
Exchange order submitted
      │
      ▼
Execution response validated
      │
      ▼
Position state created
      │
      ▼
WebSocket subscription activated
      │
      ▼
Telegram status updated
Enter fullscreen mode Exit fullscreen mode

A failure at any stage must be visible.

Silently accepting an exchange order without storing the position is dangerous.

The exchange now has exposure, but the local platform may not know it exists.


Handling Exchange Errors

Exchange APIs fail in many ways.

Examples include:

  • invalid quantity precision;
  • insufficient balance;
  • unsupported symbol;
  • authentication errors;
  • rate limits;
  • temporary network failures;
  • rejected leverage settings;
  • market restrictions;
  • malformed API responses.

The engine should not convert every exception into a generic failure.

Structured errors make debugging easier.

try:
    result = self.bybit_client.open_futures_short(
        symbol=symbol,
        qty=qty,
    )
except TimeoutError as exc:
    return {
        "ok": False,
        "reason": "exchange_timeout",
        "symbol": symbol,
        "error": str(exc),
    }
except ValueError as exc:
    return {
        "ok": False,
        "reason": "invalid_order_parameters",
        "symbol": symbol,
        "error": str(exc),
    }
except Exception as exc:
    return {
        "ok": False,
        "reason": "exchange_execution_error",
        "symbol": symbol,
        "error": str(exc),
    }
Enter fullscreen mode Exit fullscreen mode

The external API response should remain safe.

Sensitive credentials, signatures, and internal exception details should never be returned to TradingView.

Detailed errors can still be written to logs or sent to the operator through Telegram.


Do Not Trust the Requested Price as the Entry Price

TradingView may send a price with the signal.

That price is useful for context.

It is not necessarily the actual fill price.

Between signal generation and exchange execution:

  • the market can move;
  • the webhook can be delayed;
  • the order can slip;
  • the order can fill at multiple prices.

Therefore, the final position should use exchange execution data whenever possible.

signal_price = signal.get("price")
execution_price = result.get("avg_price")

entry_price = execution_price or signal_price
Enter fullscreen mode Exit fullscreen mode

The fallback is useful in Dry Run mode or when the exchange response does not immediately provide an average fill price.

For live analytics, exchange execution data should remain the source of truth.


Lesson learned

Strategy price, requested price, order price, and execution price are not always the same value.


Keeping the Engine Independent from Analytics

It is tempting to calculate fees, ROI, and PnL directly after every order.

That creates tight coupling.

The Trade Execution Engine should record execution facts.

The Analytics Engine should calculate historical metrics.

For example, the execution layer may store:

{
  "symbol": "BTCUSDT",
  "order_id": "123456789",
  "side": "Sell",
  "market": "futures",
  "qty": 0.001,
  "price": 62480.5,
  "fee": 0.0312,
  "timestamp": "2026-07-15T10:30:02Z"
}
Enter fullscreen mode Exit fullscreen mode

Later, analytics can calculate:

  • gross PnL;
  • net PnL;
  • ROI;
  • trading fees;
  • funding;
  • win rate;
  • average holding time;
  • monthly profit;
  • pair statistics.

This separation prevents reporting changes from affecting order execution.


Keeping Telegram Outside the Core Engine

Telegram is an operational control panel.

It can:

  • show open positions;
  • enable or disable pairs;
  • display analytics;
  • change signal engine settings;
  • trigger emergency close;
  • display current execution mode.

But Telegram handlers should not duplicate execution logic.

A Telegram action should call the same engine used by TradingView.

signal = {
    "symbol": selected_symbol,
    "action": "CLOSE_ALL",
    "source": "telegram",
}

result = trade_engine.process_signal(signal)
Enter fullscreen mode Exit fullscreen mode

That produces one execution path:

TradingView ─┐
             ├──► Trade Execution Engine
Signal Engine┤
             │
Telegram ────┘
Enter fullscreen mode Exit fullscreen mode

One engine.

One set of rules.

One state model.

That consistency becomes increasingly valuable as the platform grows.


Logging the Execution Pipeline

A good execution log should answer:

  • what signal arrived;
  • where it came from;
  • why it was accepted or rejected;
  • what order was submitted;
  • what exchange response was returned;
  • what position state was created;
  • which execution mode was active.

A practical log flow might look like this:

[TRADE ENGINE] signal={
  'symbol': 'BTCUSDT',
  'action': 'SELL_FUTURES',
  'source': 'tradingview'
}

[TRADE ENGINE] pair enabled: BTCUSDT
[TRADE ENGINE] execution mode: testnet
[TRADE ENGINE] opening futures short qty=0.001
[BYBIT FUTURES SHORT] order_id=123456789
[POSITION MANAGER] position created: BTCUSDT short
[WEBSOCKET] subscribed: tickers.BTCUSDT
Enter fullscreen mode Exit fullscreen mode

Rejection logs should be equally clear.

[REJECT] Pair disabled: ETHUSDT
Enter fullscreen mode Exit fullscreen mode

or:

[REJECT] Short position already open: BTCUSDT
Enter fullscreen mode Exit fullscreen mode

Readable logs are part of system design, not an afterthought.


A More Complete Execution Flow

The complete flow can now be represented like this:

TradingView / Signal Engine / Telegram
                  │
                  ▼
          Signal Normalization
                  │
                  ▼
          Action Validation
                  │
                  ▼
        Pair Configuration Lookup
                  │
                  ▼
          Enabled / Permission Check
                  │
                  ▼
            Duplicate Check
                  │
                  ▼
          Current State Inspection
                  │
                  ▼
             Symbol Lock
                  │
                  ▼
       Spot / Futures Route Selection
                  │
                  ▼
          Exchange Request Builder
                  │
                  ▼
      Dry Run / Testnet / Mainnet Adapter
                  │
                  ▼
          Exchange Result Validation
                  │
                  ▼
          Persistent Position State
                  │
                  ▼
         WebSocket Subscription Update
                  │
                  ▼
         Telegram / Logging / Analytics
Enter fullscreen mode Exit fullscreen mode

This architecture is more complex than calling place_order() directly.

That complexity is intentional.

Every layer exists because a real failure mode appeared or became predictable during development.


What Belongs in the Trade Execution Engine

A useful rule is to classify responsibilities by question.

Does this signal describe a supported action?

Trade Execution Engine.

Is this pair allowed to trade?

Trade Execution Engine and configuration.

Is a position already open?

Trade Execution Engine and Position Storage.

How is a Bybit Futures short submitted?

Exchange Client.

Has TP1 been reached?

Position Manager.

What is the current BTCUSDT price?

WebSocket Manager.

What is the monthly net profit?

Analytics Service.

Should the operator receive a notification?

Telegram or Notification Service.

This separation keeps the engine small enough to reason about.


Current Trade Execution Features

At the current stage, the platform execution pipeline supports:

  • TradingView webhook signals;
  • local Python signal generation;
  • explicit Spot BUY actions;
  • explicit Futures SHORT actions;
  • emergency close operations;
  • pair-level enable and disable controls;
  • pair-specific quantity and risk settings;
  • Dry Run execution;
  • Bybit Testnet execution;
  • Bybit Mainnet execution mode;
  • persistent position state;
  • restart recovery;
  • dynamic WebSocket subscriptions;
  • reverse signal handling;
  • rejection of unsupported signals;
  • rejection of missing or disabled pairs;
  • structured execution results;
  • Telegram control integration;
  • SQLite-based trade analytics after closure.

Some areas continue to evolve, especially duplicate signal idempotency, exchange execution reconciliation, and more advanced handling of partial fills.

That is expected.

Production trading software is not finished when the first order succeeds.

It becomes reliable through repeated testing against failure cases.


Engineering Challenges That Appear Next

Once the Trade Execution Engine is stable, the next layer becomes the position lifecycle.

Opening a position is only the beginning.

The platform still needs to answer:

  • When is TP1 reached?
  • How much quantity should close?
  • Should Stop Loss move to Break Even?
  • When should trailing become active?
  • How are highest and lowest prices tracked?
  • How should partial exits be stored?
  • What happens after a restart?
  • How does the system close the remaining quantity safely?
  • How are Spot and Futures lifecycle rules kept separate?

Those responsibilities belong to the Position Manager.

The Trade Execution Engine creates the position.

The Position Manager keeps it alive.


Conclusion

Building a Trade Execution Engine changed the project more than any individual trading feature.

Before this layer existed, signals were too closely connected to exchange calls.

After introducing it, every signal source could use the same execution path.

TradingView, Telegram, and the local Python signal engine no longer needed to understand Bybit order parameters.

They only needed to express intent.

The engine became responsible for the difficult boundary between intent and execution:

  • validating actions;
  • loading configuration;
  • inspecting state;
  • preventing invalid transitions;
  • routing Spot and Futures operations;
  • coordinating reverse signals;
  • controlling execution modes;
  • persisting position state;
  • reporting structured results.

The most important lesson was simple:

Reliable trading software is not built around the ability to place orders. It is built around the ability to control when an order is allowed, understand what state it creates, and recover when something goes wrong.

The platform described in this series continues to evolve. A technical overview of the current project is available here:

https://py-dev.top/application-software/bybit-signal-trading-bot

The link is included for readers who want to see the broader system context. The purpose of this series remains engineering: architecture, implementation decisions, failure modes, and lessons learned while building automated trading infrastructure in Python.


What's Next

The next article will focus on designing a production-ready Position Manager in Python.

It will explore:

  • position lifecycle states;
  • partial take profit;
  • multiple take-profit levels;
  • remaining quantity;
  • Break Even logic;
  • trailing stop activation;
  • Stop Loss handling;
  • highest and lowest price tracking;
  • persistent state;
  • restart recovery;
  • Spot and Futures differences.

Next: Building a Production-Ready Position Manager for Algorithmic Trading


Suggested DEV.to Tags

python
trading
architecture
websockets
Enter fullscreen mode Exit fullscreen mode

DEV.to currently allows up to four tags per article, so the most focused set is better than trying to include every keyword.


SEO Keywords Covered

python trade execution engine
algorithmic trading platform
python trading bot architecture
Bybit API Python
TradingView webhook Python
crypto trading automation
automated trading platform
Spot trading bot
Futures trading bot
Python WebSocket trading
trade signal routing
duplicate trading signals
trading system state management
Bybit Futures API
Bybit Spot API
risk management engine
reverse trading signals
Dry Run Testnet Mainnet
crypto trading software
Python fintech development
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
topstar_ai profile image
Luis

I appreciate how you've emphasized the importance of separating concerns within the Trade Execution Engine, highlighting that it should focus on accepting validated trading signals, evaluating action allowance, dispatching execution requests, and synchronizing position state. The distinction between signals and orders is particularly noteworthy, as it underscores the need for clear intent expression and operational detail handling. In my experience with similar systems, ensuring that the Trade Execution Engine remains modular and adaptable is crucial for handling various signal sources and exchange methods, such as Bybit API and TradingView webhooks. How do you approach testing and validating the Trade Execution Engine's decision-making logic to ensure it correctly interprets diverse signal types and exchange conditions?

Collapse
 
pydevtop profile image
PyDev

Thanks, Luis!

That's exactly one of the main goals behind the architecture.

The Trade Execution Engine itself doesn't talk directly to TradingView or make assumptions about the signal source. It receives a normalized signal and makes decisions based on the current platform state.

For testing, I separate it into several layers:

• Unit tests for signal validation and routing logic.
• Mocked execution providers to simulate Spot and Futures orders.
• Persistent state tests to verify different position scenarios.
• Dry Run mode, which executes the complete decision flow without sending real orders.
• Testnet integration tests before enabling Mainnet execution.

This separation makes it much easier to verify each decision independently and helps prevent regressions as new features are added.

I'll cover the testing approach in more detail in a future article of the series. Thanks for the great question!