<?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: Christian Pichichero</title>
    <description>The latest articles on DEV Community by Christian Pichichero (@tradevodata).</description>
    <link>https://dev.to/tradevodata</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%2F4017923%2Fe278d800-4f1b-4a9a-8659-027387e2544f.png</url>
      <title>DEV Community: Christian Pichichero</title>
      <link>https://dev.to/tradevodata</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tradevodata"/>
    <language>en</language>
    <item>
      <title>Allowlists Are Not Spending Caps: Two Different Security Properties, Often Confused</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Thu, 03 Sep 2026 18:05:38 +0000</pubDate>
      <link>https://dev.to/tradevodata/allowlists-are-not-spending-caps-two-different-security-properties-often-confused-lk0</link>
      <guid>https://dev.to/tradevodata/allowlists-are-not-spending-caps-two-different-security-properties-often-confused-lk0</guid>
      <description>&lt;p&gt;If you've ever configured a delegated signer, a session key, or a bot's wallet permissions, you've probably reached for two knobs: an allowlist of assets it's allowed to touch, and a cap on how much value it can move. It's tempting to treat these as two settings on the same dial — both feel like "limits." They're not. They prevent different failure modes, and conflating them is how permission systems end up weaker than their configuration screen suggests.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a spending cap actually prevents
&lt;/h3&gt;

&lt;p&gt;A spending cap bounds &lt;em&gt;magnitude&lt;/em&gt;. If a delegated key can move at most 500 USDC per day, then no matter what it does with that authority — swap, stake, send to an arbitrary address — the maximum damage from a compromised or misbehaving key is 500 USDC. It says nothing about &lt;em&gt;what&lt;/em&gt; the money touches. A cap enforced correctly stops a runaway loop or a fully compromised signer from draining a wallet in one shot. It does not stop that signer from moving the capped amount into something worthless.&lt;/p&gt;

&lt;h3&gt;
  
  
  What an allowlist actually prevents
&lt;/h3&gt;

&lt;p&gt;An allowlist bounds &lt;em&gt;scope&lt;/em&gt;. If a signer can only interact with three named token contracts, then even with no spending cap at all, it categorically cannot send funds to an arbitrary address or interact with an unknown contract. This is a different property: it's not about how much moves, it's about which counterparties are even reachable.&lt;/p&gt;

&lt;p&gt;The two compose well in theory — cap the blast radius, and separately narrow the attack surface — but each has failure modes the other doesn't cover, and "we have both" is not the same claim as "we're safe."&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 1: the allowlisted token isn't the code you allowlisted
&lt;/h3&gt;

&lt;p&gt;An allowlist entry is usually just an address. But an address is not fixed behavior. If the token contract sits behind a proxy — which a large fraction of tokens do, for legitimate upgrade reasons — the logic executed when your signer calls &lt;code&gt;transfer&lt;/code&gt; or &lt;code&gt;approve&lt;/code&gt; can change after you added the address to your list. You audited version 1. The proxy now points at version 2. Your allowlist still matches, because the allowlist only ever checked the address, and the address never moved. This isn't a hypothetical: it's the entire reason "audit the contract, then allowlist the address" is a weaker guarantee than people assume — the audit has a timestamp, the allowlist doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 2: the router that accepts arbitrary calldata
&lt;/h3&gt;

&lt;p&gt;A lot of "swap-only" permission schemes are enforced by pointing the signer at a router contract instead of individual token contracts, on the theory that a router only does swaps. But look at what the router's function signature actually accepts. Plenty of router and aggregator interfaces expose something shaped like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function execute(address target, bytes calldata data) external;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not a swap function. That's a generic call forwarder that happens to usually be used for swaps. If your policy allowlists the router's address and stops there, you've allowlisted "anything the router is willing to forward," which — depending on the router's own internal checks — can include calls into arbitrary target contracts with arbitrary calldata. The allowlist has to be checked against the &lt;em&gt;target and selector inside the calldata&lt;/em&gt;, not just the outermost contract address, or it isn't restricting anything meaningful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 3: "it can only swap" is a claim about a function signature you haven't read
&lt;/h3&gt;

&lt;p&gt;This is the general version of failure mode 2. "Swap-only" is not a security property until you know exactly what parameters the swap function takes. A swap function that hard-codes the output token and the pool is a narrow, auditable action. A swap function that takes a &lt;code&gt;path: address[]&lt;/code&gt; or a &lt;code&gt;router: address&lt;/code&gt; parameter chosen by the caller is, functionally, "call arbitrary code with a legitimate-looking wrapper around it." The English sentence "it can only swap" is doing no work — the actual boundary is whatever the function's parameter types and the policy engine's validation of those parameters allow. Read the signature, not the label.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical checklist
&lt;/h3&gt;

&lt;p&gt;When someone tells you an authorization is scoped, four questions separate a real boundary from a documentation claim:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Allowlisted at what granularity — contract address, function selector, or full calldata shape?&lt;/li&gt;
&lt;li&gt;Capped on what — per-transaction, cumulative, or notional value at execution time (which can be gamed by slippage)?&lt;/li&gt;
&lt;li&gt;Enforced &lt;em&gt;where&lt;/em&gt; — checked by a policy engine before signing, checked by the contract itself on-chain, or just described in a UI with no enforcement at all?&lt;/li&gt;
&lt;li&gt;What happens when a check fails — does the transaction revert, or does the check silently pass because the policy engine and the contract disagree about what "the same asset" means?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes allowlists useless. It makes them one layer, with a specific and narrow claim, that has to be paired with selector-level restriction and cap enforcement at the layer that actually executes the call — and it means the honest description of any "restricted signer" is a list of exactly what's checked and where, not the word "scoped" on its own.&lt;/p&gt;

&lt;p&gt;Disclosure: I build &lt;a href="https://app.tradevo.co/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=allowlists-as-a-security-boundary" rel="noopener noreferrer"&gt;Tradevo&lt;/a&gt;, which runs delegated strategy execution through a scoped session signer with a token allowlist, per-subscription allocation cap, and slippage limits — enforced by the signing provider's policy and by our own executor, not by the chain, which is exactly why I spent this piece on where enforcement actually lives instead of what the label says.&lt;/p&gt;

</description>
      <category>web3</category>
      <category>security</category>
      <category>ethereum</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Fill Model Is Where Backtests Quietly Cheat</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Thu, 03 Sep 2026 12:54:19 +0000</pubDate>
      <link>https://dev.to/tradevodata/the-fill-model-is-where-backtests-quietly-cheat-4mhe</link>
      <guid>https://dev.to/tradevodata/the-fill-model-is-where-backtests-quietly-cheat-4mhe</guid>
      <description>&lt;p&gt;Every backtest has to answer a boring question: when the strategy says "buy," what price does it actually get? Most backtesting frameworks answer this question badly by default, and the badness is almost always in the strategy's favor.&lt;/p&gt;

&lt;p&gt;Here are the four assumptions that do the most damage, roughly in order of how often they show up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mid-price fills
&lt;/h3&gt;

&lt;p&gt;If your backtest fills orders at the midpoint of the bid-ask spread, you are assuming you trade for free. You don't. A market order pays at least half the spread to cross it; a marketable limit order pays something close to that too, once you're honest about how often it actually gets hit versus sitting unfilled while the market moves away. Mid-price fills are the single most common way a backtest manufactures edge that doesn't exist, because the effect compounds with trade frequency — a strategy that trades often looks great on mid-price fills and mediocre-to-negative once it pays the spread on every round trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zero slippage
&lt;/h3&gt;

&lt;p&gt;Slippage is the gap between the price your signal fired at and the price your order actually executed at, and it's not just a queuing artifact — it's partly information. If your strategy is buying because something changed, other participants are reacting to the same thing, and the price you wanted is often gone by the time your order reaches the book. A backtest with zero slippage is quietly assuming the market waits for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unlimited size at the touch
&lt;/h3&gt;

&lt;p&gt;Backtests routinely assume you can execute your full position size at the best bid or ask, no matter how large the order is relative to the visible size there. In practice, a large order walks the book, and the average fill price is worse than the touch price by an amount that depends on how thin the book is. This one is invisible until you try to size up, which is exactly when a strategy that looked fine in testing starts bleeding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commissions omitted or averaged
&lt;/h3&gt;

&lt;p&gt;Commissions and fees are usually small per trade and therefore easy to skip or fold into a rough average. But a strategy with thin per-trade edge and high turnover can have its entire expectancy eaten by costs that were treated as a rounding error.&lt;/p&gt;

&lt;h3&gt;
  
  
  A worked example
&lt;/h3&gt;

&lt;p&gt;Take a mean-reversion strategy trading a $30 stock with a 2-cent spread: average win 18¢, average loss 14¢, win rate 55%.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expectancy (per share, before costs)
= 0.55 × 18¢ − 0.45 × 14¢
= 9.9¢ − 6.3¢
= 3.6¢
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 3.6¢ looks like a real edge. But if the backtest filled at the mid, it never paid the spread it would pay in live trading. A more honest fill — buying near the ask, selling near the bid — costs roughly the full spread on the round trip, here about 2¢. Subtract that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.6¢ − 2¢ = 1.6¢
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The edge didn't disappear, but it lost more than half its value to an assumption that never showed up as a line item anywhere in the report. Add a per-share commission and a little realistic slippage on top, and it's easy to see how a strategy with a "good" backtest turns out to be trading the spread, not an actual signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you can actually check from a trade list
&lt;/h3&gt;

&lt;p&gt;If all you have is a CSV of closed trades — entry, exit, size, timestamps — there's a limit to how much of this you can diagnose. You can check sensitivity: rerun expectancy with a range of assumed slippage and spread costs and see how much of the edge survives. You can check whether wins are concentrated in trades with unusually favorable prices relative to the surrounding bars, which is a proxy for lookahead or mid-price fills. You can check whether performance depends on a handful of trades — if removing the best 5% of trades erases the edge, that's worth knowing regardless of the fill model.&lt;/p&gt;

&lt;p&gt;What you generally can't check from a trade list alone is anything that requires order book state: actual queue position, actual available size at the touch at the moment of the signal, actual latency between signal and order arrival. Those require tick-level or order-book data and a simulator that models the exchange mechanics, not just entry and exit prices. If someone tells you they can fully validate execution realism from a CSV of closed trades, they're skipping something — the honest version of this check is partial, and it should say so.&lt;/p&gt;

&lt;h3&gt;
  
  
  The practical test
&lt;/h3&gt;

&lt;p&gt;The cheap version of all this: take your reported average win and average loss, subtract a full spread crossing on both entry and exit, and see if the strategy still has positive expectancy. If it doesn't survive that adjustment, the edge was largely the spread it never paid, and no amount of additional testing further downstream is going to rescue it. If it does survive, you've at least confirmed the edge isn't purely a fill-model artifact — which is a different question from whether it will hold up in other ways, but it's the first one worth asking.&lt;/p&gt;

&lt;p&gt;Disclosure: I build &lt;a href="https://verify.tradevo.co/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=execution-assumptions-that-flatter-a-backtest" rel="noopener noreferrer"&gt;Tradevo Verify&lt;/a&gt;, which takes a closed-trade export and runs it through this kind of stress-testing, among other checks, and produces a versioned evidence record rather than a verdict on whether the strategy is good.&lt;/p&gt;

</description>
      <category>python</category>
      <category>trading</category>
      <category>datascience</category>
      <category>statistics</category>
    </item>
    <item>
      <title>What 'Revocable' Actually Means at the Contract Level</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:05:03 +0000</pubDate>
      <link>https://dev.to/tradevodata/what-revocable-actually-means-at-the-contract-level-5201</link>
      <guid>https://dev.to/tradevodata/what-revocable-actually-means-at-the-contract-level-5201</guid>
      <description>&lt;p&gt;If you've ever called &lt;code&gt;approve()&lt;/code&gt; on an ERC-20 token and then moved on with your life, you've already brushed up against the thing this post is about: an approval is not a setting inside some app, it's a row in a smart contract's storage, and every system built on top of it is only as honest as its last read of that row.&lt;/p&gt;

&lt;p&gt;Most token approvals work the same way. A user signs a transaction granting a spender contract permission to move up to some amount of a token from their wallet. The ERC-20 standard stores this as &lt;code&gt;allowance[owner][spender]&lt;/code&gt;. Any contract that wants to move the user's tokens checks that number before doing so, and the check happens inside the same transaction that tries to move funds — so the contract-level enforcement is real. The token contract itself will not let a transfer through if the allowance is insufficient.&lt;/p&gt;

&lt;p&gt;Revocation is just another write to that same slot, usually setting it to zero. It's a normal transaction. It has to be signed, broadcast, and mined like any other. That's the part people gloss over: revoking is not a UI toggle, it's a transaction with all the same properties as the transaction that created the approval in the first place — it sits in a mempool, it can be delayed by network congestion, and it isn't final until it's in a confirmed block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gap shows up
&lt;/h2&gt;

&lt;p&gt;Say you're building a service that executes on a user's behalf using a stored approval — a trading bot, a subscription payment puller, anything with an off-chain component that decides &lt;em&gt;when&lt;/em&gt; to spend and an on-chain component that actually moves the funds. The natural design is to keep a local record: "user X has approved up to Y, active." That record is convenient. It's also just an opinion your own server holds about the world, and it can be wrong in both directions.&lt;/p&gt;

&lt;p&gt;It can be wrong stale-permissive: the user revokes, your service hasn't seen it yet, and if the on-chain check before spending is missing or weak, you build and sign a transaction anyway. Depending on how you structured the check, this either fails harmlessly at the token contract (wasted gas, a failed tx, an alert) or, if you did something sloppier — like checking your database instead of the chain — you send a transaction that the chain itself will still reject, because the allowance really is zero now. The token contract is the backstop here, which is good, but you don't want your system's normal path to depend on that backstop catching your own mistake.&lt;/p&gt;

&lt;p&gt;It can also be wrong stale-restrictive: your database says revoked, but the revoke transaction is still sitting unconfirmed, and the user expects it to be in effect immediately because that's what the button said. This one is more of a UX problem than a safety problem, but it's the same root cause — a record and the chain disagreeing about what's true right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the check has to happen at the moment of execution
&lt;/h2&gt;

&lt;p&gt;The fix sounds almost too obvious to write down: before doing anything that spends a user's tokens, read the allowance from the chain, right then, not from whatever your database cached the last time you looked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attemptExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requiredAmount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nx"&gt;onChainAllowance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readAllowance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the ground truth, right now&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;onChainAllowance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;requiredAmount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nf"&gt;skipAndLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allowance insufficient at execution time&lt;/span&gt;&lt;span class="dl"&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nf"&gt;sendExecutionTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requiredAmount&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This has real costs. Every execution now needs an RPC round-trip before it can act, which adds latency and adds load on whatever node provider you're using. If you're executing for many users on a schedule, that's a lot of extra reads for what is, most of the time, a value that hasn't changed since the last check. There's also a subtler question buried in "read from the chain": read at what block? RPC providers don't all agree on the very latest block during a reorg, and a read that lands on a block that later gets replaced is its own small version of the same trust problem, one layer down. Treating the freshest confirmed state as authoritative, and re-checking rather than caching, is the mechanism — it doesn't make the read instantaneous or immune to provider disagreement, it just means you're asking the right question at the right time instead of trusting an answer that might be minutes or days old.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks mid-cycle
&lt;/h2&gt;

&lt;p&gt;The uncomfortable case is a multi-step action — say a swap that routes through two pools, or a strategy that does three on-chain calls in sequence to complete one logical action. If a user revokes between step one and step two, step one already happened. You can't undo it. The system has to be built so a partial completion is a safe, loggable state rather than an unrecoverable one — which mostly means designing each step so it's fine to stop after it, not chaining steps that only make sense together and hoping revocation never lands in the middle. That's a design constraint, not something the revoke mechanism itself solves for you.&lt;/p&gt;

&lt;p&gt;None of this makes revocation less real. The user genuinely can remove the authorization, and once that transaction confirms, the contract genuinely will not let the spender move their tokens anymore — that part is enforced by the token contract itself, not by anyone's goodwill. What it means is narrower and more mechanical than "revocable" sounds: it's a state change on a specific chain, subject to that chain's confirmation times, and any executor sitting on top of it is only trustworthy if it treats its own records as a guess and the chain as the check.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Tradevo, which runs automated strategies on Base from wallets whose keys the user holds. More at &lt;a href="https://app.tradevo.co/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=what-revoking-actually-does" rel="noopener noreferrer"&gt;app.tradevo.co&lt;/a&gt;. The trade-offs above are ones I have had to think through rather than ones I am claiming to have solved.&lt;/p&gt;

</description>
      <category>web3</category>
      <category>ethereum</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Scoped Permissions vs Custody: Letting Software Act On-Chain Without Holding Your Keys</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Tue, 01 Sep 2026 02:19:59 +0000</pubDate>
      <link>https://dev.to/tradevodata/scoped-permissions-vs-custody-letting-software-act-on-chain-without-holding-your-keys-4pfe</link>
      <guid>https://dev.to/tradevodata/scoped-permissions-vs-custody-letting-software-act-on-chain-without-holding-your-keys-4pfe</guid>
      <description>&lt;p&gt;If you've ever set up a recurring on-chain action — a DCA buy, a rebalance, a claim-and-restake — you've run into the same fork in the road: either you hand a private key to something (a bot, a script, a service), or you sign every transaction yourself and the automation stops being automatic. Custody is the easy way out of that problem, and it's also the thing that goes wrong most often in this industry. So it's worth being precise about what "non-custodial automation" actually means mechanically, because the phrase gets used loosely.&lt;/p&gt;

&lt;p&gt;There are two structurally different ways to let a piece of software act from your wallet without giving it your funds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delegated session signers.&lt;/strong&gt; You generate a separate keypair, authorize it (usually via a signed message or an on-chain transaction) to act on your account, and hand the private half to a service. The service's backend now holds a key that can sign transactions on your behalf. Non-custodial in the narrow sense that your main wallet's key never leaves your device — but the &lt;em&gt;scope&lt;/em&gt; of what that session key can do is enforced by the vendor's own logic, before it ever reaches the chain. Something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backend, off-chain, before it decides to sign&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isCallInScope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
 &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
 &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real check, and a careful team writes it correctly. But notice where it lives: in a server you don't control, checked against a policy object that server maintains. If the backend has a bug, gets compromised, or is simply told by an operator to ignore the cap for one call, nothing on-chain stops it. The chain sees a validly signed transaction from an authorized key and executes it. The account itself has no opinion about whether the call was "in scope" — it trusted the key, full stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-enforced permissions (ERC-7715 style).&lt;/strong&gt; The newer approach, built for smart accounts under ERC-4337 and formalized in proposals like ERC-7715, moves the scope check into the account's own validation logic via caveat enforcers. When you grant a permission, you're not just handing over a key — you're installing a rule &lt;em&gt;inside your account&lt;/em&gt; that every action from that permission has to pass, checked by the account contract itself at execution time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// runs in the account's validation path, on-chain
function enforceCaveat(bytes calldata call, Caveat calldata c) external view {
 require(call.target == c.allowedTarget, "target not allowlisted");
 require(c.spent + call.value &amp;lt;= c.cap, "exceeds cap");
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference sounds small but it isn't. In the 7715 model, even if the delegate's key is fully compromised, the account contract rejects any call that violates the caveat — the enforcement is data the account carries, not trust the account extends. Revocation is also a first-class on-chain action against your own account state, not a request you send to someone else's server and hope gets honored before the next scheduled run.&lt;/p&gt;

&lt;p&gt;The honest trade-off: 7715 requires your wallet to be a smart account that supports the standard, and tooling for it is still young — wallet support is uneven, and the caveat-enforcer ecosystem (which enforcers exist, which are audited, which compose safely with each other) is not mature. The session-signer approach works today with wallets that have none of that infrastructure, and for a lot of use cases it is a reasonable, shippable answer. It's just that when you use it, the meaning of "scoped" is a promise made by a company's backend code, not a rule your account carries. That's a real distinction, not a pedantic one, and it's worth asking any "non-custodial automation" product which side of it they're on.&lt;/p&gt;

&lt;p&gt;A few other things are true regardless of which model you pick. Gas sponsorship (someone else pays gas so the user doesn't need a native-token balance sitting around) is orthogonal to custody — you can sponsor gas under either architecture. An allowlist of target contracts and an allocation cap reduce blast radius but don't eliminate it: a bug in an allowlisted contract, or a cap set too high, still lets real money move. And revocability is only as good as how quickly it takes effect — an on-chain revocation is final the moment it's mined; an off-chain "we'll stop signing now" is final whenever the backend process notices.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Tradevo (app.tradevo.co), which runs algorithmic strategies from a wallet whose keys the user holds. More at &lt;a href="https://app.tradevo.co/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=scoped-permissions-instead-of-custody" rel="noopener noreferrer"&gt;app.tradevo.co&lt;/a&gt;. I'm not claiming it's the account-enforced version described above; it's worth being clear about which one you're actually getting, from us or anyone else, before you sign anything.&lt;/p&gt;

</description>
      <category>web3</category>
      <category>ethereum</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>What Shuffling Your Trade History Actually Tells You (And What It Doesn't)</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:50:01 +0000</pubDate>
      <link>https://dev.to/tradevodata/what-shuffling-your-trade-history-actually-tells-you-and-what-it-doesnt-1fa7</link>
      <guid>https://dev.to/tradevodata/what-shuffling-your-trade-history-actually-tells-you-and-what-it-doesnt-1fa7</guid>
      <description>&lt;p&gt;You ran a backtest. The equity curve is smooth, the drawdown is small, the Sharpe ratio looks respectable. Here's the thing nobody tells you early enough: that curve is one path. It's the result of your trades happening in exactly the order they happened. If trade #14 (a big loser) had landed right after trade #3 (another big loser) instead of scattered safely between winners, your drawdown number would be a different number, from the same trades, with the same win rate, same average win, same average loss.&lt;/p&gt;

&lt;p&gt;That's the whole motivation for Monte Carlo trade resampling. You take the list of closed trades — just the P&amp;amp;L values, stripped of their original sequence — and you reshuffle them, thousands of times, rebuilding an equity curve for each shuffle. Two common flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permutation (resampling without replacement):&lt;/strong&gt; every shuffle uses the exact same set of trades, just in a different order. Total return is fixed across all shuffles; only the &lt;em&gt;path&lt;/em&gt; changes — which is precisely the point, since path determines drawdown and time-underwater.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bootstrap (resampling with replacement):&lt;/strong&gt; each shuffle draws trades at random, allowing repeats and omissions. This also perturbs the total return, not just the order, giving you a sense of variance from sample size, not just sequence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a minimal version of the permutation approach:&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;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;max_drawdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;equity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accumulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;equity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;dd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;equity&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;peak&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resample_paths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trade_returns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_sims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;trades&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trade_returns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;results&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="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_sims&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;shuffled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;permutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;equity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cumprod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shuffled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max_drawdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;equity&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this on 80 closed trades and you don't get one drawdown number, you get a distribution of 5,000 drawdown numbers. That distribution is the actual output worth looking at. Somewhere in there is a 5th percentile case — a drawdown notably worse than the one your backtest happened to show you, built from the exact same trades. If your original backtest's drawdown sits near the friendly end of that distribution, your single equity curve got lucky on ordering, and you didn't know it because you only ever looked at the one path that occurred.&lt;/p&gt;

&lt;h3&gt;
  
  
  The median-path trap
&lt;/h3&gt;

&lt;p&gt;Here's where people go wrong once they've done the resampling correctly: they look at the median drawdown, or the median final equity, and treat it as "the expected outcome going forward." This is a mistake for a specific mathematical reason, not just a vague warning. Max drawdown is a nonlinear, path-dependent statistic — it's the result of a maximum and a minimum operating over the whole sequence. When you average or take the median of thousands of &lt;em&gt;nonlinear&lt;/em&gt; statistics, that summary number doesn't correspond to any real path anyone actually experiences. There is no shuffle in your simulation set whose drawdown equals the median drawdown by construction — it's a statistic about the population of outcomes, not a description of a plausible one.&lt;/p&gt;

&lt;p&gt;Worse, the distribution of drawdowns is almost always right-skewed (bounded at zero, long tail toward catastrophic). The median underrepresents the tail. If you're using this number to size positions or set risk limits, you want the 90th or 95th percentile of drawdown, not the middle of the pack — the middle is the case where nothing went particularly wrong, which is not the case you're trying to survive.&lt;/p&gt;

&lt;h3&gt;
  
  
  The assumption everyone skips: independence
&lt;/h3&gt;

&lt;p&gt;Both permutation and bootstrap resampling rest on one assumption: that each trade's outcome is independent of the trades around it — that shuffling the order doesn't destroy any real information, because there wasn't any sequential structure to begin with. For a lot of systematic strategies this is roughly fine. For momentum strategies, it's false, and it's false in a way that matters.&lt;/p&gt;

&lt;p&gt;Momentum strategies, by construction, tend to produce autocorrelated trade outcomes: winning trades cluster during trending regimes, losing trades cluster during chop or reversals, because the underlying edge itself is regime-dependent. When you randomly permute those trades, you break up the clusters. This can cut both ways — sometimes it makes the resampled drawdowns look &lt;em&gt;worse&lt;/em&gt; than reality, because it creates unlucky strings of losses that would never actually co-occur (a losing streak needs a choppy regime, and regimes don't get randomly interleaved trade-by-trade in real markets). Other times it hides the real risk, because the actual worst case is "strategy stops working when the regime changes for three months straight," and no reshuffling of historical trade P&amp;amp;L will manufacture a scenario the strategy never lived through.&lt;/p&gt;

&lt;p&gt;Block bootstrapping — resampling contiguous chunks of trades instead of individual ones — partially addresses this by preserving some local correlation structure. It's a real improvement, not a full fix. It still can't invent a regime your strategy never traded through, and it still assumes the &lt;em&gt;blocks&lt;/em&gt; are exchangeable, which is a weaker but still real assumption.&lt;/p&gt;

&lt;p&gt;So what resampling actually tells you: how much of your backtest's apparent smoothness depended on the specific order the trades happened to arrive in, and how much of that ordering is even ordering you can trust reshuffling to explore honestly. It's a stress test on a fixed sample, not a forecast, and treating the tidy version — the median path, or a permutation test on a momentum book — as the expected future is the fast way to be surprised by a drawdown your simulation told you was rare.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Tradevo Verify, which runs this kind of resampling (among other checks) against closed-trade exports and reports the resulting distribution rather than a single pass/fail number — because the distribution is the honest answer and a single number usually isn't.&lt;/p&gt;

</description>
      <category>python</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>trading</category>
    </item>
    <item>
      <title>What a Clean Equity Curve Doesn't Prove</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:27:25 +0000</pubDate>
      <link>https://dev.to/tradevodata/what-a-clean-equity-curve-doesnt-prove-1fif</link>
      <guid>https://dev.to/tradevodata/what-a-clean-equity-curve-doesnt-prove-1fif</guid>
      <description>&lt;p&gt;You've seen the shape before: equity curve going up and to the right, Sharpe ratio north of 2, max drawdown that looks survivable. It's convincing. It's also, on its own, close to worthless as evidence that the strategy will do anything useful going forward.&lt;/p&gt;

&lt;p&gt;This isn't a claim that backtesting is pointless. It's that a single backtest, however clean, can hide four specific failure modes that don't show up in the summary stats you're staring at. You have to go looking for them in the trade list itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Return concentration
&lt;/h2&gt;

&lt;p&gt;Open your closed-trade export and sort by PnL, descending. Sum the top 3 trades. Sum everything else. If the top 3 account for most of your total return, your backtest isn't describing a strategy — it's describing a small number of events that happened to occur in your sample window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;trade&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;pnl&lt;/span&gt;
&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;120&lt;/span&gt;
&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;40&lt;/span&gt;
&lt;span class="mf"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3800&lt;/span&gt;   &lt;span class="err"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;one&lt;/span&gt; &lt;span class="k"&gt;trade&lt;/span&gt;
&lt;span class="mf"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;60&lt;/span&gt;
&lt;span class="mf"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;90&lt;/span&gt;
&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="mf"&gt;47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A strategy with 47 trades where one trade is 90% of total profit isn't a systematic edge, it's a lottery ticket that paid off once during backtesting. The Sharpe ratio doesn't tell you this. The equity curve doesn't tell you this, because one big trade still draws a smooth-looking line. You have to look at the distribution of individual trade PnL, not the cumulative sum.&lt;/p&gt;

&lt;p&gt;The fix isn't to throw away the trade — maybe it was real. The fix is to know it's there before you size a live position based on "the strategy averages X% per trade."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Regime dependence
&lt;/h2&gt;

&lt;p&gt;A strategy backtested from 2019 to 2023 has lived through exactly one dominant regime: a multi-year bull market interrupted by one sharp, fast-recovering crash. If your entries are long-biased and your backtest window doesn't include a prolonged sideways or bear regime, you haven't tested a strategy — you've tested a strategy's performance during one macro condition, and gotten a result that will not generalize.&lt;/p&gt;

&lt;p&gt;The way this hides itself: date range selection feels neutral. "I used all the data I had" sounds responsible. But if all the data you had happens to be one regime, your Sharpe ratio is really a Sharpe ratio conditioned on that regime, and nothing in the report tells you that conditioning exists. You find it by segmenting trades by market condition (trend vs. chop, high vol vs. low vol) and checking whether performance holds up in each segment separately, not just in aggregate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Execution assumptions doing the work
&lt;/h2&gt;

&lt;p&gt;Every backtest makes assumptions about fills: what price you get, how much slippage, whether you can actually get filled at all at the size you're testing. These assumptions are usually buried in a config file or a default in your backtesting library, and they are frequently the entire source of the edge.&lt;/p&gt;

&lt;p&gt;A mean-reversion strategy that assumes fills at the exact touch price on a thinly traded instrument is not describing a strategy — it's describing what happens if you had a magic wand for that one variable. Move the assumption from "fill at touch" to "fill at touch plus one tick" and watch a lot of "profitable" systematic strategies go flat or negative. This is worth doing as a deliberate stress test: take your existing trade list, degrade the fill assumption by a fixed amount, and recompute. If the strategy's profitability doesn't survive a small, realistic degradation, the edge was execution assumption, not signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Path luck
&lt;/h2&gt;

&lt;p&gt;Even a strategy with real, non-concentrated, regime-robust edge produced one specific sequence of trades, in one specific order, during one specific slice of history. That sequence is a single sample from a distribution of possible sequences. Some of those alternate sequences look much worse — deeper drawdowns, longer flat periods, sequences where the losing trades cluster early and you'd have quit before the edge showed up.&lt;/p&gt;

&lt;p&gt;The standard way to check this is to shuffle: take your trade returns, resample them (with replacement, or reorder them, depending on what you're testing for), and generate a distribution of possible equity curves instead of the one you happened to get. If your actual curve sits near the median of that distribution, the drawdown you experienced is typical. If your actual curve is near the best-case tail, you got lucky with path, and a live version of this strategy is more likely to look like the median case — which might include a drawdown you didn't plan for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this adds up to
&lt;/h2&gt;

&lt;p&gt;None of these four checks are exotic. Concentration is a sort and a sum. Regime dependence is a segmentation. Execution sensitivity is a parameter sweep. Path luck is a resample. You can build all of this yourself in an afternoon with pandas and numpy, and if you're serious about running a systematic strategy with real money, you should — at minimum look at your trade list sorted by PnL and ask if 3 trades are secretly the whole story.&lt;/p&gt;

&lt;p&gt;The uncomfortable conclusion is that a clean equity curve is weak evidence by default. It's evidence of what happened in one sample, once, under one set of fill assumptions. Turning it into evidence you can actually lean on means trying to break it, not admiring it.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Tradevo Verify, which runs a version of these checks (execution stress, path/Monte Carlo, concentration, regime tests) against a closed-trade CSV and returns a report on how much weight the evidence can carry — not a verdict that the strategy is good. It's a $99 one-time check, and it's built to return a fragile result whenever the trade list earns one.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>trading</category>
      <category>statistics</category>
    </item>
    <item>
      <title>Point-in-Time Fundamentals for Numerai Signals: Killing Lookahead in Your Feature Join</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Wed, 19 Aug 2026 16:00:35 +0000</pubDate>
      <link>https://dev.to/tradevodata/point-in-time-fundamentals-for-numerai-signals-killing-lookahead-in-your-feature-join-1h54</link>
      <guid>https://dev.to/tradevodata/point-in-time-fundamentals-for-numerai-signals-killing-lookahead-in-your-feature-join-1h54</guid>
      <description>&lt;p&gt;If you build features for Numerai Signals from fundamentals, the single most common way to silently overstate your live performance is joining on the wrong date. A table keyed by fiscal period end, or by a single "report date" that is overwritten on every revision, cannot tell you when a number was actually knowable. Our rows are keyed by &lt;code&gt;first_filed&lt;/code&gt; — the date the value first appeared on EDGAR — with the first-reported value stored separately from the current one. Numerai's tournament resolves against real future returns, so any leakage in your feature construction shows up as a validation metric that decays the moment you go live. (This is not investment advice, and nothing here is a performance promise.)&lt;/p&gt;

&lt;p&gt;This article covers three things: why lookahead leaks into fundamentals-based signals even when you think you've handled it, how a &lt;code&gt;first_filed&lt;/code&gt;-keyed join fixes the mechanics, and where a small, honest, US-annual-only dataset like ours fits — and where it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fundamentals leak into signals more than price data does
&lt;/h2&gt;

&lt;p&gt;Price and volume are point-in-time by construction — the close on Tuesday was known Tuesday night. Fundamentals are not. A 10-K covering fiscal year 2022 might be filed in March 2023, restated in an amendment in August 2023, and then sit in a vendor's database keyed only by "period end 2022-12-31." If your pipeline joins on period end and pulls whatever value is in the database &lt;em&gt;today&lt;/em&gt;, you're feeding your model information that didn't exist yet, and sometimes a corrected number that didn't exist until months later.&lt;/p&gt;

&lt;p&gt;This is a bigger problem for fundamentals than most people expect, because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filing lag varies a lot by company size and quality.&lt;/strong&gt; Some filers report a few weeks after period end (rare), most take one to three months, and small caps can lag further.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restatements are common and not always flagged.&lt;/strong&gt; If a value changes across amendments and you don't know which version was live on a given day, backtests can pick up the &lt;em&gt;revised&lt;/em&gt; number, which can correlate with future returns simply because it was derived with hindsight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numerai's eras compound the problem.&lt;/strong&gt; Signals are scored weekly across thousands of tickers; a systematic few-week lookahead bias across the whole universe doesn't average out — it can inflate validation metrics uniformly, which is worth checking for if a validation curve looks unusually strong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We wrote a longer, more technical breakdown of this mechanism in &lt;a href="https://tradevodata.com/blog/lookahead-bias-fundamental-backtests?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=point-in-time-data-for-numerai-signals" rel="noopener noreferrer"&gt;Lookahead Bias in Fundamental Backtests&lt;/a&gt; if you want the failure modes in more detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correct join: &lt;code&gt;first_filed&lt;/code&gt;, not period end
&lt;/h2&gt;

&lt;p&gt;The fix is mechanical once you have the right column. Every fundamentals row needs a &lt;code&gt;first_filed&lt;/code&gt; timestamp — the date the value became public via SEC EDGAR — separate from the fiscal period it describes. For a given Numerai submission date &lt;code&gt;as_of&lt;/code&gt;, the query is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fundamentals&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;first_filed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;as_of&lt;/span&gt;   &lt;span class="c1"&gt;-- same-day inclusive&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;first_filed&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the filing-date half of the leakage fix: the filter is &lt;code&gt;first_filed &amp;lt;= as_of&lt;/code&gt;, applied per row, instead of a uniform "lag by 90 days" heuristic across every filer (which both under- and over-corrects depending on the company). You also want two values per row, not one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;original_value&lt;/code&gt; — the first-reported figure, safe for point-in-time backtests.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;latest_value&lt;/code&gt; — the current, revision-including figure, useful only if you're deliberately studying restatement effects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We key our dataset this way: &lt;code&gt;first_filed&lt;/code&gt;, &lt;code&gt;original_value&lt;/code&gt;, &lt;code&gt;latest_value&lt;/code&gt;, a &lt;code&gt;restated&lt;/code&gt; flag (set when a same-tag revision exceeds 0.5%, including amendments), and &lt;code&gt;qa_status&lt;/code&gt;. Across our current build, 18,723 rows carry that restated flag out of 312,751 total — restatements are common enough that ignoring them isn't a rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What our dataset is (and explicitly is not)
&lt;/h2&gt;

&lt;p&gt;Tradevo Data (&lt;a href="https://tradevodata.com/?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=point-in-time-data-for-numerai-signals" rel="noopener noreferrer"&gt;tradevodata.com&lt;/a&gt;) is a point-in-time US equity fundamentals dataset sourced entirely from SEC EDGAR (public domain data, not redistributed from a paid vendor). Currently: 5,189 US companies, 312,751 point-in-time rows, 7 core concepts (Revenue, NetIncome, Assets, StockholdersEquity, OperatingCashFlow, EPSDiluted, DilutedShares), up to 12 fiscal years of history, &lt;strong&gt;annual only&lt;/strong&gt; — 10-K and 10-K/A filings. Quarterly is on the roadmap, not shipped. If your Numerai signal design needs quarterly fundamentals or non-US tickers, this dataset will not cover you today — say so up front rather than let you find out after checkout.&lt;/p&gt;

&lt;p&gt;Access is one JSON endpoint, &lt;code&gt;/v1/fundamentals?ticker&amp;amp;as_of[&amp;amp;concept]&lt;/code&gt;, server-side &lt;code&gt;first_filed &amp;lt;= as_of&lt;/code&gt; filtering built in, plus the full dataset via &lt;code&gt;/v1/download&lt;/code&gt; and whole-universe cross-sections via &lt;code&gt;/v1/snapshot?as_of&lt;/code&gt; — all included in the $49/mo plan, 5,000 requests/day and 2,500 distinct tickers/day (use &lt;code&gt;/v1/snapshot&lt;/code&gt; or &lt;code&gt;/v1/download&lt;/code&gt; for cross-sections). No quarterly, no non-US, no Parquet yet (CSV/gzip only; Parquet is roadmap).&lt;/p&gt;

&lt;p&gt;On lag: on the reliable-filing rows of our 40-company free sample, the gap between fiscal period end and &lt;code&gt;first_filed&lt;/code&gt; was mean 43.4 days / max 61 days. That figure is scoped to the sample. Across the full 5,189-company universe the reliable-row gap is wider — mean 66 days, median 60, p90 90 — because large caps are the fastest filers, so don't extrapolate the sample figure to the whole universe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fair comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Tradevo Data&lt;/th&gt;
&lt;th&gt;Sharadar (Nasdaq Data Link)&lt;/th&gt;
&lt;th&gt;Tiingo&lt;/th&gt;
&lt;th&gt;QuantConnect&lt;/th&gt;
&lt;th&gt;Build it yourself from EDGAR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Point-in-time fields&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;first_filed&lt;/code&gt;, original + latest)&lt;/td&gt;
&lt;td&gt;Yes, per their docs&lt;/td&gt;
&lt;td&gt;Fundamentals PIT coverage varies, check their docs&lt;/td&gt;
&lt;td&gt;Yes, via their data infra&lt;/td&gt;
&lt;td&gt;Yes, if you build it correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frequency&lt;/td&gt;
&lt;td&gt;Annual only&lt;/td&gt;
&lt;td&gt;Annual + quarterly, per their docs&lt;/td&gt;
&lt;td&gt;Varies by plan&lt;/td&gt;
&lt;td&gt;Varies by plan&lt;/td&gt;
&lt;td&gt;Whatever you extract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coverage&lt;/td&gt;
&lt;td&gt;US only&lt;/td&gt;
&lt;td&gt;US, check their docs for depth/history&lt;/td&gt;
&lt;td&gt;US-focused&lt;/td&gt;
&lt;td&gt;Multi-asset via platform&lt;/td&gt;
&lt;td&gt;Whatever you scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;$49/mo flat&lt;/td&gt;
&lt;td&gt;See their pricing page&lt;/td&gt;
&lt;td&gt;See their pricing page&lt;/td&gt;
&lt;td&gt;See their pricing page&lt;/td&gt;
&lt;td&gt;Your engineering time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restatement flags&lt;/td&gt;
&lt;td&gt;Yes, explicit&lt;/td&gt;
&lt;td&gt;Check their docs&lt;/td&gt;
&lt;td&gt;Check their docs&lt;/td&gt;
&lt;td&gt;Check their docs&lt;/td&gt;
&lt;td&gt;You build the logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We don't know competitors' current prices and won't guess — check their pricing pages directly, they change.&lt;/p&gt;

&lt;h2&gt;
  
  
  When another option is genuinely better
&lt;/h2&gt;

&lt;p&gt;If you need quarterly fundamentals for Numerai Signals features (which many quality/growth factors want), Sharadar or a comparable vendor with quarterly PIT coverage is the right call today — we don't have it. If you need international equities, none of what's here helps; we're US-only. If you're already inside QuantConnect's ecosystem and want fundamentals integrated with their backtester and live trading, their bundled data may save you more integration time than a standalone API, even before comparing price.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to build it yourself
&lt;/h2&gt;

&lt;p&gt;EDGAR's data is public and free. If you only need a handful of concepts for a handful of tickers, and you're comfortable parsing XBRL and handling amendment logic yourself, you can build a &lt;code&gt;first_filed&lt;/code&gt;-keyed table in a weekend. The tradeoffs: you own restatement detection, filer-level edge cases (fiscal year changes, non-calendar year ends, multiple amendments to the same period), and ongoing maintenance as EDGAR's XBRL taxonomy shifts. For a few tickers, doable. For thousands of tickers across multiple years, it becomes a real data-engineering project — which is the gap we built this to fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try before you subscribe
&lt;/h2&gt;

&lt;p&gt;The free sample — 40 companies, 3,280 rows, full methodology, no signup — is on &lt;a href="https://tradevodata.com/go/github-sample?cta_location=blog-numerai&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=point-in-time-data-for-numerai-signals" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Run your own join logic against it before paying for anything. If it fits your Signals pipeline, the full dataset is &lt;a href="https://tradevodata.com/?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=point-in-time-data-for-numerai-signals#pricing" rel="noopener noreferrer"&gt;$49/mo&lt;/a&gt;, instant key after Stripe checkout, cancel anytime. More on the mechanics of PIT fundamentals generally: &lt;a href="https://tradevodata.com/blog/point-in-time-fundamentals-data?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=point-in-time-data-for-numerai-signals" rel="noopener noreferrer"&gt;Point-in-Time Fundamentals Data, Explained&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Not investment advice; verify any competitor pricing yourself on their current pricing pages.&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Why the Same Fundamental Lives Under Different XBRL Tags in SEC EDGAR</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Wed, 12 Aug 2026 16:31:22 +0000</pubDate>
      <link>https://dev.to/tradevodata/why-the-same-fundamental-lives-under-different-xbrl-tags-in-sec-edgar-fmi</link>
      <guid>https://dev.to/tradevodata/why-the-same-fundamental-lives-under-different-xbrl-tags-in-sec-edgar-fmi</guid>
      <description>&lt;p&gt;If you've tried to pull "revenue" for a US company directly out of SEC EDGAR's XBRL data, you've probably noticed something annoying: the same line item on the income statement doesn't always show up under the same tag. One year it's &lt;code&gt;Revenues&lt;/code&gt;. Another year, for the same company, it's &lt;code&gt;SalesRevenueNet&lt;/code&gt;. After 2018 it might switch again to &lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt;. Nothing about the business changed — the taxonomy did.&lt;/p&gt;

&lt;p&gt;This is one of the least-discussed but most consequential problems in building point-in-time fundamentals from EDGAR, and it's the reason a lot of DIY XBRL scrapers quietly produce broken time series without anyone noticing until a backtest looks weird.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tags change at all
&lt;/h2&gt;

&lt;p&gt;The SEC requires filers to tag financial statement line items using the US GAAP XBRL taxonomy, which the FASB updates annually. A few things drive tag churn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Taxonomy revisions.&lt;/strong&gt; New tags get added, old ones get deprecated, and companies (or their filing agents) migrate to the current tag in a later filing — sometimes mid-history, sometimes not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accounting standard changes.&lt;/strong&gt; The rollout of ASC 606 (revenue recognition) around 2018 is the clearest example: many filers moved from &lt;code&gt;Revenues&lt;/code&gt; or &lt;code&gt;SalesRevenueNet&lt;/code&gt; to &lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt; or the "IncludingAssessedTax" variant, sometimes in the same fiscal year they adopted the standard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filer inconsistency.&lt;/strong&gt; Two companies in the same industry, filing in the same quarter, can choose different (both technically valid) tags for what an analyst would call the same concept. Smaller filers and their outside preparers are especially inconsistent year over year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom/extension tags.&lt;/strong&gt; Filers can create company-specific extension tags instead of using a standard one, which is valid XBRL but invisible to anyone matching on a fixed tag list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a data error. It's just how the taxonomy and filer behavior evolve. But if your pipeline hardcodes "revenue = tag X," you will silently lose or corrupt history the moment a company switches tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this breaks naive datasets
&lt;/h2&gt;

&lt;p&gt;The common failure mode looks like this: a script pulls &lt;code&gt;Revenues&lt;/code&gt; for every 10-K, going back as far as the filer used that tag. The moment the company switches to &lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt;, the naive pull sees a gap — the concept looks like it disappeared. Depending on how the downstream code handles missing values, you get one of three quiet failures:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A false gap in the time series (treated as no revenue reported).&lt;/li&gt;
&lt;li&gt;A forward-fill of the last known value, understating growth or flatlining a metric that actually changed.&lt;/li&gt;
&lt;li&gt;A join against the wrong tag entirely, if a fallback rule grabs a &lt;em&gt;different&lt;/em&gt; line item that happens to exist (e.g., falling back to &lt;code&gt;SalesRevenueGoodsNet&lt;/code&gt; and picking up only product revenue, not total revenue).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are worse than an explicit "no data," because they don't look like errors — they look like real numbers. That's the trap: tag drift doesn't crash your pipeline, it silently biases it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How synonym-tag resolution actually works
&lt;/h2&gt;

&lt;p&gt;The fix is conceptually simple but tedious to do correctly: instead of mapping one concept to one tag, you maintain a &lt;strong&gt;synonym set&lt;/strong&gt; of tags per concept and resolve among the candidates present in each filing, rather than baking in a single tag at ingest time.&lt;/p&gt;

&lt;p&gt;In practice, for Tradevo Data that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each of our seven tracked concepts (Revenue, NetIncome, Assets, StockholdersEquity, OperatingCashFlow, EPSDiluted, DilutedShares) maps to a list of known US GAAP tags that filers have used for that concept, not a single tag.&lt;/li&gt;
&lt;li&gt;When a filing is parsed, the synonym tags present in that same filing are compared rather than ranked. Every candidate on the list is intended to be a consolidated total, so when two of them disagree materially the larger is the total and the smaller is a component of it, and the larger wins. The tag that actually resolved is recorded on the row, so the mapping is auditable rather than a black box. We used to apply a fixed priority order, and it shipped a 10× error on General Mills — the &lt;a href="https://tradevodata.com/blog/we-shipped-a-10x-error?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=xbrl-tags-for-fundamentals-explained" rel="noopener noreferrer"&gt;post-mortem&lt;/a&gt; explains why no fixed ordering gets MetLife and General Mills right at the same time.&lt;/li&gt;
&lt;li&gt;We track &lt;code&gt;first_filed&lt;/code&gt; (when the value became public) separately from &lt;code&gt;latest_value&lt;/code&gt; (the current, possibly amended figure), and flag rows as &lt;code&gt;restated&lt;/code&gt; when a same-tag revision moves the value by more than 0.5%, including amendments. That's how the dataset ends up with 18,723 labeled restatements — those are tag-consistent revisions, not tag-switch artifacts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qa_status&lt;/code&gt; on each row exists specifically so a switch that looks suspicious (e.g., a jump coinciding with a tag change) is visible to whoever is using the data, rather than silently smoothed over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full mapping logic and the reasoning behind it — including which tags we treat as synonyms for each concept and why — is public in the methodology alongside the free sample, not hidden behind the paid API. If you want to see exactly how a specific company's revenue tag changed over time, that's the place to check it yourself: github.com/christianpichichero-max/pit-fundamentals (3,280 rows across 40 companies, full methodology, no signup).&lt;/p&gt;

&lt;p&gt;For the point-in-time angle specifically — why &lt;code&gt;first_filed&lt;/code&gt; matters independently of tag resolution — see &lt;a href="https://tradevodata.com/blog/point-in-time-fundamentals-data?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=xbrl-tags-for-fundamentals-explained" rel="noopener noreferrer"&gt;/blog/point-in-time-fundamentals-data&lt;/a&gt; and &lt;a href="https://tradevodata.com/blog/lookahead-bias-fundamental-backtests?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=xbrl-tags-for-fundamentals-explained" rel="noopener noreferrer"&gt;/blog/lookahead-bias-fundamental-backtests&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fair comparison of your options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Handles tag drift?&lt;/th&gt;
&lt;th&gt;Point-in-time (&lt;code&gt;first_filed&lt;/code&gt;)?&lt;/th&gt;
&lt;th&gt;Frequency&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw SEC EDGAR + your own scraper&lt;/td&gt;
&lt;td&gt;Only if you build synonym mapping yourself&lt;/td&gt;
&lt;td&gt;Only if you build it (EDGAR gives you filing dates, not a PIT API)&lt;/td&gt;
&lt;td&gt;Whatever you implement&lt;/td&gt;
&lt;td&gt;Free (your engineering time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tradevo Data&lt;/td&gt;
&lt;td&gt;Yes, synonym sets per concept, documented in the free methodology&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;first_filed&lt;/code&gt; + &lt;code&gt;original_value&lt;/code&gt; on every row&lt;/td&gt;
&lt;td&gt;Annual only (10-K / 10-K/A); quarterly is on the roadmap, not available today&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharadar / Tiingo / QuantConnect fundamentals&lt;/td&gt;
&lt;td&gt;Not independently verified by us — these are established, mature vendors, so check their docs for specifics&lt;/td&gt;
&lt;td&gt;Varies by product; check each vendor's docs&lt;/td&gt;
&lt;td&gt;Varies by product; check each vendor's docs&lt;/td&gt;
&lt;td&gt;See their pricing pages: &lt;a href="https://data.nasdaq.com/publishers/SHARADAR" rel="noopener noreferrer"&gt;Sharadar via Nasdaq Data Link&lt;/a&gt;, &lt;a href="https://www.tiingo.com/" rel="noopener noreferrer"&gt;Tiingo&lt;/a&gt;, &lt;a href="https://www.quantconnect.com/datasets" rel="noopener noreferrer"&gt;QuantConnect&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This isn't an attempt to declare a winner. We haven't audited Sharadar's, Tiingo's, or QuantConnect's internal tag-resolution logic, so we're not claiming to know how they handle it — only that they're credible, established sources worth comparing against. If price or feature fit matters to your decision, their pricing pages will tell you what's on offer; we're not going to guess a number for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the established players are the better choice
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about what you actually need before defaulting to the cheaper option:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you need &lt;strong&gt;quarterly fundamentals&lt;/strong&gt;, Tradevo Data doesn't have them yet (roadmap only) — an established vendor that already offers quarterly data is the right call today.&lt;/li&gt;
&lt;li&gt;If you need &lt;strong&gt;non-US markets&lt;/strong&gt;, more historical depth than 12 fiscal years, or a broader concept set beyond our seven, a larger vendor's coverage is likely to fit better.&lt;/li&gt;
&lt;li&gt;If you need a &lt;strong&gt;track record&lt;/strong&gt; — a data provider that's been used in production research for years, with support SLAs and a sales team you can talk to — that's a real advantage of established players over a $49/mo budget tool run by a small team.&lt;/li&gt;
&lt;li&gt;If Parquet or other formats matter to your pipeline today (not roadmap, today), check whether an established vendor already ships it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We'd rather point you to the right tool than pretend we're the right tool for every use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should build it yourself
&lt;/h2&gt;

&lt;p&gt;Building your own EDGAR XBRL parser is a legitimate choice, not just a fallback for people who can't afford data. It's the right call if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need concepts or tags outside the seven we track (segment data, non-GAAP reconciliations, footnote disclosures).&lt;/li&gt;
&lt;li&gt;You need international filers or non-EDGAR sources.&lt;/li&gt;
&lt;li&gt;You have engineering time to spend and want full control over the synonym-resolution rules rather than trusting someone else's judgment calls.&lt;/li&gt;
&lt;li&gt;Your research only needs a handful of companies and a few concepts — at that scale, hand-checking tag switches against the actual 10-K filings is faster than integrating a new data source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where a vendor (any vendor, not just us) earns its cost is in the tedious part: tracking taxonomy changes across thousands of filers over many years, catching restatements, and doing it consistently so you're not re-solving the same tag-drift problem every time the FASB updates the taxonomy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it before you pay for it
&lt;/h2&gt;

&lt;p&gt;The free sample has the same tag-resolution logic as the paid dataset, just scoped to 40 companies and 3,280 rows — enough to inspect a real tag switch yourself and decide if the approach holds up: github.com/christianpichichero-max/pit-fundamentals.&lt;/p&gt;

&lt;p&gt;If it does and you need the full 5,189-company, 312,751-row universe with a server-side &lt;code&gt;as_of&lt;/code&gt; query, the API and bulk download (&lt;code&gt;/v1/download&lt;/code&gt;, &lt;code&gt;/v1/snapshot?as_of&lt;/code&gt;) are $49/mo, cancel anytime: &lt;a href="https://tradevodata.com/?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=xbrl-tags-for-fundamentals-explained#pricing" rel="noopener noreferrer"&gt;tradevodata.com/?ref=blog&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Not investment advice. This dataset describes what was publicly filed and when — it makes no claims about future performance. Verify competitor pricing and feature claims yourself on their websites before deciding.&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
    <item>
      <title>SEC 10-K Filing Deadline Data: The Structural Reason Fundamentals Lag Weeks Behind</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Wed, 12 Aug 2026 16:30:51 +0000</pubDate>
      <link>https://dev.to/tradevodata/sec-10-k-filing-deadline-data-the-structural-reason-fundamentals-lag-weeks-behind-2h36</link>
      <guid>https://dev.to/tradevodata/sec-10-k-filing-deadline-data-the-structural-reason-fundamentals-lag-weeks-behind-2h36</guid>
      <description>&lt;p&gt;If you've ever wondered why a company's "Q4 numbers" aren't publicly known the day the fiscal year ends, the answer isn't vendor laziness or data-pipeline delay. It's the SEC's own filing calendar. The deadlines are public, stable, and have been in place for years — and they're the single biggest structural reason any honest fundamentals dataset has a gap between period-end and public availability.&lt;/p&gt;

&lt;p&gt;This matters for anyone backtesting on fundamentals: if your data model assumes a number was known the moment the quarter closed, you're not modeling reality. You're modeling a fantasy calendar that doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SEC's 10-K Deadlines Are Public and Fixed by Filer Class
&lt;/h2&gt;

&lt;p&gt;Under SEC rules (Exchange Act Rule 12b-2 and related Regulation S-K guidance), the deadline to file an annual report (Form 10-K) after fiscal year-end depends on the company's filer category, which is based on public float:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Filer Category&lt;/th&gt;
&lt;th&gt;Public Float Threshold&lt;/th&gt;
&lt;th&gt;10-K Deadline After Fiscal Year-End&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Large Accelerated Filer&lt;/td&gt;
&lt;td&gt;$700M or more&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accelerated Filer&lt;/td&gt;
&lt;td&gt;$75M to $700M&lt;/td&gt;
&lt;td&gt;75 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-Accelerated Filer&lt;/td&gt;
&lt;td&gt;Under $75M&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These thresholds and deadlines are public record, not proprietary knowledge. Any company can and does file earlier than its deadline — but a meaningful share file close to it, because compiling audited financials, running the audit committee process, and drafting MD&amp;amp;A takes real time. The deadline is a ceiling companies work toward, not a floor they clear early by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Deadline Creates a Point-in-Time Gap
&lt;/h2&gt;

&lt;p&gt;Here's the mechanical consequence: a fiscal year that ends December 31 might not have its 10-K publicly filed until late February (large accelerated), mid-March (accelerated), or the end of March (non-accelerated). That's a 60-to-90-day window baked into the regulatory structure, before you even account for filers who use extensions (Form 12b-25) or file 10-K/A amendments later.&lt;/p&gt;

&lt;p&gt;So when you see a "FY2023 Revenue" figure, the honest question is: known to the public &lt;em&gt;when&lt;/em&gt;? Not "as of fiscal year-end" — as of the date the 10-K (or amendment) actually hit EDGAR. Any dataset that timestamps fundamentals by fiscal period end instead of filing date is implicitly assuming zero-day disclosure, which the SEC's own deadlines show doesn't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Filing Date to "First Known" Value
&lt;/h2&gt;

&lt;p&gt;This is why point-in-time (PIT) datasets track more than just the number. Tradevo Data's schema, sourced directly from SEC EDGAR (public domain), records for each row:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;first_filed&lt;/strong&gt; — the date the value first became public&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;original_value&lt;/strong&gt; — the first-reported figure (the point-in-time-safe one)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;latest_value&lt;/strong&gt; — the current, possibly-restated figure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;restated flag&lt;/strong&gt; — set when a later filing changes the same tag by more than 0.5%, including amendments&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;qa_status&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Across the dataset, 18,723 restatements carry the restated flag — a reminder that "the number" for a given fiscal year isn't fixed at first filing; it can move, and a PIT-correct backtest needs to use what was known on a given date, not what we know now with hindsight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Actually Measured (Scoped Honestly)
&lt;/h2&gt;

&lt;p&gt;On the free 40-company sample's reliable-filing rows, the measured lookahead — the gap between fiscal period end and first_filed — averaged 43.4 days, with a maximum of 61 days. That number is specific to the 40-company sample and should not be quoted as a property of the full 5,189-company, 312,751-row dataset, where the same gap is wider — mean 66 days, median 60, p90 90 on reliable-filing rows — because large caps are the fastest filers. We're not going to blur that line for a cleaner headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Bigger or Quarterly-Aware Vendor Is the Better Choice
&lt;/h2&gt;

&lt;p&gt;In the interest of not overselling: if your strategy needs quarterly (10-Q) fundamentals, non-US equities, Parquet delivery, or a vendor with a longer operating track record and broader SLA guarantees, providers like Sharadar, Tiingo, or QuantConnect are worth evaluating — see their pricing pages directly, since we won't quote competitor prices here. QuantConnect in particular is worth a look if you want fundamentals data pre-integrated into a backtesting engine rather than delivered as a standalone feed. Tradevo Data is annual-only (10-K plus 10-K/A) for now, US-only, and delivered as JSON/CSV — quarterly and Parquet are roadmap items, not shipped features.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Build It Yourself from EDGAR
&lt;/h2&gt;

&lt;p&gt;EDGAR's raw filings and XBRL data are free and public. If you only need a handful of tickers, have engineering time to spare, and want full control over parsing logic, building your own extractor is a legitimate option. The tradeoffs to budget for: XBRL taxonomy changes across years, handling 10-K/A amendments correctly so you don't silently overwrite original_value with latest_value, and building the first_filed logic so your backtest can't see a number before it existed. None of this is exotic, but it's also not a weekend project if you want it PIT-correct rather than just "mostly right."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Tradevo Data Fits
&lt;/h2&gt;

&lt;p&gt;Tradevo Data exists for the middle case: you want SEC-sourced, point-in-time-correct annual fundamentals — Revenue, NetIncome, Assets, StockholdersEquity, OperatingCashFlow, EPSDiluted, DilutedShares, up to 12 fiscal years — across 5,189 US companies (312,751 rows), without building the EDGAR pipeline yourself or paying for a full institutional-grade platform. The $49/mo plan includes the full bulk &lt;code&gt;/v1/download&lt;/code&gt;, the whole-universe &lt;code&gt;/v1/snapshot?as_of&lt;/code&gt; cross-section, and the &lt;code&gt;/v1/fundamentals?ticker&amp;amp;as_of&lt;/code&gt; JSON endpoint — 5,000 requests/day and 2,500 distinct tickers/day; use &lt;code&gt;/v1/snapshot&lt;/code&gt; or &lt;code&gt;/v1/download&lt;/code&gt; for cross-sections — with a Stripe checkout that issues a key instantly. Cancel anytime. Docs are at /docs.&lt;/p&gt;

&lt;p&gt;This is a budget tier of research-grade PIT data — not a claim to be the only cheap option, and not a promise about what it will do for your returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try Before You Subscribe
&lt;/h2&gt;

&lt;p&gt;The free sample — 40 companies, 3,280 rows, full methodology, no signup — is on GitHub: github.com/christianpichichero-max/pit-fundamentals. It's the same schema, same first_filed/original_value/restated logic, just smaller. If the filing-deadline mechanics above are new to you, the deeper walkthroughs are at /blog/lookahead-bias-fundamental-backtests and /blog/point-in-time-fundamentals-data.&lt;/p&gt;

&lt;p&gt;If the schema fits your backtests, the full dataset is at tradevodata.com/?ref=blog. If you're still comparing PIT vendors, &lt;a href="https://tradevodata.com/alternatives?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=sec-10-k-filing-deadlines-and-point-in-time-data" rel="noopener noreferrer"&gt;our comparison index&lt;/a&gt; lays out the options side by side.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not investment advice; verify competitor pricing yourself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
    <item>
      <title>We Shipped a 10x Error on General Mills. Here's the Bug, and the Check That Would Have Caught It.</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Tue, 04 Aug 2026 19:38:46 +0000</pubDate>
      <link>https://dev.to/tradevodata/we-shipped-a-10x-error-on-general-mills-heres-the-bug-and-the-check-that-would-have-caught-it-5gml</link>
      <guid>https://dev.to/tradevodata/we-shipped-a-10x-error-on-general-mills-heres-the-bug-and-the-check-that-would-have-caught-it-5gml</guid>
      <description>&lt;p&gt;On 2026-08-03 our API was serving General Mills' FY2024 revenue as &lt;strong&gt;$2.038 billion&lt;/strong&gt;. The real figure is &lt;strong&gt;$19.857 billion&lt;/strong&gt;. We were wrong by an order of magnitude on an S&amp;amp;P 500 company, for weeks, and every automated check we had said the data was fine.&lt;/p&gt;

&lt;p&gt;This is the post-mortem. It is worth reading if you build anything on SEC XBRL, because the root cause is not exotic — it is the most natural way to write the code, and it is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the filing actually says
&lt;/h2&gt;

&lt;p&gt;General Mills tags two different numbers in the same 10-K for the same period:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;XBRL element&lt;/th&gt;
&lt;th&gt;FY2024 value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Revenues&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$2,037,800,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$19,857,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both come from SEC's own &lt;code&gt;companyconcept&lt;/code&gt; API. Neither is a mistake by the filer. &lt;code&gt;Revenues&lt;/code&gt; here is a component; the consolidated top line lives under the ASC-606 element.&lt;/p&gt;

&lt;p&gt;We picked &lt;code&gt;Revenues&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we picked the wrong one, and why it was defensible
&lt;/h2&gt;

&lt;p&gt;Our tag resolution used a static priority list, with &lt;code&gt;Revenues&lt;/code&gt; ranked first. That ordering was itself a fix. Earlier, we had ASC-606 contract revenue ranked above &lt;code&gt;Revenues&lt;/code&gt;, and it produced this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MetLife FY2024&lt;/strong&gt; — served at $2.2B against a true $71.0B&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capital One&lt;/strong&gt; — 85% low&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AvalonBay&lt;/strong&gt; — 99.8% low, for eight consecutive years&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For insurers, REITs and card issuers, contract revenue is a small slice and &lt;code&gt;Revenues&lt;/code&gt; is the real total. So we promoted &lt;code&gt;Revenues&lt;/code&gt;, verified MetLife came out right, and shipped.&lt;/p&gt;

&lt;p&gt;That fix was correct for MetLife and catastrophically wrong for General Mills — because &lt;strong&gt;there is no single XBRL element that is the consolidated top line for every filer&lt;/strong&gt;. &lt;code&gt;Revenues&lt;/code&gt; is the total for an insurer and a minor component for a consumer-goods company. Any fixed ordering serves one and betrays the other.&lt;/p&gt;

&lt;p&gt;We had traded one class of error for another, and we could not see it, because we validated the fix against the company that motivated it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing caught it
&lt;/h2&gt;

&lt;p&gt;This is the part worth generalising. We had four automated gates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a claims check comparing published site copy to the database&lt;/li&gt;
&lt;li&gt;a minimum-row-count guard against truncated builds&lt;/li&gt;
&lt;li&gt;endpoint smoke tests&lt;/li&gt;
&lt;li&gt;an invariant scan for internal consistency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of them asks &lt;em&gt;"is this data consistent with itself?"&lt;/em&gt; None asks &lt;em&gt;"is this number correct?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;$2.038 billion is internally consistent. It is correctly dated, plausibly sized for a public company, sourced from a real XBRL element in a real filing, and it passes every range check. It looks exactly like money. There is no internal signal that separates it from the right answer.&lt;/p&gt;

&lt;p&gt;We even had a flag firing. The row carried &lt;code&gt;qa_status = FLAG:ambiguous_tag&lt;/code&gt;, which our engine sets when another element in the same filing reports materially more. &lt;strong&gt;The system detected the ambiguity and published the smaller number anyway.&lt;/strong&gt; Detecting is not deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug, which was worse
&lt;/h2&gt;

&lt;p&gt;While fixing the first, we found that our bank-revenue path had been silently broken for months.&lt;/p&gt;

&lt;p&gt;Banks frequently tag no single total-revenue element, so we synthesise one: net interest income plus noninterest income, the standard definition. That code computed Fifth Third's FY2023 revenue correctly at &lt;strong&gt;$8.708B&lt;/strong&gt;. Then it stamped the value with the wrong filing date — &lt;code&gt;2026-02-24&lt;/code&gt; instead of &lt;code&gt;2024-02-27&lt;/code&gt; — because of 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="n"&gt;nii&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;annual_points_for_tag&lt;/span&gt;&lt;span class="p"&gt;(...)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dict comprehension keeps the &lt;strong&gt;last&lt;/strong&gt; entry per key. SEC reports the same period across many filings, so the last one is the most recent re-filing. The synthetic bank total therefore always looked like it was filed years late.&lt;/p&gt;

&lt;p&gt;Our selection logic picks the &lt;strong&gt;earliest-filed&lt;/strong&gt; candidate, on purpose — that is the point-in-time discipline. So the correct bank total always arrived "late" and lost to the fee-income tag it existed to replace. &lt;strong&gt;The fix computed the right answer and threw it away, every single time.&lt;/strong&gt; Fifth Third kept publishing $0.577B against a true $8.708B.&lt;/p&gt;

&lt;p&gt;One line, and it silently reversed the outcome of a fix everyone believed had shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that would have caught both
&lt;/h2&gt;

&lt;p&gt;The missing piece was not more tests. It was one test of a different &lt;em&gt;kind&lt;/em&gt;: compare the data to something outside itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/verify-against-sec.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a sample of served values, it asks SEC's &lt;code&gt;companyconcept&lt;/code&gt; API for every candidate revenue element the filer reported for that exact period, and asserts that what we serve is the consolidated total rather than a component of it. It deliberately does &lt;strong&gt;not&lt;/strong&gt; reuse our tag-selection logic — otherwise it would reimplement the bug and agree with itself.&lt;/p&gt;

&lt;p&gt;Run against the broken data, it printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WRONG GIS FY2024: serving $2.038B vs SEC $19.857B — 10.3% of the total
WRONG GIS FY2023: serving $1.957B vs SEC $20.094B —  9.7% of the total
WRONG PG  FY2014: serving $29.400B vs SEC $83.062B — 35.4% of the total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It now runs on every data refresh and fails the build. A gate that cannot catch its own motivating case is decoration, so we proved it fired before shipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix
&lt;/h2&gt;

&lt;p&gt;We stopped ranking elements and started comparing them. Every tag in our candidate list is &lt;em&gt;intended&lt;/em&gt; to be a consolidated total — the gross-overstating ones are explicitly excluded — so when two candidates in the same filing disagree materially, the larger is the total and the smaller is a component of it. That rule gets MetLife and General Mills right simultaneously, which no fixed ordering can.&lt;/p&gt;

&lt;p&gt;The regression test keeps them in the same file, deliberately, because they pull in opposite directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cost, and what changed
&lt;/h2&gt;

&lt;p&gt;Wrong values reached the paid API and the free sample. P&amp;amp;G FY2014 shipped at $29.4B against a true $83.1B in the public CC0 file — the one our own pricing page tells prospects to verify against.&lt;/p&gt;

&lt;p&gt;All of it is corrected now: General Mills at $19.857B, P&amp;amp;G at $83.062B, Fifth Third at $8.708B, M&amp;amp;T at $9.279B, MetLife still right at $70.986B. Roughly 320 values across all seven concepts have since been checked against SEC's API with zero mismatches.&lt;/p&gt;

&lt;p&gt;Three things we would tell anyone building on XBRL:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;There is no universal top-line element.&lt;/strong&gt; Any static tag priority is wrong for some filer. Compare magnitudes within the filing instead of trusting an order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detecting an ambiguity is not resolving it.&lt;/strong&gt; We flagged the row and published it anyway. If your system knows something is uncertain, decide what to do about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-consistency is not correctness.&lt;/strong&gt; Every gate we had compared the data to itself. Wrong numbers pass those effortlessly, because wrong numbers are usually well-formed. Something in your pipeline has to compare against the outside world.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We would rather publish this than have you find it. The free sample is 40 large caps, 3,280 point-in-time rows, CC0, no signup — &lt;a href="https://tradevodata.com/go/github-sample?cta_location=blog-we-shipped-a-10x-error&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=we-shipped-a-10x-error" rel="noopener noreferrer"&gt;github.com/christianpichichero-max/pit-fundamentals&lt;/a&gt; — and the methodology is public specifically so it can be attacked.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not investment advice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
    <item>
      <title>As-Reported vs Restated Financial Data: Why the Difference Matters for Backtesting</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Wed, 29 Jul 2026 16:54:14 +0000</pubDate>
      <link>https://dev.to/tradevodata/as-reported-vs-restated-financial-data-why-the-difference-matters-for-backtesting-1big</link>
      <guid>https://dev.to/tradevodata/as-reported-vs-restated-financial-data-why-the-difference-matters-for-backtesting-1big</guid>
      <description>&lt;p&gt;Every financial statement exists in at least two versions: the number a company first told the market, and whatever number ends up in today's database after amendments, reclassifications, and restatements. If you're building a fundamental backtest, confusing the two is one of the quieter ways to inflate a strategy's historical returns — quieter than survivorship bias, but just as real.&lt;/p&gt;

&lt;p&gt;This article works through what "as-reported" and "restated" actually mean, why a backtest that uses the wrong one is training on information that didn't exist yet, and how a point-in-time (PIT) dataset like Tradevo Data represents both so you can choose deliberately instead of by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  As-reported: what the market actually saw
&lt;/h2&gt;

&lt;p&gt;As-reported (also called original or first-filed) is the value disclosed in the filing that made it public — for us, a 10-K (or 10-K/A). It's stamped with a date: the moment that number entered the public record via SEC EDGAR. Before that date, no market participant could have known it. That's the entire premise of point-in-time data: every fact carries a timestamp for when it became knowable, not just what the fact eventually became.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restated: what the number is today
&lt;/h2&gt;

&lt;p&gt;Restated (or latest) is the current, revised figure after any subsequent 10-K/A amendments or comparative restatements in later filings. Companies restate for real reasons — accounting errors, standard changes, reclassifications, M&amp;amp;A-driven reallocations. The restated number is often &lt;em&gt;more accurate&lt;/em&gt; as a historical record of what actually happened. It is not, however, what a trader or analyst could have known on the date the original filing hit the tape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this becomes lookahead bias in a backtest
&lt;/h2&gt;

&lt;p&gt;A backtest that joins today's restated fundamentals to historical prices is implicitly assuming the market had access to information it didn't have yet. If a company later restates revenue upward and your backtest uses the restated figure as of the original filing date, any strategy conditioned on that revenue print will look smarter in hindsight than it could have been in real time. The effect is usually small per company, but restatements aren't rare — our dataset flags 18,723 restatements across 312,751 point-in-time rows, which suggests this is a systematic feature of financial data, not an edge case you can ignore.&lt;/p&gt;

&lt;p&gt;The fix is mechanical, not statistical: for any date you're backtesting against, use only the values that had a &lt;code&gt;first_filed&lt;/code&gt; date on or before that date, and use the value as it was first reported — not as it later became.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete example
&lt;/h2&gt;

&lt;p&gt;Suppose a company files its original 10-K reporting revenue for fiscal year N. Some quarters later, it files a 10-K/A that revises that same fiscal year's revenue — the change exceeds the 0.5% same-tag threshold we use to flag a meaningful restatement (not just a rounding or presentation tweak).&lt;/p&gt;

&lt;p&gt;In Tradevo Data's schema, that single revenue fact for fiscal year N produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;first_filed&lt;/code&gt;: the date the original 10-K became public&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;original_value&lt;/code&gt;: the revenue as first reported — this is what you join against historical dates for a PIT-safe backtest&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;latest_value&lt;/code&gt;: the revenue as currently stated, after the 10-K/A&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;restated&lt;/code&gt;: &lt;code&gt;true&lt;/code&gt;, because the revision exceeded the 0.5% same-tag threshold&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qa_status&lt;/code&gt;: our internal check on whether the row parsed and reconciled cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your backtest logic is "as of date X, what did we know about this company's revenue," you filter to rows where &lt;code&gt;first_filed &amp;lt;= X&lt;/code&gt; and read &lt;code&gt;original_value&lt;/code&gt;. If your logic is "what actually happened to this company's revenue, full stop," you read &lt;code&gt;latest_value&lt;/code&gt;. Same row, two different questions, two different answers — and picking the wrong one for the wrong question is exactly the bug PIT data exists to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  As-reported vs restated at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;As-reported (&lt;code&gt;original_value&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Restated (&lt;code&gt;latest_value&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Answers the question&lt;/td&gt;
&lt;td&gt;What did the market know on this date?&lt;/td&gt;
&lt;td&gt;What is the corrected historical fact?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correct use&lt;/td&gt;
&lt;td&gt;Backtesting, PIT research, signal generation&lt;/td&gt;
&lt;td&gt;Fundamental analysis of "true" historical performance, post-mortems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk if misused&lt;/td&gt;
&lt;td&gt;None if used correctly for its purpose&lt;/td&gt;
&lt;td&gt;Lookahead bias if fed into a historical backtest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changes over time&lt;/td&gt;
&lt;td&gt;No — frozen at first filing&lt;/td&gt;
&lt;td&gt;Yes — updated as amendments are filed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it lives in our schema&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;original_value&lt;/code&gt; + &lt;code&gt;first_filed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;latest_value&lt;/code&gt; + &lt;code&gt;restated&lt;/code&gt; flag&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When restated data is genuinely the better choice
&lt;/h2&gt;

&lt;p&gt;This isn't a case for always preferring as-reported. If you're doing retrospective fundamental analysis — "how did this company's margins actually trend over a decade" — the restated figure is usually the more honest answer, since it reflects corrected accounting rather than a since-fixed error. Restated data is also the right call for training long-horizon valuation models where you're not simulating point-in-time decisions, or for auditing how much a company's numbers have moved since first disclosure. The mistake isn't using restated data; it's using it inside a backtest that pretends to be historical.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to build it yourself — or use someone else's dataset
&lt;/h2&gt;

&lt;p&gt;Building a PIT pipeline from raw EDGAR filings is entirely doable, and for some teams it's the right call: you get full control over parsing logic, tag mapping, and restatement detection thresholds, and you're not dependent on anyone's refresh schedule. The tradeoffs are real, though — reconciling XBRL tags across amendments, tracking every 10-K/A, and validating first-filed dates against actual EDGAR timestamps is unglamorous, ongoing work, and it's easy to introduce silent bugs (like accidentally picking up a restated value) that only surface when your backtest results look suspiciously good.&lt;/p&gt;

&lt;p&gt;If you need quarterly data, non-US markets, or Parquet natively today, other providers are worth evaluating — Sharadar, Tiingo, and QuantConnect all offer credible fundamentals products; see their pricing pages directly, since we won't quote competitor prices here and terms change. Tradevo Data is annual-only (10-K/10-K/A), US-only, and CSV-first (Parquet is on the roadmap), so if your use case needs more than that today, one of those may be the better fit right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Tradevo Data fits
&lt;/h2&gt;

&lt;p&gt;What we do offer: 5,189 US companies, 312,751 point-in-time rows across 7 core concepts, up to 12 years of history, with every value carrying &lt;code&gt;first_filed&lt;/code&gt;, &lt;code&gt;original_value&lt;/code&gt;, &lt;code&gt;latest_value&lt;/code&gt;, and a &lt;code&gt;restated&lt;/code&gt; flag so you never have to guess which one you're looking at. On our 40-company free sample, the gap between fiscal period end and &lt;code&gt;first_filed&lt;/code&gt; averaged 43.4 days (max 61 days) on reliable-filing rows. Across the full universe the same gap is wider — mean 66 days, median 60, p90 90 on reliable-filing rows — because large caps are the fastest filers. The sample figure is the friendly end, not a property of the whole dataset.&lt;/p&gt;

&lt;p&gt;The full methodology and 3,280 sample rows are free, no signup: &lt;a href="https://tradevodata.com/go/github-sample?cta_location=blog-as-reported-vs-restated-fundamentals&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=as-reported-vs-restated-fundamentals" rel="noopener noreferrer"&gt;github.com/christianpichichero-max/pit-fundamentals&lt;/a&gt;. If it fits your workflow, the full dataset and API are &lt;a href="https://tradevodata.com/?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=as-reported-vs-restated-fundamentals#pricing" rel="noopener noreferrer"&gt;$49/mo at tradevodata.com&lt;/a&gt; — bulk download and whole-universe snapshots included, cancel anytime. For more on how lookahead bias creeps into backtests, see &lt;a href="https://tradevodata.com/blog/lookahead-bias-fundamental-backtests?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=as-reported-vs-restated-fundamentals" rel="noopener noreferrer"&gt;/blog/lookahead-bias-fundamental-backtests&lt;/a&gt; and &lt;a href="https://tradevodata.com/blog/point-in-time-fundamentals-data?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=as-reported-vs-restated-fundamentals" rel="noopener noreferrer"&gt;/blog/point-in-time-fundamentals-data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not investment advice; verify competitor pricing yourself on their respective sites.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
    <item>
      <title>How to Detect Lookahead Bias in a Backtest: A Practical Checklist</title>
      <dc:creator>Christian Pichichero</dc:creator>
      <pubDate>Wed, 08 Jul 2026 17:03:29 +0000</pubDate>
      <link>https://dev.to/tradevodata/how-to-detect-lookahead-bias-in-a-backtest-a-practical-checklist-2cb6</link>
      <guid>https://dev.to/tradevodata/how-to-detect-lookahead-bias-in-a-backtest-a-practical-checklist-2cb6</guid>
      <description>&lt;p&gt;Lookahead bias in a fundamentals backtest almost never announces itself. It shows up as a backtest that looks unusually good, and then a live strategy that quietly stops working. The mechanism is boring: your join used a date the data wasn't actually public yet. This article is a checklist for finding that, not a pitch for a particular dataset — though we'll use our own free sample to show working code, because it's the one we can show you column-by-column without asking anything of you.&lt;/p&gt;

&lt;p&gt;This is not investment advice. Nothing here promises a better-performing backtest — only a more honest one, with fewer joins that leak future information into the past.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 1: Your signal times earnings suspiciously well
&lt;/h2&gt;

&lt;p&gt;If a fundamentals-driven signal enters positions right before earnings-driven price moves with better-than-random timing, check what date you used to decide the fundamental was "known." A common bug: joining on fiscal period end date (e.g., Q4 2019 = 2019-12-31) instead of the date the 10-K was actually filed and made public. Every company reports its fiscal year end weeks to months before the filing exists. If your backtest thinks Q4 data is available on the period end date, it's trading on the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 2: Live performance decays right after paper trading ends
&lt;/h2&gt;

&lt;p&gt;This is the classic tell. A strategy backtests clean, goes live (or into a genuinely walk-forward paper test), and the edge shrinks or disappears. If the strategy relies on fundamentals and nothing else changed — no regime shift, no capacity issue — lookahead bias in the historical joins is the first thing to rule out, because live trading is the one environment where lookahead is structurally impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 3: Performance is fragile to a few outlier dates
&lt;/h2&gt;

&lt;p&gt;Run your equity curve breakdown by trade. If a disproportionate share of PnL comes from trades placed in a narrow window around known reporting seasons, and those specific trades have unusually strong hit rates, look at whether the fundamental value used in the join was restated later and your backtest is silently using the &lt;em&gt;restated&lt;/em&gt; number instead of what was originally reported.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Join-Date Audit
&lt;/h2&gt;

&lt;p&gt;The fastest way to check: pull your actual join logic and ask, for every fundamental value, "what date did my code think this became true?" Then compare that to the date it was actually filed. Two dates get confused constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fiscal period end&lt;/strong&gt; — when the reporting period closed (always in the past relative to filing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First filed date&lt;/strong&gt; — when the 10-K (or 10-K/A) hit EDGAR and became public&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your pipeline uses the former where it should use the latter, you have a lookahead bug baked into every single row, sized to the actual filing lag for that company and quarter.&lt;/p&gt;

&lt;h3&gt;
  
  
  A pandas check using first_filed
&lt;/h3&gt;

&lt;p&gt;Our &lt;a href="https://tradevodata.com/go/github-sample?cta_location=blog-how-to-detect-lookahead-bias-in-a-backtest&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=how-to-detect-lookahead-bias-in-a-backtest" rel="noopener noreferrer"&gt;free PIT sample&lt;/a&gt; (40 companies, 3,280 rows, no signup) includes a &lt;code&gt;first_filed&lt;/code&gt; column specifically so you can run this check yourself. Example:&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;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sample.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_dates&lt;/span&gt;&lt;span class="o"&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;first_filed&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;fiscal_period_end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_leakage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;as_of_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join_col&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fiscal_period_end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;as_of&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;as_of_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;naive_join&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;join_col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;# what a naive backtest would include
&lt;/span&gt;    &lt;span class="n"&gt;correct_join&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_filed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# what was actually public by as_of
&lt;/span&gt;    &lt;span class="n"&gt;leaked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;naive_join&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;naive_join&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correct_join&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&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;leaked&lt;/span&gt;

&lt;span class="n"&gt;leaked_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_leakage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-03-31&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leaked_rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows would be lookahead-leaked &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;if you joined on fiscal_period_end instead of first_filed&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;If &lt;code&gt;leaked_rows&lt;/code&gt; is non-empty for a realistic &lt;code&gt;as_of&lt;/code&gt; date, your naive join is including fundamentals before they existed publicly. Swap the join key to &lt;code&gt;first_filed&lt;/code&gt; (or whatever your data vendor's equivalent "became public" field is called) and rerun.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lag-Shift Test
&lt;/h2&gt;

&lt;p&gt;A second, independent check that doesn't require inspecting your join code line by line: shift your &lt;code&gt;as_of&lt;/code&gt; date backward by a fixed number of days across your entire backtest — say 30, 60, 90 days — and rerun. If performance is fairly stable under small shifts, that's a decent sign your logic isn't sitting right on a knife's edge of leaked information. If performance craters the moment you shift even slightly earlier, your original result was likely dependent on data being available earlier than it should have been. This is a blunt instrument, not proof, but it's cheap to run and catches a lot of accidental lookahead.&lt;/p&gt;

&lt;p&gt;For deeper background on why this matters mechanically, see &lt;a href="https://tradevodata.com/blog/lookahead-bias-fundamental-backtests?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=how-to-detect-lookahead-bias-in-a-backtest" rel="noopener noreferrer"&gt;lookahead bias in fundamental backtests&lt;/a&gt; and our overview of &lt;a href="https://tradevodata.com/blog/point-in-time-fundamentals-data?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=how-to-detect-lookahead-bias-in-a-backtest" rel="noopener noreferrer"&gt;point-in-time fundamentals&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing Your Options for Fixing It
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scrape EDGAR yourself, track filing dates&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Whatever you build&lt;/td&gt;
&lt;td&gt;Your time&lt;/td&gt;
&lt;td&gt;Teams needing full control, custom concepts, or non-US data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Research-grade PIT vendors (e.g. Sharadar, Tiingo, QuantConnect)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Broad, often quarterly + more concepts&lt;/td&gt;
&lt;td&gt;See their pricing pages&lt;/td&gt;
&lt;td&gt;Funds/teams needing quarterly data, longer history, or bulk delivery — this is genuinely where they win over us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tradevo Data&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;5,189 US companies, 7 concepts, annual only, up to 12 fiscal years&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;Individual quants who need PIT-safe &lt;em&gt;annual&lt;/em&gt; US fundamentals cheaply, via API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free PIT sample (GitHub)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;40 companies, 3,280 rows&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Prototyping the join logic and running the checks in this article before paying for anything&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We're not the only affordable option, and we're not claiming to be — the vendors above are credible and some cover more ground (quarterly data, longer history, bulk delivery). See their pricing pages directly since we won't quote numbers we don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Build It Yourself (or Use Someone Else)
&lt;/h2&gt;

&lt;p&gt;Being honest about where Tradevo Data isn't the right tool — and where the vendors above genuinely win:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need quarterly fundamentals.&lt;/strong&gt; We're annual-only (10-K + 10-K/A). Quarterly PIT logic is a roadmap item, not shipped. A vendor with quarterly coverage wins here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need non-US equities.&lt;/strong&gt; We're US-only, sourced from SEC EDGAR. A vendor with international coverage wins here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need more than 7 concepts&lt;/strong&gt;, or line items beyond Revenue, NetIncome, Assets, StockholdersEquity, OperatingCashFlow, EPSDiluted, and DilutedShares.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need Parquet files specifically&lt;/strong&gt; — bulk is included (a one-call gzipped-CSV download of the full dataset via &lt;code&gt;/v1/download&lt;/code&gt;, plus a whole-universe &lt;code&gt;/v1/snapshot&lt;/code&gt;), but the delivery format is CSV/JSON today; Parquet is on the roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're already comfortable parsing EDGAR filings directly&lt;/strong&gt; and tracking &lt;code&gt;first_filed&lt;/code&gt; yourself — that's a legitimate, free path if you have the engineering time, and it's exactly what our free sample's methodology documents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those apply and you just want a point-in-time-safe &lt;code&gt;first_filed&lt;/code&gt;/&lt;code&gt;original_value&lt;/code&gt; pair for US annual fundamentals without building the EDGAR pipeline yourself, that's the actual use case we built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Before You Pay for Anything
&lt;/h2&gt;

&lt;p&gt;Start with the &lt;a href="https://tradevodata.com/go/github-sample?cta_location=blog-how-to-detect-lookahead-bias-in-a-backtest&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=how-to-detect-lookahead-bias-in-a-backtest" rel="noopener noreferrer"&gt;free sample&lt;/a&gt; — 40 companies, full methodology, no signup — and run the join-date audit and lag-shift test above on your own logic. If it holds up and you want the full 5,189-company, 312,751-row dataset with &lt;code&gt;first_filed&lt;/code&gt;, &lt;code&gt;original_value&lt;/code&gt;, &lt;code&gt;latest_value&lt;/code&gt;, and restatement flags via API, it's &lt;a href="https://tradevodata.com/?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=how-to-detect-lookahead-bias-in-a-backtest#pricing" rel="noopener noreferrer"&gt;$49/mo at Tradevo Data&lt;/a&gt;, instant key after checkout, cancel anytime, docs at &lt;code&gt;/docs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you're actively comparing PIT data providers, &lt;a href="https://tradevodata.com/alternatives?ref=blog&amp;amp;utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=syndicate&amp;amp;utm_content=how-to-detect-lookahead-bias-in-a-backtest" rel="noopener noreferrer"&gt;our comparison index&lt;/a&gt; lays out where we fit and where we don't.&lt;/p&gt;




&lt;p&gt;Not investment advice; verify competitor pricing yourself on their own pricing pages.&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>finance</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
