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
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
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
}
An exchange order requires operational details:
{
"category": "linear",
"symbol": "BTCUSDT",
"side": "Sell",
"orderType": "Market",
"qty": "0.001"
}
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}
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)
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"
}
A local signal engine may produce:
{
"symbol": "BTCUSDT",
"action": "SELL_FUTURES",
"price": 62480.5,
"strategy": "ema_rsi_macd",
"interval": "15",
"source": "pydev_signal_engine",
}
A Telegram emergency action may not have a market price at all:
{
"symbol": "BTCUSDT",
"action": "CLOSE_ALL",
"source": "telegram",
}
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"),
}
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
The same principle can be extended later with actions such as:
SELL_SPOT
BUY_FUTURES
CLOSE_SPOT
CLOSE_FUTURES
REDUCE_POSITION
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
This makes logs and analytics easier to interpret.
unsupported_action_SELL
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
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
This creates a clean separation:
Signal Source
│
└── symbol + action
Trade Execution Engine
│
└── configuration lookup
Execution Layer
│
└── exchange-specific parameters
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,
}
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,
}
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),
)
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),
)
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,
)
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,
)
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,
}
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()
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
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",
}
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"
}
The engine stores processed IDs.
if storage.signal_exists(signal_id):
return {
"ok": False,
"reason": "duplicate_signal",
"signal_id": signal_id,
}
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()
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
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]
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,
)
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}",
}
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"
}
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
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),
}
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
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"
}
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)
That produces one execution path:
TradingView ─┐
├──► Trade Execution Engine
Signal Engine┤
│
Telegram ────┘
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
Rejection logs should be equally clear.
[REJECT] Pair disabled: ETHUSDT
or:
[REJECT] Short position already open: BTCUSDT
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
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
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
Top comments (2)
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?
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!