<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Energy Price Board]]></title><description><![CDATA[Energy Price Board]]></description><link>https://energypriceboard.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Energy Price Board</title><link>https://energypriceboard.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 19 Sep 2026 16:40:17 GMT</lastBuildDate><atom:link href="https://energypriceboard.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The cheapest number on the page is not the price]]></title><description><![CDATA[I run a board that reads the price of one thing — rented TRON energy — from twenty vendors every fifteen minutes and sorts them. The commodity is identical: 65,000 units of energy delegated to your ad]]></description><link>https://energypriceboard.hashnode.dev/the-cheapest-number-on-the-page-is-not-the-price</link><guid isPermaLink="true">https://energypriceboard.hashnode.dev/the-cheapest-number-on-the-page-is-not-the-price</guid><category><![CDATA[Python]]></category><category><![CDATA[webscraping ]]></category><category><![CDATA[api]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Energy Price Board]]></dc:creator><pubDate>Sun, 06 Sep 2026 16:52:38 GMT</pubDate><content:encoded><![CDATA[<p>I run a board that reads the price of one thing — rented TRON energy — from twenty vendors every fifteen minutes and sorts them. The commodity is identical: 65,000 units of energy delegated to your address for an hour, which is roughly what one USDT transfer consumes. Twenty sellers, one product, and no two of them quote it the same way.</p>
<p>This post is about the part between "I fetched the page" and "I have a number I can sort on". None of it is TRON-specific. If you have ever built a comparison of anything priced by more than three vendors, you have met all of these.</p>
<h2>Four shapes of the same quote</h2>
<p><strong>A sentence.</strong> mefree.net has no price table. It prints its tariff in the order form as prose, in Chinese:</p>
<pre><code>单价 3 TRX / 1 小时
每笔等于 65000 能量
</code></pre>
<p>Unit price 3 TRX per hour; each unit equals 65,000 energy. So the reader is a regex over prose, and the load-bearing part is the second line — you have to read the size the price refers to rather than assume it:</p>
<pre><code class="language-python">UNIT_PRICE  = re.compile(r"单价\s*([0-9]+(?:[.,][0-9]+)?)\s*TRX\s*/\s*1\s*小时", re.I)
UNIT_ENERGY = re.compile(r"每笔等于\s*([0-9][0-9,]*)\s*能量")
...
per_65k = unit_price * 65000 / unit_energy
</code></pre>
<p>The first version of that scraper did something more obvious and much worse: take every <code>N TRX</code> on the page, keep the smallest. It published a price of 1 TRX for a while, because the smallest <code>N TRX</code> on the page was sometimes an order counter and sometimes a discount banner.</p>
<p><strong>A number that is not for sale.</strong> apitrx.com prints, in Russian, "transfer 2.5 TRX to this address and you get 65,000 energy". Further up the same page it prints "hourly orders from 1.1 TRX". Both numbers are true. Only one is what a walk-up customer pays — the other is a members' rate through their bot. The smallest-number heuristic picks 1.1, under-quotes the vendor by more than half, and puts them top of the board on false pretences.</p>
<p>So the parser anchors on the sentence that describes the transaction, not on the smallest match anywhere on the page:</p>
<pre><code class="language-python">m65 = re.search(r"[Пп]ереведите\s+([0-9]+(?:[.,][0-9]+)?)\s*TRX[^.]{0,120}?65\s?000\s+энерг", norm)
</code></pre>
<p>and the cheaper members' rate goes into a note attached to the row, because it is real and a buyer should know it exists.</p>
<p><strong>A rate in different units.</strong> ergon.ustx.io quotes tiers in TRX per million energy <em>per day</em>: 50 for one day, 42.5 for two, 35 for three or more. Converting is arithmetic, but watch where the days go:</p>
<pre><code class="language-python">for term, days in TERM_DAYS.items():          # {"1d": 1, "3d": 3, "10d": 10}
    rate = pick_rate(rates, days)
    for vol_key, energy in VOLUMES.items():
        result[f"{vol_key}_{term}_price"] = r6(rate * days * energy / 1_000_000)
</code></pre>
<p>My first version dropped <code>* days</code>, reasoning that the tier already contains the long-rental discount. It does — per day. Losing the factor made three days come out cheaper than one day: 2.275 TRX against 3.25.</p>
<p>That is the useful part. You will not catch a units bug by rereading the code, and I didn't. You catch it instantly with an assertion that a longer rental cannot cost less than a shorter one at the same size. Monotonic in duration, monotonic in volume: two free tests, and across this whole project they are the only two that have ever caught anything.</p>
<p>Ergon also has a floor — minimum order 100,000 energy — so a literal 65,000 order cannot be placed there at all. The tariff is strictly linear in energy, so the 65,000 cell is the exact pro-rata value of the same published rate, and the row carries a note saying that. A <em>comparable</em> number and a <em>purchasable</em> number are not always the same number. Publish the first, say the second.</p>
<p><strong>An API that answers a different question.</strong> Tronify has a proper REST endpoint, which is the case where you relax and get hurt. <code>queryPreorderInfo</code> does not quote the energy you asked for; it adds a fixed package to every order:</p>
<pre><code>request  15 000  -&gt;  response  79 400   (+64 400)
request  65 000  -&gt;  response 129 400   (+64 400)
request 131 000  -&gt;  response 195 400   (+64 400)
</code></pre>
<p>Take the returned fee at face value and Tronify looks about twice as expensive as it is. The fee is exactly linear in the quoted energy with a zero intercept, so the fix is a rescale — and, more to the point, one that repairs itself if the vendor ever stops doing this:</p>
<pre><code class="language-python">if quoted_energy &gt; 0 and abs(quoted_energy - energy) &gt; 1:
    scaled = fee * energy / quoted_energy
</code></pre>
<p>If the package goes away, quoted equals requested, the branch never fires, the price passes through. I would rather write that conditional than a constant <code>- 64400</code> that silently becomes wrong on some Tuesday I am not watching.</p>
<p><strong>And one that answers nothing.</strong> One vendor's API needs a key I do not have. It returns no numbers at all. That row's ten price cells stay <code>"N/A"</code>, <code>has_prices</code> is false, and the row is excluded from every ranking and listed separately. This is the boring case and the one people get wrong: a missing price is not zero, and it is not "expensive". Zero sorts first.</p>
<h2>One cell</h2>
<p>All of that converges on one shape. Two sizes (65,000 and 130,000 energy), five terms (15m, 1h, 1d, 3d, 10d), a price in TRX, and a contract strict enough to be worth checking before a row is allowed into the output at all:</p>
<pre><code class="language-python">for k, v in obj.items():
    if k.endswith("_price"):
        if not (is_num(v) or v == "N/A"):
            return False, f"bad value {k}={v!r}"
</code></pre>
<p>A number or the literal string <code>"N/A"</code>. Nothing else, ever. It reads like paranoia until the week a scraper returns <code>None</code> for a price and something downstream renders it as the string "None" and sorts it between 5 and 6.</p>
<p>Twenty vendors times ten cells is 200. In the scan I am looking at (2026-09-06 16:20 UTC), 69 of those are quotes somebody actually published, 50 are empty, and the other 81 are mine.</p>
<h2>The 81</h2>
<p>Most vendors sell one product: an hour. Seventeen of the twenty publish a real 65k/1h price; far fewer publish anything else. If you want a table with five columns you either leave two thirds of it blank or you derive it. I derive it, from ratios computed across the platforms that publish both sides of each relationship:</p>
<pre><code class="language-python">if v1h is not None and v1d is not None and v1h &gt; 0:
    samples['h_to_d'].append(v1d / v1h)
</code></pre>
<p>Median across the market, with a minimum sample count before the median is trusted at all, a hard-coded fallback when it is not, and a clamp on either side:</p>
<pre><code class="language-python">RATIO_SPEC = {
    'vol_ratio':  (2.0,  1.0, 3.0),
    'h_to_d':     (1.7,  1.0, 3.0),
    'd1_to_d3':   (2.6,  1.0, 3.5),
    'd1_to_d10':  (8.0,  1.5, 11.0),
}
</code></pre>
<p>The clamps are not tuning knobs. They are a claim: no ratio outside that band is a real market ratio, it is a parse error somewhere upstream, and I would rather ship the dumb fallback than a plausible-looking number computed from garbage.</p>
<p>Two rules are the difference between this being a feature and being a lie.</p>
<p><strong>An estimate never becomes an input.</strong> The pipeline runs every fifteen minutes and reads its own previous output. If last run's derived cells are eligible to seed this run's ratios, the ratios drift toward whatever the fill function already assumed. Then they drift further. Give it a week and the table is a fixed point of its own arithmetic with no remaining connection to the market — and it looks exactly as convincing as it did on day one. So the function that gathers inputs throws away anything the previous run marked as derived:</p>
<pre><code class="language-python">def get_known(p):
    """Only REAL prices — last run's estimates don't count."""
    prev_est = set(p.get('estimated_keys') or [])
    k = {}
    for vol in VOLUMES:
        for term in TERMS:
            key = PRICE_KEY.format(vol=vol, term=term)
            if key in prev_est:
                continue
            ...
</code></pre>
<p>Nine lines. It is the only thing standing between a comparison table and a rumour.</p>
<p><strong>A derived cell says so, all the way to the surface.</strong> Every filled key is recorded on the row and the front end renders those with a <code>≈</code>. The subtle part is that the fill function is not the only thing doing arithmetic. A scraper can derive a cell too — doubling an hourly quote to get the 130,000 column, say — and that multiplication is exactly as much mine as the fill function's is. So the aggregation step takes the scraper's own declaration and marks it identically:</p>
<pre><code class="language-python"># The scraper may have declared computed cells itself (e.g. 131k = 2 x 65k).
# They must be flagged like fill_prices' estimates, or arithmetic reaches
# the site disguised as a platform's quote.
declared = obj.get("derived_keys") or []
out["estimated_keys"] = [k for k in declared if isinstance(k, str)]
</code></pre>
<p>Arithmetic disguised as a quote is, I think, the main way comparison tables lie. Almost never on purpose. It is a <code>* 2</code> written six months ago in a file nobody has opened since.</p>
<h2>Three things I would keep</h2>
<p>If you are building the same shape of thing for a different market:</p>
<ol>
<li>The smallest number on a vendor's page is a marketing number until proven otherwise. Anchor your parser on the sentence that describes a transaction, and put the number you rejected in a note rather than dropping it.</li>
<li>Write the two monotonicity assertions before you write your third scraper. Longer costs more, bigger costs more. They cost nothing and they catch unit errors, which are the ones you cannot see by reading.</li>
<li>Decide at the boundary whether a cell is a quote or your own arithmetic, carry that bit all the way to the rendered page, and never let yesterday's arithmetic become today's input.</li>
</ol>
<p>The board is at <a href="https://energypriceboard.com">energypriceboard.com</a>, and the file every number above came out of is <a href="https://energypriceboard.com/result.json">result.json</a> — no key, no signup, <code>Access-Control-Allow-Origin: *</code>, rewritten every fifteen minutes. Each row's <code>estimated_keys</code> tells you which of its ten cells are the platform's and which are mine.</p>
<p>Disclosure: some outbound links on that board are referral links, and one listed platform is mine. The sort is by price only — there is no field a platform can pay into to move up, and the order would be identical if every referral arrangement were cancelled tomorrow.</p>
]]></content:encoded></item></channel></rss>