<?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: aleax h</title>
    <description>The latest articles on DEV Community by aleax h (@aleax_h_9843add3eb7d8575e).</description>
    <link>https://dev.to/aleax_h_9843add3eb7d8575e</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%2F4215213%2F1c9b45da-88ff-460f-88a5-ea504abb56b7.png</url>
      <title>DEV Community: aleax h</title>
      <link>https://dev.to/aleax_h_9843add3eb7d8575e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aleax_h_9843add3eb7d8575e"/>
    <language>en</language>
    <item>
      <title>Keeping a fee calculator correct when exchanges change their tiers</title>
      <dc:creator>aleax h</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:06:16 +0000</pubDate>
      <link>https://dev.to/aleax_h_9843add3eb7d8575e/keeping-a-fee-calculator-correct-when-exchanges-change-their-tiers-305n</link>
      <guid>https://dev.to/aleax_h_9843add3eb7d8575e/keeping-a-fee-calculator-correct-when-exchanges-change-their-tiers-305n</guid>
      <description>&lt;p&gt;Every exchange fee calculator I have seen starts the same way: a table of maker/taker rates, copied from the exchange's fee page into a constant, and a multiply. It works for about six weeks. Then the exchange adds a tier, changes the token discount, or introduces a promotional rate for one pair, and the calculator keeps confidently returning yesterday's number.&lt;/p&gt;

&lt;p&gt;The interesting engineering problem is not the multiplication. It is keeping the multiplication correct while the inputs move underneath you. Here is the model I ended up with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the schedule as data, not as constants
&lt;/h2&gt;

&lt;p&gt;A fee schedule is a versioned document, not a set of numbers in your source:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exchange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetched_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-01T09:12:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tiers&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_volume_30d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00100&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_volume_30d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00070&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00090&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_volume_30d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00060&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00080&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_discount&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OKB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rounding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ceiling&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties matter here and both are easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rates are decimal strings, not floats.&lt;/strong&gt; &lt;code&gt;0.0008&lt;/code&gt; is not representable in binary floating point, and the error compounds the moment you sum fees across many fills. Keep them as strings in the document and parse them into &lt;code&gt;Decimal&lt;/code&gt; at the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schedule carries its own &lt;code&gt;fetched_at&lt;/code&gt; and &lt;code&gt;version&lt;/code&gt;.&lt;/strong&gt; A number without a timestamp is indistinguishable from a stale number, and staleness is the failure mode you actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lookup is a bisect, not an if-chain
&lt;/h2&gt;

&lt;p&gt;Tier selection is "the highest tier whose minimum volume the account meets". That is a binary search over sorted thresholds, and writing it as a chain of &lt;code&gt;if volume &amp;gt; X&lt;/code&gt; is how the off-by-one at the boundary gets in:&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;import&lt;/span&gt; &lt;span class="n"&gt;bisect&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pick_tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;volume_30d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;thresholds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_volume_30d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bisect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bisect_right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;volume_30d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bisect_right&lt;/code&gt; minus one is the tier you are &lt;em&gt;in&lt;/em&gt;; &lt;code&gt;bisect_left&lt;/code&gt; silently gives you the tier you are about to reach. The difference is one boundary, and boundaries are exactly where people screenshot a calculator and complain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effective rate is a product, and order does not matter — until rounding does
&lt;/h2&gt;

&lt;p&gt;Effective rate is base rate times the token discount:&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;effective_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token_discount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;token_discount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiplication is commutative, so the order is irrelevant. Rounding is not. Exchanges differ on whether the fee is rounded up, rounded half-even, or truncated at the asset's precision, and the difference shows up at scale: 10,000 trades of 100 USDT at a 0.08% rate, rounded up to 8 decimal places each, versus rounded half-even, is a measurable spread that a "comparison" site will happily report as a difference between exchanges.&lt;/p&gt;

&lt;p&gt;So the rounding rule belongs in the schedule document, next to the rates, and the fee function takes it as an argument rather than deciding for you:&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;fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notional&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notional&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.00000001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mark stale data instead of hiding it
&lt;/h2&gt;

&lt;p&gt;Fee schedules change rarely, so caching them is correct — and caching them without a staleness marker is how a calculator becomes quietly wrong. What worked for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cache the whole document, not the computed rate;&lt;/li&gt;
&lt;li&gt;keep the previous version so a diff is possible when the schedule changes;&lt;/li&gt;
&lt;li&gt;expose &lt;code&gt;fetched_at&lt;/code&gt; to the UI and show "estimated" once it is older than your TTL, instead of pretending the number is current.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A calculator that says "based on the schedule from 3 days ago" is more trustworthy than one that says nothing and is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The properties worth testing
&lt;/h2&gt;

&lt;p&gt;The arithmetic is simple enough that unit tests mostly restate it. The useful tests are properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fee is monotonically non-decreasing in notional (for a fixed rate);&lt;/li&gt;
&lt;li&gt;fee of zero notional is zero;&lt;/li&gt;
&lt;li&gt;fee never exceeds notional for any sane rate;&lt;/li&gt;
&lt;li&gt;the tier lookup is stable exactly at each threshold — test &lt;code&gt;threshold - 1&lt;/code&gt;, &lt;code&gt;threshold&lt;/code&gt;, &lt;code&gt;threshold + 1&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a schedule missing a field fails loudly at load, not at compute time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the one that saves you: a missing &lt;code&gt;rounding&lt;/code&gt; key should not fall back to a default that silently changes every number on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this ends up
&lt;/h2&gt;

&lt;p&gt;I keep a working implementation of this model as a reference when comparing exchanges — the interesting part is not the UI but the schedule documents and the tier boundary handling: &lt;a href="https://www.leddlp.com/okx?lang=en_US" rel="noopener noreferrer"&gt;the fee calculator I use for spot pairs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are building your own, the fee endpoints and the maker/taker tier definitions are in the exchange API docs rather than the marketing pages, and they are the only source that stays consistent with what you are actually charged: &lt;a href="https://www.okx.com/docs-v5/en/" rel="noopener noreferrer"&gt;OKX V5 API reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version: treat fees as data with a version and a timestamp, keep decimals out of floats, put the rounding rule in the document, and test the boundaries instead of the middle.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
