<?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>What Adding Bybit Taught Me About Multi-Exchange Trading Bots</title>
      <dc:creator>Даниил Шпытко</dc:creator>
      <pubDate>Fri, 07 Aug 2026 16:18:45 +0000</pubDate>
      <link>https://dev.to/weekendly/what-adding-bybit-taught-me-about-multi-exchange-trading-bots-1k31</link>
      <guid>https://dev.to/weekendly/what-adding-bybit-taught-me-about-multi-exchange-trading-bots-1k31</guid>
      <description>&lt;p&gt;The first MEXC-only version of my trading bot worked. That was exactly why adding a second exchange was dangerous.&lt;/p&gt;

&lt;p&gt;When software supports only one exchange, its behavior can look generic even when exchange-specific assumptions are scattered everywhere. A field called &lt;code&gt;status&lt;/code&gt;, a quantity returned by an order endpoint, or a successful HTTP response can quietly become part of the business logic. Nothing exposes those assumptions until another exchange behaves differently.&lt;/p&gt;

&lt;p&gt;I recently added Bybit to &lt;a href="https://infotradebot.pro" rel="noopener noreferrer"&gt;NOVA&lt;/a&gt;, the Spot trading automation system I have been building around ten isolated trading profiles and a Telegram Mini App. Bybit is now deployed for nine of those profiles. CFX/USDT remains MEXC-only because that market is not supported on Bybit.&lt;/p&gt;

&lt;p&gt;The visible result is a simple exchange selector. The work behind it was mostly about making sure that selector could not lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  An accepted order is not an executed trade
&lt;/h2&gt;

&lt;p&gt;The first boundary I had to reinforce was the difference between order acknowledgement and execution.&lt;/p&gt;

&lt;p&gt;A successful response from a create-order endpoint tells me that the exchange accepted the request. It does not necessarily tell me that the purchase is complete or that the final executed quantity is already known.&lt;/p&gt;

&lt;p&gt;That distinction matters because NOVA's normal cycle is sequential:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Submit a Spot purchase.&lt;/li&gt;
&lt;li&gt;Confirm what actually executed.&lt;/li&gt;
&lt;li&gt;Calculate the quantity available for sale.&lt;/li&gt;
&lt;li&gt;Place a limit order at the user's target price.&lt;/li&gt;
&lt;li&gt;Track that order until the cycle reaches a terminal state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step two is skipped, the next order is built from an assumption rather than exchange state.&lt;/p&gt;

&lt;p&gt;The Bybit adapter therefore treats the initial response as an acknowledgement. The lifecycle continues only after a private event or a bounded REST reconciliation produces a normalized, confirmed order state. The trading core does not need to understand Bybit's raw response fields, but it does need a trustworthy answer to a business question: &lt;em&gt;how much was actually filled?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The requested quantity may not be the sellable quantity
&lt;/h2&gt;

&lt;p&gt;This was the most practical difference.&lt;/p&gt;

&lt;p&gt;Suppose the purchase request represents one unit of an asset. If the exchange charges the fee in the purchased asset, the balance that becomes available can be slightly below one unit. Trying to place the exit order for the original requested quantity can then fail because that full quantity is no longer available.&lt;/p&gt;

&lt;p&gt;The safe 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;confirmed fill
    -&amp;gt; executed base quantity
    -&amp;gt; subtract fee charged in the base asset
    -&amp;gt; round down using the market quantity step
    -&amp;gt; validate the minimum order requirements
    -&amp;gt; submit the exit order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rounding down is intentional. Rounding to the nearest step can create a quantity that is larger than the available balance.&lt;/p&gt;

&lt;p&gt;When the execution data is incomplete, NOVA stops the transition instead of guessing. A visible failure is easier to recover from than an exit order created from invented numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Market rules belong to the selected exchange
&lt;/h2&gt;

&lt;p&gt;Symbol names are the obvious difference between exchange APIs. Precision rules are the more dangerous one.&lt;/p&gt;

&lt;p&gt;Each selected market can define its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;price tick size;&lt;/li&gt;
&lt;li&gt;quantity step;&lt;/li&gt;
&lt;li&gt;minimum quantity;&lt;/li&gt;
&lt;li&gt;minimum order value;&lt;/li&gt;
&lt;li&gt;supported order behavior;&lt;/li&gt;
&lt;li&gt;fee representation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those values cannot be copied from the MEXC implementation or stored as convenient global defaults. The adapter must load metadata for the actual market on the actual exchange. If valid metadata is unavailable, order creation fails closed.&lt;/p&gt;

&lt;p&gt;This also changed how I think about "multi-exchange support." It is not a boolean feature flag. Support is a capability matrix: an exchange may be integrated while a particular market or operation is still unavailable. That is why nine profiles can use Bybit while CFX/USDT remains tied to MEXC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changing exchanges is a lifecycle transition, not a dropdown change
&lt;/h2&gt;

&lt;p&gt;The user-facing selector created another problem: what happens if a profile already has an unfinished operation on the old exchange?&lt;/p&gt;

&lt;p&gt;Changing a saved value immediately would split ownership of the trading lifecycle. The application could start reading the new exchange while an order on the previous one was still open.&lt;/p&gt;

&lt;p&gt;I chose a stricter rule:&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;def&lt;/span&gt; &lt;span class="nf"&gt;can_change_exchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;profile_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_unfinished_operation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real check contains more detail, but the invariant is simple. A profile cannot switch exchanges while its current operation still needs to be tracked or reconciled.&lt;/p&gt;

&lt;p&gt;The old exchange remains responsible for the lifecycle it started. Only after that lifecycle reaches a safe terminal state can the profile be reconfigured.&lt;/p&gt;

&lt;p&gt;This is one of those cases where a small interface control represents a large state transition. Treating it as ordinary settings persistence would have made the UI convenient and the recovery behavior unreliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saving credentials is not permission to start trading
&lt;/h2&gt;

&lt;p&gt;I also separated exchange connection from strategy activation.&lt;/p&gt;

&lt;p&gt;When a user adds Bybit credentials, NOVA validates the account type, Spot trading access, and balance availability. Withdrawal permission is not required. After validation, the profile remains stopped.&lt;/p&gt;

&lt;p&gt;Starting the trading cycle is a separate explicit action.&lt;/p&gt;

&lt;p&gt;That distinction prevents a settings form from creating a financial side effect. It also makes recovery and support easier: "the exchange is connected" and "the strategy is running" are two different states, and the interface should show them separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stayed neutral
&lt;/h2&gt;

&lt;p&gt;Adding Bybit did not require a second trading core. The core still works with normalized concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;market identity;&lt;/li&gt;
&lt;li&gt;order side and type;&lt;/li&gt;
&lt;li&gt;confirmed execution state;&lt;/li&gt;
&lt;li&gt;executed base and quote quantities;&lt;/li&gt;
&lt;li&gt;average execution price;&lt;/li&gt;
&lt;li&gt;lifecycle ownership;&lt;/li&gt;
&lt;li&gt;capital limits;&lt;/li&gt;
&lt;li&gt;recovery transitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The adapters own signing, endpoints, raw statuses, streams, market metadata, fee details, and exchange-specific error classification.&lt;/p&gt;

&lt;p&gt;That boundary is not perfectly static. A second implementation always reveals places where an interface was shaped around the first one. But those changes should make the contract more semantic, not leak a new set of raw Bybit fields into the core.&lt;/p&gt;

&lt;p&gt;I made the same choice for conditional exits. NOVA currently uses a regular limit order above the average entry price as its profit exit and does not automatically close a Spot position at a loss. I did not add a universal stop-loss type merely to make two adapters look symmetrical. If a future strategy genuinely requires conditional orders, they will enter through explicit capability checks because trigger semantics and Spot support differ between exchanges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests are evidence, but not the final evidence
&lt;/h2&gt;

&lt;p&gt;Before deployment, I ran 39 network-isolated scenarios covering the common Bybit lifecycle, fee-aware quantity calculation, market rules, events, recovery, and DCA-related paths. I also checked the public metadata for the supported markets.&lt;/p&gt;

&lt;p&gt;All of those checks passed. They prove that the implementation satisfies the contracts I wrote. They do not prove that every real account state and exchange response has already been observed.&lt;/p&gt;

&lt;p&gt;At the time of this release, the first complete user-driven Bybit cycle had not yet finished in live operation. I think that distinction is worth stating. "Deployed," "covered by tests," and "repeatedly confirmed with live executions" describe different levels of evidence.&lt;/p&gt;

&lt;p&gt;The next practical milestone is not another adapter. It is completing full user cycles on Bybit and observing recovery behavior under real conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface is the next boundary
&lt;/h2&gt;

&lt;p&gt;NOVA is currently managed through Telegram bots and a Mini App. That was a useful way to build the first working control plane, but ten profiles and two exchanges are beginning to outgrow a messenger-first interface.&lt;/p&gt;

&lt;p&gt;My longer-term direction is to make the website the primary management surface: connect exchanges, configure profiles, inspect open operations, view history, and control execution from a regular browser. Telegram will remain useful for notifications and quick actions rather than carrying the entire product interface.&lt;/p&gt;

&lt;p&gt;The backend lesson will stay the same: a clean selector is only honest when lifecycle ownership, capabilities, and recovery rules are explicit underneath it.&lt;/p&gt;

&lt;p&gt;If you have built a second exchange adapter, what exposed the biggest hidden assumption in your first implementation: order states, fees, precision rules, streaming, or recovery?&lt;/p&gt;

</description>
      <category>python</category>
      <category>architecture</category>
      <category>fintech</category>
      <category>crypto</category>
    </item>
    <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>
