<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Даниил Шпытко</title>
    <description>The latest articles on DEV Community by Даниил Шпытко (@weekendly).</description>
    <link>https://dev.to/weekendly</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4036477%2F86dade48-e103-4433-8bd0-08194877c3ad.jpg</url>
      <title>DEV Community: Даниил Шпытко</title>
      <link>https://dev.to/weekendly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/weekendly"/>
    <language>en</language>
    <item>
      <title>Designing Safe API Boundaries for a Multi-Pair Spot Trading Bot</title>
      <dc:creator>Даниил Шпытко</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:35:53 +0000</pubDate>
      <link>https://dev.to/weekendly/designing-safe-api-boundaries-for-a-multi-pair-spot-trading-bot-3cho</link>
      <guid>https://dev.to/weekendly/designing-safe-api-boundaries-for-a-multi-pair-spot-trading-bot-3cho</guid>
      <description>&lt;p&gt;I’m building NOVA, a Telegram-based system for automating repeatable Spot trading cycles.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;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?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At first glance, a Spot trading cycle looks simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Buy an asset for a specified amount.&lt;/li&gt;
&lt;li&gt;Place a limit sell order above the entry price.&lt;/li&gt;
&lt;li&gt;Wait until the sell order is filled.&lt;/li&gt;
&lt;li&gt;Start the next cycle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In production, however, every arrow in this sequence can fail.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What NOVA actually does
&lt;/h2&gt;

&lt;p&gt;NOVA automates Spot trading rules configured by the user.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For each connected market pair, the user defines parameters such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the purchase amount;&lt;/li&gt;
&lt;li&gt;the target percentage for the limit sell;&lt;/li&gt;
&lt;li&gt;the price range in which new cycles are allowed;&lt;/li&gt;
&lt;li&gt;the capital limit;&lt;/li&gt;
&lt;li&gt;optional DCA parameters;&lt;/li&gt;
&lt;li&gt;optional averaging behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The funds remain in the user’s exchange account.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One design decision is especially important:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One market pair is isolated into one runtime process and uses its own API credential context.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This creates some operational overhead, but it also gives strong fault isolation.&lt;/p&gt;

&lt;p&gt;A problem in one market process does not have to stop all other markets.&lt;/p&gt;




&lt;h2&gt;
  
  
  The danger of coupling the core to an exchange
&lt;/h2&gt;

&lt;p&gt;The first version of many trading systems usually spreads exchange-specific details across the codebase:&lt;/p&gt;

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

&lt;p&gt;That approach works until a second exchange needs to be added.&lt;/p&gt;

&lt;p&gt;Then every business module implicitly depends on the first exchange.&lt;/p&gt;

&lt;p&gt;The problem is not only maintainability. It also makes recovery logic dangerous.&lt;/p&gt;

&lt;p&gt;If the trading core directly interprets raw exchange responses, a transport change can silently affect business transitions.&lt;/p&gt;

&lt;p&gt;I wanted the core to operate with concepts such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;market identity;&lt;/li&gt;
&lt;li&gt;order side;&lt;/li&gt;
&lt;li&gt;order type;&lt;/li&gt;
&lt;li&gt;normalized order status;&lt;/li&gt;
&lt;li&gt;quantity;&lt;/li&gt;
&lt;li&gt;executed quantity;&lt;/li&gt;
&lt;li&gt;price;&lt;/li&gt;
&lt;li&gt;account balance;&lt;/li&gt;
&lt;li&gt;market rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It should not need to know how MEXC spells a field, constructs a symbol, opens a private stream, or signs a request.&lt;/p&gt;




&lt;h2&gt;
  
  
  The neutral exchange runtime
&lt;/h2&gt;

&lt;p&gt;The current architecture uses a neutral runtime bundle.&lt;/p&gt;

&lt;p&gt;A simplified version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Protocol&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExchangeRuntimeBundle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ExchangeRuntimeDescriptor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;spot_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaSpotExchangeClient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;order_gateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaRestOrderGateway&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;account_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaBalanceReader&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;fee_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaFeeRateReader&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;credential_validator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaCredentialValidator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;market_metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaMarketMetadataProvider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;error_policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaExchangeErrorPolicy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;public_market_stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaPublicMarketStream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_private_order_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user_context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UserContext&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NovaPrivateOrderSession&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trading core receives this runtime through a factory.&lt;/p&gt;

&lt;p&gt;Today, the active factory supports MEXC only. Unsupported exchange codes fail closed.&lt;/p&gt;

&lt;p&gt;That is intentional.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The MEXC boundary owns:&lt;/p&gt;

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

&lt;p&gt;The core owns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trading decisions;&lt;/li&gt;
&lt;li&gt;state transitions;&lt;/li&gt;
&lt;li&gt;capital constraints;&lt;/li&gt;
&lt;li&gt;lifecycle ownership;&lt;/li&gt;
&lt;li&gt;recovery policy;&lt;/li&gt;
&lt;li&gt;notifications;&lt;/li&gt;
&lt;li&gt;persistence.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A normalized order model
&lt;/h2&gt;

&lt;p&gt;Raw responses are converted into a neutral order snapshot before they reach business logic.&lt;/p&gt;

&lt;p&gt;A simplified model might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NovaOrderSnapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;market_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NovaOrderSide&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;order_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NovaOrderType&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;nova_order_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NovaOrderStatus&lt;/span&gt;
    &lt;span class="n"&gt;exchange_order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;nova_client_order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;orig_base_quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;filled_base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;filled_quote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;avg_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;status_present&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;matches_request_identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The business layer works only with normalized enums such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NovaOrderStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;NEW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NEW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PARTIALLY_FILLED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PARTIALLY_FILLED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FILLED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FILLED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;CANCELED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CANCELED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;REJECTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REJECTED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;EXPIRED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXPIRED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;UNKNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNKNOWN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents raw transport fields from becoming part of the business contract.&lt;/p&gt;

&lt;p&gt;The same principle is used for private WebSocket events.&lt;/p&gt;

&lt;p&gt;The MEXC adapter parses the raw frame and produces a neutral private-order event. Deduplication and lifecycle processing happen only after normalization.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trading state machine
&lt;/h2&gt;

&lt;p&gt;The normal automated cycle can be represented like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READY
  ↓
BUY_SUBMITTING
  ↓
BUY_CONFIRMED
  ↓
SELL_SUBMITTING
  ↓
WAITING_FOR_SELL
  ↓
SELL_FILLED
  ↓
FINALIZING
  ↓
READY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But a production state machine also needs recovery transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important idea is that an HTTP response is not always the source of truth.&lt;/p&gt;

&lt;p&gt;The exchange may accept an order even when the application never receives the response.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Private WebSocket as the primary signal
&lt;/h2&gt;

&lt;p&gt;Originally, the system checked order state in PostgreSQL every second.&lt;/p&gt;

&lt;p&gt;Two separate loops repeatedly read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user record;&lt;/li&gt;
&lt;li&gt;the subscription;&lt;/li&gt;
&lt;li&gt;the current task;&lt;/li&gt;
&lt;li&gt;the trade state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The architecture was changed so that private order events are now the primary terminal signal.&lt;/p&gt;

&lt;p&gt;The normal path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REST and database reconciliation still exist, but only as a safety mechanism.&lt;/p&gt;

&lt;p&gt;The current behavior uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a normal reconciliation interval of approximately 60 seconds;&lt;/li&gt;
&lt;li&gt;a shorter interval when the private stream is degraded;&lt;/li&gt;
&lt;li&gt;startup reconciliation after a process restart;&lt;/li&gt;
&lt;li&gt;reconciliation after a private-stream reconnect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;More importantly, order completion became event-driven instead of waiting for the next polling interval.&lt;/p&gt;




&lt;h2&gt;
  
  
  Exactly-once is a business problem, not a WebSocket problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Therefore, the event handler cannot simply trust that every event is new.&lt;/p&gt;

&lt;p&gt;A simplified terminal handler looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_terminal_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;order_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;executed_quantity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executed_quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;average_price&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;average_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release_current_blocker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wake_next_cycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real implementation has more checks, but the important protections are:&lt;/p&gt;

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

&lt;p&gt;This does not magically provide distributed exactly-once delivery.&lt;/p&gt;

&lt;p&gt;It does make the business transition idempotent under the failure modes the system currently supports.&lt;/p&gt;




&lt;h2&gt;
  
  
  Subscription expiry must not abandon an existing order
&lt;/h2&gt;

&lt;p&gt;Subscription logic is another area where a simple implementation can become unsafe.&lt;/p&gt;

&lt;p&gt;A tempting rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subscription inactive → stop everything
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is incorrect when the system already placed a SELL order.&lt;/p&gt;

&lt;p&gt;The safer rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inactive subscription:
    track and finalize existing orders
    block new BUY placements
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In NOVA, an existing SELL continues to be monitored even if the subscription expires.&lt;/p&gt;

&lt;p&gt;When the SELL is filled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the result is persisted;&lt;/li&gt;
&lt;li&gt;the trade is finalized;&lt;/li&gt;
&lt;li&gt;the user can be notified;&lt;/li&gt;
&lt;li&gt;a new BUY is not created unless the subscription is active again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same distinction applies to credentials.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Credentials should be validated at lifecycle boundaries
&lt;/h2&gt;

&lt;p&gt;Repeatedly reading the same API key from the database does not prove that the key is valid.&lt;/p&gt;

&lt;p&gt;Credential safety is handled through lifecycle gates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when a user runtime is created;&lt;/li&gt;
&lt;li&gt;after credentials are updated;&lt;/li&gt;
&lt;li&gt;before a new placement;&lt;/li&gt;
&lt;li&gt;after an authentication error;&lt;/li&gt;
&lt;li&gt;when the credential version no longer matches the active runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The runtime stores a credential version rather than treating a connection as valid forever.&lt;/p&gt;

&lt;p&gt;Before a new BUY, the system checks that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the subscription snapshot is fresh and active;&lt;/li&gt;
&lt;li&gt;credentials are actionable;&lt;/li&gt;
&lt;li&gt;the credential version matches the runtime;&lt;/li&gt;
&lt;li&gt;the user context is not waiting for a rebuild;&lt;/li&gt;
&lt;li&gt;no conflicting order lifecycle is active.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This removes constant database polling without weakening placement safety.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why one process per market pair?
&lt;/h2&gt;

&lt;p&gt;NOVA currently runs a separate worker process for each supported market profile.&lt;/p&gt;

&lt;p&gt;This has clear advantages:&lt;/p&gt;

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

&lt;p&gt;It also has costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more WebSocket connections;&lt;/li&gt;
&lt;li&gt;more database pools;&lt;/li&gt;
&lt;li&gt;more schedulers;&lt;/li&gt;
&lt;li&gt;more process supervision;&lt;/li&gt;
&lt;li&gt;more operational configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small system, the isolation benefits currently outweigh the additional resource use.&lt;/p&gt;

&lt;p&gt;I would not automatically recommend this topology for every trading platform. It is a deliberate trade-off, not a universal pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recovery after restart
&lt;/h2&gt;

&lt;p&gt;A process restart is normal in production. It should not be treated as an exceptional event.&lt;/p&gt;

&lt;p&gt;During startup, the worker restores state before starting ordinary trading.&lt;/p&gt;

&lt;p&gt;It checks:&lt;/p&gt;

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

&lt;p&gt;A key rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Restarting an active automated cycle must not disable it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If the strategy was explicitly stopped, the existing SELL remains tracked but a new cycle does not begin.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the architecture still does not solve perfectly
&lt;/h2&gt;

&lt;p&gt;Separating the exchange boundary improved the system, but it did not eliminate every failure window.&lt;/p&gt;

&lt;p&gt;Some remaining engineering problems are:&lt;/p&gt;

&lt;h3&gt;
  
  
  Durable placement intent
&lt;/h3&gt;

&lt;p&gt;There is still a difficult crash window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exchange accepts order
→ process crashes
→ local persistence has not happened yet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Partial-fill accounting
&lt;/h3&gt;

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

&lt;p&gt;The executed portion still matters for balance, cost basis, and the next recovery action.&lt;/p&gt;

&lt;p&gt;This deserves dedicated behavioral and database integration tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notification delivery
&lt;/h3&gt;

&lt;p&gt;Trade persistence should not depend on Telegram availability.&lt;/p&gt;

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

&lt;p&gt;For the current scale, this is an explicit trade-off rather than an invisible assumption.&lt;/p&gt;




&lt;h2&gt;
  
  
  The main lessons
&lt;/h2&gt;

&lt;p&gt;The most useful lessons from building this system were not related to indicators or market prediction.&lt;/p&gt;

&lt;p&gt;They were architectural:&lt;/p&gt;

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

&lt;p&gt;The exchange adapter is only one part of the system.&lt;/p&gt;

&lt;p&gt;The more important boundary is between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remote exchange state;&lt;/li&gt;
&lt;li&gt;durable local state;&lt;/li&gt;
&lt;li&gt;business intent;&lt;/li&gt;
&lt;li&gt;side effects such as notifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That boundary determines whether a trading bot can recover from failures without silently creating a second order or forgetting an existing one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing question
&lt;/h2&gt;

&lt;p&gt;I’m continuing to improve NOVA as a practical case study in event-driven Spot trading automation.&lt;/p&gt;

&lt;p&gt;I would be interested in hearing how other developers handle the hardest boundary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>architecture</category>
      <category>websockets</category>
      <category>fintech</category>
    </item>
  </channel>
</rss>
