<?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: Kyles Light</title>
    <description>The latest articles on DEV Community by Kyles Light (@kyles_light_275be0175609f).</description>
    <link>https://dev.to/kyles_light_275be0175609f</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1958750%2F5bae6d54-ddcd-49f8-a8d2-900bcfbf986e.jpg</url>
      <title>DEV Community: Kyles Light</title>
      <link>https://dev.to/kyles_light_275be0175609f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kyles_light_275be0175609f"/>
    <language>en</language>
    <item>
      <title>Parsing Foreign Private Issuer Financials from SEC EDGAR: A War Story</title>
      <dc:creator>Kyles Light</dc:creator>
      <pubDate>Thu, 19 Feb 2026 15:14:47 +0000</pubDate>
      <link>https://dev.to/kyles_light_275be0175609f/parsing-foreign-private-issuer-financials-from-sec-edgar-a-war-story-c21</link>
      <guid>https://dev.to/kyles_light_275be0175609f/parsing-foreign-private-issuer-financials-from-sec-edgar-a-war-story-c21</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxknie05pl9yh4hjbs30c.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxknie05pl9yh4hjbs30c.webp" alt="image" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Building a financial data platform that covers US-listed companies sounds straightforward — until you encounter Foreign Private Issuers (FPIs). Companies like Alibaba, Bilibili, Sea Limited, and Baidu don't file 10-Qs like domestic companies. They file 6-Ks, and the data extraction story is entirely different.&lt;/p&gt;

&lt;p&gt;This post walks through the real technical challenges we hit while building &lt;a href="https://10q10k.net" rel="noopener noreferrer"&gt;10q10k.net&lt;/a&gt;, and the solutions we landed on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Track Filing System
&lt;/h2&gt;

&lt;p&gt;US-listed companies fall into two categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domestic filers&lt;/strong&gt; submit 10-Q (quarterly) and 10-K (annual) reports with structured XBRL data. The SEC's &lt;code&gt;companyfacts&lt;/code&gt; API gives you clean JSON with every financial concept tagged and machine-readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign Private Issuers&lt;/strong&gt; submit 20-F (annual) and 6-K (current reports). Their quarterly data lives inside HTML exhibits attached to 6-K filings — essentially press release PDFs rendered as HTML tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The XBRL path is trivial. Hit &lt;code&gt;https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json&lt;/code&gt;, extract &lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt; from the &lt;code&gt;us-gaap&lt;/code&gt; namespace, filter by period duration, done.&lt;/p&gt;

&lt;p&gt;The 6-K path is where things get interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 1: Finding the Needle in the 6-K Haystack
&lt;/h2&gt;

&lt;p&gt;A single FPI might file 50-200 6-K reports per year. Most aren't earnings releases — they're governance notices, share purchase agreements, proxy statements, and regulatory disclosures. Only 4-8 per year contain quarterly financial statements.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Naive Approach (Too Slow)
&lt;/h3&gt;

&lt;p&gt;Our first implementation downloaded every 6-K exhibit and ran a regex check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INCOME_HEADER_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;CONDENSED&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?(?:&lt;/span&gt;&lt;span class="sr"&gt;CONSOLIDATED&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;STATEMENTS&lt;/span&gt;&lt;span class="se"&gt;?\s&lt;/span&gt;&lt;span class="sr"&gt;+OF&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;OPERATIONS|INCOME&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;is6kEarningsRelease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;INCOME_HEADER_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a company with 195 6-K filings, this meant 195 HTTP requests just to find 8 earnings releases. At SEC's 10 req/s rate limit, that's 20+ seconds of I/O before we even start parsing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: Pre-filter by Filing Size
&lt;/h3&gt;

&lt;p&gt;SEC's submissions JSON includes a &lt;code&gt;size&lt;/code&gt; field for each filing — the total filing size in bytes. Earnings press releases with their large HTML financial tables are consistently 300KB-700KB, while governance 6-Ks are typically under 100KB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;find6kEarningsCandidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Filing6kCandidate&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Filing6kCandidate&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;6-K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalSize&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;totalSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Skip small filings&lt;/span&gt;
    &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single filter reduced our candidate set from 195 to ~30 filings, cutting the discovery phase from 20 seconds to under 5.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-Phase Exhibit Discovery
&lt;/h3&gt;

&lt;p&gt;Even after the size filter, we don't want to download 30 full HTML documents (each 200KB+). Instead, we use a two-phase approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1&lt;/strong&gt;: Fetch each filing's &lt;code&gt;index.json&lt;/code&gt; (~300 bytes) to check if it contains an &lt;code&gt;ex99*.htm&lt;/code&gt; exhibit above 25KB. This is cheap — 30 small JSON requests batched 6-at-a-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2&lt;/strong&gt;: Only download and parse the ~8 filings that actually have large exhibits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 2: The Currency Column Problem
&lt;/h2&gt;

&lt;p&gt;This is where the real pain begins. FPI earnings tables are designed for human readers, not machines. A typical Chinese FPI income statement looks 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;                          Three Months Ended March 31,
                            2024        2025        2025
                            RMB         RMB         US$
Net revenues:
  Value-added services    2,528,909   2,807,340    386,862
  Advertising             1,668,584   1,997,635    275,281
  Mobile games              982,810   1,731,155    238,560
Total net revenues        5,664,600   7,003,248    965,073
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The challenge: &lt;strong&gt;which column has the USD values?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The third column (US$) is a "convenience translation" — the same numbers converted at a stated exchange rate. Columns 1 and 2 are in RMB. If you grab the wrong column, your revenue is off by 7x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Detecting the USD Column
&lt;/h3&gt;

&lt;p&gt;We scan the first 8 rows of each table for a currency header row — a row where most tokens match known currency codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;USD_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\$?&lt;/span&gt;&lt;span class="sr"&gt;US&lt;/span&gt;&lt;span class="se"&gt;\$?&lt;/span&gt;&lt;span class="sr"&gt;$|^USD$|^&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CURRENCY_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(?:\$&lt;/span&gt;&lt;span class="sr"&gt;|RMB|US&lt;/span&gt;&lt;span class="se"&gt;\$?&lt;/span&gt;&lt;span class="sr"&gt;|USD|CNY|VND|JPY|EUR|GBP|...&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currencyCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;CURRENCY_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currencyCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;colIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;CURRENCY_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;USD_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;usdIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;colIdx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;colIdx&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fun edge case: Sea Limited (a Singapore company) uses bare &lt;code&gt;$&lt;/code&gt; instead of &lt;code&gt;US$&lt;/code&gt; in their tables. Our initial regex didn't match that, so their entire filing returned null.&lt;/p&gt;

&lt;h3&gt;
  
  
  When There's No USD Column At All
&lt;/h3&gt;

&lt;p&gt;Here's the twist: &lt;strong&gt;most Chinese FPI filings before 2025 don't include a USD column.&lt;/strong&gt; The entire table is in RMB.&lt;/p&gt;

&lt;p&gt;But they do include a conversion note in the filing text:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;All translations from RMB to US$ were made at the rate of RMB 7.2993 to US$1.00, the exchange rate on December 31, 2024.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So we extract the exchange rate with a simple regex and convert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractExchangeRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/RMB&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*to&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*US/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When no USD column exists, we use the most recent quarterly column (in local currency) and divide every value by the exchange rate. The &lt;code&gt;rate &amp;gt; 1 &amp;amp;&amp;amp; rate &amp;lt; 100&lt;/code&gt; guard prevents garbage matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 3: Scale Detection Gone Wrong
&lt;/h2&gt;

&lt;p&gt;This one caused our most spectacular data errors — &lt;strong&gt;$458 billion quarterly revenue for Alibaba&lt;/strong&gt; (actual: ~$34 billion).&lt;/p&gt;

&lt;p&gt;Financial tables express values in different scales:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"In millions" → multiply raw values by 1,000,000&lt;/li&gt;
&lt;li&gt;"In thousands" → multiply by 1,000&lt;/li&gt;
&lt;li&gt;No indicator → values are in base units&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scale indicator can appear in many places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table header row: &lt;code&gt;(In millions, except per share data)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Between the section header and the table (what we call the "preamble")&lt;/li&gt;
&lt;li&gt;In a footnote 2,000 characters before the table&lt;/li&gt;
&lt;li&gt;Nowhere near the table at all, but somewhere in the document&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Cascading Scale Detection
&lt;/h3&gt;

&lt;p&gt;We settled on a three-level cascade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Check preamble text (between "Statements of Operations" header and &amp;lt;table&amp;gt;)
2. Check nearby HTML (2000 chars before the table)
3. Global document scan (last resort)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical lesson: &lt;strong&gt;never guess the scale from the magnitude of the values.&lt;/strong&gt; We originally had a heuristic (&lt;code&gt;autoDetectScale&lt;/code&gt;) that tried to infer scale from the raw numbers — "if revenue is 34,820, it's probably in millions; if it's 34,820,000, it's probably in thousands." This failed catastrophically for FPIs where revenue numbers in local currency (RMB) can be 10x larger than expected USD ranges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenge 4: Table Structure Variance
&lt;/h2&gt;

&lt;p&gt;Not all income statements follow the same HTML structure. We encountered three patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern A: Section header → Table&lt;/strong&gt; (most common)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;b&amp;gt;Condensed Consolidated Statements of Operations&amp;lt;/b&amp;gt;
&amp;lt;table&amp;gt;...&amp;lt;/table&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pattern B: Standalone quarterly table&lt;/strong&gt; (Sea Limited)&lt;br&gt;
The quarterly income statement appears as a standalone table with "For the Three Months Ended" in its own header row, not under any "Statements of Operations" section header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern C: Mixed content&lt;/strong&gt; (Bilibili)&lt;br&gt;
The first regex match for "Statements of Operations" lands on a text paragraph that happens to contain those words, and the next &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; is actually a balance sheet.&lt;/p&gt;

&lt;p&gt;Our solution: a layered search strategy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. findSectionTables() — match header regex, grab next &amp;lt;table&amp;gt;
2. If no quarterly data found, scan ALL tables for "Three Months" rows
3. If still nothing, accept non-quarterly data from section tables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Challenge 5: SEC Rate Limiting at Scale
&lt;/h2&gt;

&lt;p&gt;SEC EDGAR enforces a 10 requests/second rate limit. Our initial implementation was getting 429'd constantly because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch of 8 parallel &lt;code&gt;index.json&lt;/code&gt; requests + 200ms pause = ~40 req/s&lt;/li&gt;
&lt;li&gt;Following a 301 redirect (from padded CIK URLs) doubles the request count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fixes:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Strip leading zeros from CIK in URLs (avoids 301 redirect)&lt;/li&gt;
&lt;li&gt;Reduce parallel batch to 6 with 600ms delay&lt;/li&gt;
&lt;li&gt;Add exponential backoff retry on 429:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&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="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The &lt;code&gt;hasQuarterlyXbrl&lt;/code&gt; Trap
&lt;/h2&gt;

&lt;p&gt;One subtle bug: our system checks whether a company has quarterly XBRL data (meaning we can use the structured API instead of parsing 6-K HTML). Baidu had exactly one quarterly XBRL entry — from 2018. This caused our code to skip the 6-K path entirely, producing zero revenue data for 2023-2025.&lt;/p&gt;

&lt;p&gt;The fix was embarrassingly simple: only consider XBRL entries from the last 3 years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revenue Segments: Vertical vs. Horizontal
&lt;/h2&gt;

&lt;p&gt;Different companies structure their revenue breakdown differently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vertical (Bilibili):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Net revenues:
  Value-added services    386,862
  Advertising             275,281
  Mobile games            238,560
Total net revenues        965,073
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Horizontal (Sea Limited):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              E-commerce  DigitalFinancial  Entertainment  Total
Revenue       4,294,756   989,861           653,033        5,986,024
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our segment parser handles the vertical case by looking for rows between a "revenues:" header and a "total revenues" footer. The header might or might not have a trailing colon. The header row might or might not have numeric values (distinguishing it from a data row that happens to contain "Revenue").&lt;/p&gt;

&lt;p&gt;The horizontal case requires different logic entirely — treating column headers as segment names and extracting the value from each column of the revenue row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Cloudflare Workers + D1
&lt;/h2&gt;

&lt;p&gt;The whole system runs on Cloudflare Workers with D1 (SQLite) as the database. A cron trigger fires every 2 minutes, processing 30 companies per invocation. For FPIs, the 6-K parsing can take 30-60 seconds per company due to SEC rate limits, which is borderline for Worker execution limits.&lt;/p&gt;

&lt;p&gt;Key lesson: long-running HTTP-triggered tasks (&lt;code&gt;ctx.waitUntil&lt;/code&gt;) can timeout silently. We moved FPI processing to the cron handler which has more generous time limits, and added auto-recovery for jobs stuck in "running" state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-filter aggressively.&lt;/strong&gt; Filing size metadata can eliminate 80% of candidates before any exhibit download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never infer numeric scale from value magnitude.&lt;/strong&gt; Always look for explicit indicators in the document text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency detection is not optional.&lt;/strong&gt; FPI tables mix local currency and USD columns — grabbing the wrong one gives you 7x errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build for variance.&lt;/strong&gt; Every FPI structures their earnings release slightly differently. Your parser needs multiple fallback strategies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect rate limits from the start.&lt;/strong&gt; Retrofitting rate limiting into a batch pipeline is painful. Build it in from day one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The structured XBRL world is clean and predictable. The FPI 6-K world is messy, inconsistent, and full of edge cases. But it's also where you find some of the most interesting companies in the market — and getting their data right is worth the effort.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://10q10k.net" rel="noopener noreferrer"&gt;10q10k.net&lt;/a&gt; is an open financial data platform with interactive Sankey diagrams for 6,000+ US-listed companies. The FPI parsing pipeline described here handles companies from China, Singapore, Brazil, and 20+ other countries.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>showdev</category>
      <category>softwareengineering</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Building 10q10k.net: A Financial Visualization Platform Running Entirely on Cloudflare's Edge</title>
      <dc:creator>Kyles Light</dc:creator>
      <pubDate>Tue, 17 Feb 2026 09:19:58 +0000</pubDate>
      <link>https://dev.to/kyles_light_275be0175609f/building-10q10knet-a-financial-visualization-platform-running-entirely-on-cloudflares-edge-2p31</link>
      <guid>https://dev.to/kyles_light_275be0175609f/building-10q10knet-a-financial-visualization-platform-running-entirely-on-cloudflares-edge-2p31</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwnkksn1qgsr5nm01sv5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwnkksn1qgsr5nm01sv5q.png" alt="TSLA ScreenShot" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://10q10k.net" rel="noopener noreferrer"&gt;10q10k.net&lt;/a&gt; is a financial data platform covering all S&amp;amp;P 500 companies. It visualizes quarterly earnings as interactive Sankey flow charts — showing how revenue flows through costs, profits, and expenses. The entire stack runs on Cloudflare's edge with zero traditional servers.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;

&lt;p&gt;Every company page shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Sankey flow chart&lt;/strong&gt; mapping revenue segments → gross profit → operating profit → net profit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Financial metrics&lt;/strong&gt; with YoY growth, margins, and EPS beat/miss indicators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Income Statement, Balance Sheet, and Cash Flow&lt;/strong&gt; breakdowns with bar charts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Earnings calendar&lt;/strong&gt; with upcoming report dates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The charts are &lt;strong&gt;downloadable and copyable&lt;/strong&gt; — hover to see action buttons. The exported PNG includes company branding and a watermark, composited via Canvas API.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://10q10k.net/NVDA" rel="noopener noreferrer"&gt;NVDA&lt;/a&gt; · &lt;a href="https://10q10k.net/AAPL" rel="noopener noreferrer"&gt;AAPL&lt;/a&gt; · &lt;a href="https://10q10k.net/GOOG" rel="noopener noreferrer"&gt;GOOG&lt;/a&gt; · &lt;a href="https://10q10k.net/MSFT" rel="noopener noreferrer"&gt;MSFT&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pnpm monorepo
├── web/        # Next.js 16 (App Router) — frontend
├── worker/     # Cloudflare Worker — data pipeline
└── share/      # Shared TypeScript types &amp;amp; constants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Everything runs on Cloudflare:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workers&lt;/strong&gt; for server-side compute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; (edge SQLite) for storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages&lt;/strong&gt; for static assets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No EC2, no RDS, no Docker. Monthly cost is near-zero on the free tier.&lt;/p&gt;




&lt;h2&gt;
  
  
  Technical Challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Financial Data Is Messier Than You Think
&lt;/h3&gt;

&lt;p&gt;The same metric — "Revenue" — can be reported under 15+ different names depending on the industry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Standard companies&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;// Banks&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;InterestAndNoninterestRevenue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;// Utilities&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ElectricUtilityRevenue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;// REITs&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RealEstateRevenueNet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;// Oil &amp;amp; Gas&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OilAndGasRevenue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And some companies don't report an explicit revenue concept at all. For those, we fall back to a &lt;strong&gt;computed value&lt;/strong&gt;: &lt;code&gt;OperatingIncome + CostsAndExpenses&lt;/code&gt;. This single decision unlocked ~40 more companies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: If you're working with financial data at scale, budget 80% of your time for edge cases and normalization.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Fiscal Year Mapping Problem
&lt;/h3&gt;

&lt;p&gt;Not every company's fiscal year matches the calendar. Apple's Q1 ends in December. Microsoft's ends in September. Walmart's fiscal year starts in February.&lt;/p&gt;

&lt;p&gt;I built a &lt;strong&gt;quarter mapping system&lt;/strong&gt; that normalizes all companies to calendar quarters, handling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Non-standard fiscal year starts&lt;/li&gt;
&lt;li&gt;52-week fiscal years (period ending Dec 28 vs Jan 1)&lt;/li&gt;
&lt;li&gt;A 7-day grace period at quarter boundaries&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Quarterly Cash Flow from Cumulative Data
&lt;/h3&gt;

&lt;p&gt;Most companies report &lt;strong&gt;year-to-date cumulative&lt;/strong&gt; cash flows, not quarterly values. A Q3 filing shows 9 months of data, not 3.&lt;/p&gt;

&lt;p&gt;To derive a single quarter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Q1 = Q1 YTD (as reported)
Q2 = H1 YTD − Q1 YTD
Q3 = 9M YTD − H1 YTD
Q4 = Annual − 9M YTD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The challenge is identifying the correct prior period to subtract. The algorithm matches entries by fiscal year start date and selects the &lt;strong&gt;longest&lt;/strong&gt; preceding period — critical for Q3 where you need the 6-month entry, not 3-month.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Revenue Segment Extraction
&lt;/h3&gt;

&lt;p&gt;Breaking down revenue by business segment (e.g., Google Services vs Google Cloud) requires parsing structured tags embedded in financial filings. The pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parse context elements (period dates, dimensional axes)&lt;/li&gt;
&lt;li&gt;Match financial values to their contexts&lt;/li&gt;
&lt;li&gt;Score axes by &lt;strong&gt;revenue coverage&lt;/strong&gt; — prefer axes where named segments cover ≥40% of total revenue&lt;/li&gt;
&lt;li&gt;Handle Q4 specially: derive from annual minus Q1+Q2+Q3&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  5. The "Octopus" Sankey Layout
&lt;/h3&gt;

&lt;p&gt;Standard Sankey charts become chaotic with 10+ nodes. I implemented a custom &lt;strong&gt;"octopus" layout&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Center&lt;/strong&gt;: Revenue node as the body&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Left tentacles&lt;/strong&gt;: Revenue segments, spread proportionally by value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right flows&lt;/strong&gt;: Cost of Revenue → Gross Profit → Operating Expenses → Net Profit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Key layout decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Value-weighted Y positioning&lt;/strong&gt; — larger segments get proportionally more space&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flow conservation&lt;/strong&gt; — outflows never exceed inflows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loss handling&lt;/strong&gt; — companies with negative profits render red flows correctly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage threshold&lt;/strong&gt; — only show segments if they cover ≥40% of revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used &lt;strong&gt;Plotly.js&lt;/strong&gt; with &lt;code&gt;arrangement: "fixed"&lt;/code&gt; and manually computed node coordinates. The default &lt;code&gt;"snap"&lt;/code&gt; mode creates visual chaos at this scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chart Export Without html2canvas
&lt;/h2&gt;

&lt;p&gt;I initially used &lt;code&gt;html2canvas&lt;/code&gt; for PNG export, but it crashed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Attempting to parse an unsupported color function "lab"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modern CSS uses &lt;code&gt;lab()&lt;/code&gt;, &lt;code&gt;oklch()&lt;/code&gt;, etc. that &lt;code&gt;html2canvas&lt;/code&gt; can't handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Use Plotly's native &lt;code&gt;toImage()&lt;/code&gt; to export just the chart, then &lt;strong&gt;composite branding via Canvas API&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Export chart as PNG from Plotly&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Plotly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chartEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Create a larger canvas with header/footer space&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Draw background → header (logo, symbol, quarter) → chart → watermark&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chartImg&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="nx"&gt;headerHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Export final composite&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives full control over the exported image — branding, background color (dark/light theme aware), and 2x resolution — without any DOM screenshot library.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend Stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Next.js 16 on Cloudflare Workers
&lt;/h3&gt;

&lt;p&gt;Server components fetch from D1 at the edge. No API round-trips, minimal latency worldwide.&lt;/p&gt;

&lt;p&gt;Deployed via &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt; — the OpenNext adapter that makes Next.js App Router work natively on Cloudflare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three-Language i18n
&lt;/h3&gt;

&lt;p&gt;Full &lt;strong&gt;English, Chinese, and Japanese&lt;/strong&gt; support via &lt;code&gt;next-intl&lt;/code&gt;. English URLs omit the locale prefix (&lt;code&gt;/AAPL&lt;/code&gt;), while others are prefixed (&lt;code&gt;/zh/AAPL&lt;/code&gt;, &lt;code&gt;/ja/AAPL&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  SEO-First Design
&lt;/h3&gt;

&lt;p&gt;Every company page generates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dynamic &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;meta description&amp;gt;&lt;/code&gt; targeting analyst search queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD&lt;/strong&gt; structured data (Corporation + Dataset schemas)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Graph&lt;/strong&gt; and &lt;strong&gt;Twitter Card&lt;/strong&gt; meta tags&lt;/li&gt;
&lt;li&gt;A comprehensive &lt;strong&gt;sitemap&lt;/strong&gt; covering all companies × locales&lt;/li&gt;
&lt;li&gt;Canonical URLs with &lt;code&gt;hreflang&lt;/code&gt; alternates&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mobile Optimizations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bar charts&lt;/strong&gt;: &lt;code&gt;staticPlot: true&lt;/code&gt; on mobile to prevent accidental zoom while scrolling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sankey charts&lt;/strong&gt;: Horizontal scroll wrapper for small screens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive layouts&lt;/strong&gt;: Two-column grid on desktop, stacked on mobile&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Financial data normalization is the real challenge.&lt;/strong&gt; Fetching data is easy; making it consistent across 500+ companies with different reporting standards is where you spend all your time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cloudflare's edge stack is production-ready.&lt;/strong&gt; D1 + Workers + Pages can power a real data platform. Zero DevOps, near-zero cost.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Plotly.js Sankey charts need manual layout.&lt;/strong&gt; The automatic positioning creates chaos with many nodes. Fixed coordinates with value-weighted positioning is the way to go.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't use &lt;code&gt;html2canvas&lt;/code&gt; in 2026.&lt;/strong&gt; Modern CSS color functions (&lt;code&gt;lab&lt;/code&gt;, &lt;code&gt;oklch&lt;/code&gt;) break it. Use native chart library export + Canvas API compositing instead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YTD cumulative reporting is an underappreciated complexity.&lt;/strong&gt; If you're building anything with quarterly financial data, you &lt;em&gt;will&lt;/em&gt; need subtraction logic for cash flows.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://10q10k.net" rel="noopener noreferrer"&gt;10q10k.net&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Explore any S&amp;amp;P 500 company's financial flow — the Sankey charts are interactive, downloadable, and available in three languages.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Next.js, Cloudflare Workers, D1, Plotly.js, and TypeScript.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>serverless</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Built a Markdown Editor That Deliberately Does Less</title>
      <dc:creator>Kyles Light</dc:creator>
      <pubDate>Sat, 14 Feb 2026 07:01:02 +0000</pubDate>
      <link>https://dev.to/kyles_light_275be0175609f/i-built-a-markdown-editor-that-deliberately-does-less-1fkh</link>
      <guid>https://dev.to/kyles_light_275be0175609f/i-built-a-markdown-editor-that-deliberately-does-less-1fkh</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft2osc7dmr4ml563o28nj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft2osc7dmr4ml563o28nj.png" alt=" " width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a particular kind of frustration that comes from wanting something simple and never finding it.&lt;/p&gt;

&lt;p&gt;I built PaperLab because I was tired of Notion's loading times and Obsidian's complexity. Every time I sat down to write, I found myself configuring systems about writing instead of actually writing.&lt;/p&gt;

&lt;p&gt;I wanted the blank page back.&lt;/p&gt;

&lt;p&gt;PaperLab is built with a few stubborn principles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instantly Fast&lt;/strong&gt;: Opens like a light switch. All data is local-first in IndexedDB. The cloud syncs quietly in the background, but never makes you wait.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Architectural Privacy&lt;/strong&gt;: I didn't want to just write a privacy policy. I wanted to make it architecturally impossible for me to read your notes. With E2E encryption enabled, the server only stores encrypted blobs. Your passphrase never leaves your device.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Intentionally Simple&lt;/strong&gt;: No plugins. No AI. No databases. Just a clean, three-pane interface for writing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's a tool for those who want a room with good light and a closed door.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://paperlab.ink" rel="noopener noreferrer"&gt;https://paperlab.ink&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions and hear your thoughts.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
