There is a bias so old and so reliable that it has its own name in the
academic literature: the favorite-longshot bias. At horse tracks, it has
been documented for the better part of a century. Bettors love longshots
(the 50-to-1 story, the lottery ticket with a narrative), and they love
them so much that they systematically overpay for them. Favorites,
meanwhile, are boring, underbet, and quietly underpriced. A dollar
spread across longshots at the track loses far more than a dollar spread
across favorites. Sportsbooks show the same pattern. It is one of the
most replicated findings in the economics of gambling.
So here is an obvious question: does Polymarket have it too? The
suspicion writes itself. Prediction markets are retail-heavy. The
markets that go viral are the improbable ones. If people bring their
racetrack brains to Polymarket, longshots should be overpriced there
too: a 3-cent contract that really wins 1% of the time, a 95-cent
favorite that really wins 97%.
And the stakes are not academic. If the bias exists, there is free
money in fading it: sell the longshots, buy the favorites, collect the
gap. A persistent, mechanical, no-cleverness-required income stream.
That is worth checking carefully.
So I checked. I took 6,000 resolved Polymarket markets: binary markets
with lifetime volume between $10k and $1M, everything in that band that
resolved between March and June 2026, more than 12 million trades. For
each one I recorded the last traded price 24 hours before it
resolved. Then the question is almost embarrassingly simple: of all the
markets priced around 30 cents a day before the end, how often did the
thing actually happen? Sort the markets into ten price buckets, compare
each bucket’s average price to how often those markets resolved yes.
Markets whose trade history didn’t reach back far enough from the
finish line get dropped rather than fudged, which leaves 4,512 markets
on the day-before horizon; that and the rest of the statistical fine
print are handled in the full methodology, linked at the bottom.
Here is the result.
Every dot is a price bucket; the dashed line is what perfect honesty
would look like. The dots sit on the line. Markets priced at an average
of 97.6 cents happened 97.6% of the time. Markets priced around 45
cents happened 46.1% of the time. And the cheapest longshots, average
price about 1.4 cents, happened 1.5% of the time. Read that again
through your racetrack glasses: the longshots paid out slightly more
often than their price implied, not less. The gap is well within noise,
but it is the opposite direction from the century of horse-racing data.
In no bucket, anywhere on the chart, does the gap between price and
reality exceed the bucket’s margin of error.
It gets sharper closer to the end. One hour before resolution, markets
priced 0.90–1.00 averaged 0.995 and realized 0.994. At that point the
market has essentially finished thinking, and what it says is a
995-in-1,000 chance is, as far as anyone can measure, a 995-in-1,000
chance.
I will admit the first pass had me interested. The initial cohort of
2,000 markets showed wobbles shaped exactly like the classic bias:
markets priced at 84.5 cents were resolving yes only 75.5% of the time,
and a mid-range bucket priced at 35.6 cents was landing at 30.4%. But
none of it was statistically solid, and when I trebled the sample by
adding two more months of markets, the wobbles flattened onto the
diagonal. The leftover deviations now point in different directions in
different buckets (the 10–20 cent bucket resolved above its price,
the 30–40 cent bucket below it), which is the signature of noise, not
of a bias. The more data I added, the more honest the prices looked.
What this means for you, practically: on Polymarket, in liquid markets
near resolution, you are paying fair price. The 4-cent moonshot is not
secretly a 1% shot dressed up by lottery-brained flow; it’s roughly a
4% shot. There is no mechanical income in selling longshots, and you
are not being taxed for buying them. The price, a day out, is about as
good an estimate of reality as exists. That is genuinely unusual
among things people bet on, and mildly deflating if you were hoping to
outsmart it.
One honest caveat, and it’s the interesting one. Markets that resolve
at a rate of thousands per few months are, by nature, short-lived ones:
sports, crypto price thresholds, this-week deadlines. That is what
this sample is made of. The classic favorite-longshot literature lives
in exactly the place this sample can’t reach: long-dated political and
event markets, which resolve too slowly to pile up in a four-month
window. Whether a “15% chance” on an election twelve months out is
honest in the same way is a different measurement, and it is still
open. That’s a future post.
This piece is one probe from a longer project: a four-month program
that tested around sixteen hypothesized edges on Polymarket (the
things people on trading Twitter assure you are free money) against a
multi-terabyte archive of the order book. Most of them did not survive
contact with the data, usually in instructive ways. The calibration
result you just read is the cleanest of the nulls. Some of the others
died more interesting deaths.
The full methodology (cohort construction, the censoring rule, the
confidence intervals) is written up in the study writeup. The
whole thing reproduces with one command against the study dataset:
uv run python -m pmr.studies.calibration --horizon "24 hours" --buckets 10.
This research runs on a full-universe tick archive of the Polymarket
order book (May–July 2026), collected because historical order-book
data cannot be backfilled from any public API. If you’re building or
researching in this space and need historical data, get in touch:
research@lafargue.cc.
I discovered Bitcoin in 2013. In 2021, I launched my first blockchain business.
Early 2021, I got interested in the Decentraland project. Its goal is to build a virtual world on the Ethereum blockchain. I believe the future of the metaverse is on the blockchain because in a virtual world, ownership is key.
In Decentraland, owning LAND grants you the rights to deploy whatever 3D creation you want inside the virtual world. The way I see this project is like a showroom for the blockchain. In its current state, blockchain is not accessible for the general public. In this early adopter stage, it is important to include users excited about the technology, regardless of technical background. Decentraland is the place for building blockchain experiences that are easier to access.
First steps
On January 19th, 2021, I deposited 1000€ on Binance. I purchased 8065 MANA (Decentraland’s own currency) which I withdrew to my Ethereum wallet. I used 8000 MANA to purchase 1 LAND on the Decentraland marketplace. Basically, I bought a piece of virtual LAND for about 1000€.
Here’s what that LAND looks like on the map:
About two months later, on March 17th, I sold that LAND for 7214 MANA. Atari had annouced an upcoming project in Decentraland which shot the value of MANA up almost 10x. Although I had lost MANA relative to my purchase price of the LAND, my stake was now worth $7,677.
Riding the high of this lucky investment, I began to be more interested in the LAND market. I continued to buy and sell a few LAND tokens to see if I could turn a profit.
At this point I came to the understanding that all the best LAND deals were gone within minutes if not seconds. If I wanted to be first, I needed to automate. So I set up a Telegram bot that would send me notifications when LAND was listed at low prices. Unfortunately I don’t have any screenshots of that bot but basically it would send me messages like this on Telegram in real time:
⚠️ LAND BARGAIN ALERT ⚠️ 32,88 has been listed for 500 MANA
These alerts helped me pick up some LAND on the cheap.
I also built a custom trading bot for the Opensea market. This market was interesting because it was possible to place offers on LAND at no cost. Unfortunately, there was no way to make a blanket offer on all available LAND, offers needed to be created individually. What made things worst is the fluctuating value of the LAND requiring the value of offers to be re-evalutated on a daily basis. Placing offers manually, every day, on the thousands of existing LAND was not feasible. For this reason I built bot that could do it for me. This strategy proved very fruitful.
The last piece of this business came when Opensea made changes to their platform that made my bot obsolete. I spotted a unique opportunity that piqued my interest. Inside Decentraland, there are casinos where people play for cryptocurrency which is worth real money. Some of these casinos have processed millions of dollars worth of bets.
In the Decentraland Discord server, I came into contact with a seller who wanted to sell a land lease for the “Flamingos Casino” that had yet to open. This “lease” would grant the holder a profit share of the revenues generated in the casino on that LAND. I checked the historical prices of these leases and one had recently sold for 22 ETH. I offered the seller 9 ETH. Initially he refused but months later he came back and sold it to me.
I immediately listed it on the market for 21 ETH and advertised the sale daily in the Discord server. Within 10 days, I pocketed 20.48 ETH after fees for more than 11 ETH profit
Overall, within about a year, my initial investment of 1000€ had grown to be worth over $100,000 and my total transaction volume over a 6 months period was more than $1M.
This blog post outlines how I developped a profitable NFT trading bot in early 2023 after attending Le Wagon web development bootcamp in Nantes, France. I already had prior experience coding but after the bootcamp, I wanted to put into practice the new things I’d learned, notably the Ruby programming language and MVC architecture. To develop this project, I used the Sinatra micro-framework coupled with a PostgreSQL database and a dockerized Node.js instance for the transaction signing logic.
The plan
First a quick recap of my prior experience with NFT trading bots:
In 2021, I developped an OpenSea mass offer bot which made over $50k in a 6 months period.
The bot exploited an asymetry between buy and sell side orders.
Sellers might be short on time and wanting to sell their NFT right now, without waiting for a buyer to show up.
My bot sent offers to buy each individual NFT in a collection at a discount compared to the cheapest NFT listed for that collection.
For example, if the cheapest NFT in the Bing Bong collection was listed for 1 ETH, I would send an offer to each holder of a Bing Bong NFT to buy it from them for something like 0.85 ETH. Some of them accepted and I would then list the NFT just under the cheapest listed NFT in that collection, in this example something like 0.99 ETH, pocketing 0.14 ETH when I sold it. With the current value of ETH being what it is, this is a nice profit for running a program and clicking a few buttons.
Collection offers
It is 2023 now however and most NFT marketplaces have realized this asymetry and implemented a new feature: the collection offer. The concept is still the same but now you can place offers on entire collections instead of just single NFTs. Here’s an illustration I made to represent the strategy visually:
The yellow zone in the middle is where we make profit, in this example the max profit (difference between highest buy offer and floor price) is 1 ETH.
It is now also much harder to get approved for an OpenSea API key. So I decided to try my luck with another marketplace for which the API would be easier to access. I came upon the MagicEden marketplace for the Solana blockchain which also had the collection offer feature.
Designing the bot
I wanted to improve upon my bot from 2021 and make this one fully automated. I wanted to be able to just run a program on my laptop and have it go make money for me. So from the start I knew I would need the following features:
Get data for a set of NFT collections I was interested in trading - things like trading volume, current floor price, value of the current highest collection offer. This is the data my bot would use to know how much to buy and sell NFTs for.
Create collection offers for collections that have potential for profit and adjust these offers when outbid by the competition or when the collection stops being profitable.
Have a sense of NFTs currently held in the account.
List these NFTs at a profit and adjust the price if other users list NFTs for cheaper than us. It’s important to sell fast to stay liquid and avoid hanging on to any single NFT for too long. Otherwise, we risk a big loss if the value of the NFT we are holding drops.
Provide an interface for tracking the actions taken and the profit realized by the bot (letting a program manage your crypto without supervision is a terrible idea!).
Digging into the API
First, I went and checked out the MagicEden API docs. Before wasting time developping my features, I wanted to see if the API provided some useful endpoints that would save me time trying to scrape data from the site. Upon inspection of the docs, I found these endpoints which provided some of the functionality needed for my bot:
api-mainnet.magiceden.dev/v2/collections/:symbol - for getting important data for a collection including the floor price. It’s not explicitly listed in the docs but it’s easy to deduce as three of its sub-endpoints are listed in the docs.
api-mainnet.magiceden.dev/v2/wallets/:wallet_address/tokens - for getting the tokens currently held in my account, useful for knowing when someone sells an NFT to my bot.
api-mainnet.magiceden.dev/v2/instructions/sell - for generating Solana transaction data to list an NFT to sell on MagicEden. This one however requires an API key to use (which I don’t have).
api-mainnet.magiceden.dev/v2/instructions/sell_cancel - for generating Solana transaction data to delist a previously listed NFT. Once again this one requires an API key.
There’s some more endpoints I found in those docs and ended up using but I won’t mention them here as they’re beyond the scope of what I’ll discuss in this writeup. I was still missing some of the data my bot would need to work so I tried finding some private API endpoints not listed in the docs!
So I went to the MagicEden site, navigated to the pages that contained the data, opened Chrome Dev Tools on the Network tab, refreshed the site and inspected the requests made by my browser. This method allowed me to discover these additional endpoints:
https://api-mainnet.magiceden.dev/v2/mmm/pools?collectionSymbol=:symbol - for getting collection offers for a given collection. Great for getting the current highest collection offer. Additionally this endpoint was completely accessible when I tried a request from Insomnia!
https://api-mainnet.magiceden.io/v2/instructions/mmm/create-pool - for generating Solana transaction data to place a collection offer. This endpoint is on the magiceden.io domain as opposed to the previous endpoints which are all on the magiceden.dev domain. When I tried requesting this endpoint in Insomnia, I got a 403 Forbidden error. I will discuss how I got around this limitation later in the post.
https://api-mainnet.magiceden.io/v2/instructions/mmm/sol-withdraw-buy - for generating Solana transaction data to cancel a collection offer. As with the previous endpoint, this one was also protected.
With these endpoints in hand I was ready to get started.
Implementing the API in Ruby
The first thing I did was create a Ruby class called MagicEdenApi. Then I added class methods to it for each API endpoint I planned to call. This would make it easier to query the API in my program. Here’s an example for the best offer endpoint:
classMagicEdenApi@@api_endpoint="https://api-mainnet.magiceden.dev/v2"# The method takes a collection symbol as an argument
defself.get_collection_best_offer(symbol)# Make a GET request to the API
response=RestClient.get("#{@@api_endpoint}/mmm/pools
?collectionSymbol=#{symbol}
&limit=500
&offset=0
&filterOnSide=1
&hideExpired=true
&direction=1
&field=5")parsed=JSON.parse(response.body)['results']# Sleep for 2 seconds to avoid spamming and getting blacklisted from API
sleep2# Sort offers by price and return relevant information for the highest one
sorted_offers=parsed.sort_by{|o|o['spotPrice']}.reverseifparsed.any?{best_offer: from_lamports(sorted_offers.first['spotPrice']),best_offer_date: sorted_offers.first['updatedAt'],best_offerer: sorted_offers.first['poolOwner']}else{}endendend
Then now in the rest of my program I could use:
require_relative'magic_eden_api'# Get best offer for the Bing Bong collection
MagicEdenApi.get_collection_best_offer('bing_bong')
Now you may be wondering how I went about implementing the API endpoints that weren’t accessible outside of the magiceden.io domain. For these, I wrote a special method in my MagicEdenApi class which I called bypass_api. I figured that when using the site in my browser I was able to access these endpoints so I decided to reproduce the same configuration programatically. Selenium is a framework for automating your browser, so I wrote the following method using Selenium:
That script fetches the endpoint we want to access and inserts the response on the page
After 5 seconds we ask Selenium to find the element that should have been inserted, if it has we return the API response contained in that element
The reason that this method works is that from the perspective of the server, we are using the site in a normal way: browsing it then making normal calls to the API within the same browser. It’s not 100% effective but it’s good enough for our use. Now we can use all the endpoints we found earlier.
Sending transactions to the Solana blockchain
With the features of the API now fully implemented as a Ruby class, we are still missing one key aspect before we go and start buying and selling NFTs. We still need a way to take the transactions we generate with the MagicEden API, sign them, and send them to the Solana blockchain. Remember there are four types of transactions we might want to do:
Create a collection offer
Delete a collection offer
Create a listing for an NFT
Delete a listing
For signing and sending Solana transactions, the go to is the @solana/web3.js package in Javascript. Since I’d decided to write most of my app in Ruby, for this small utility layer I decided to go with a dockerized instance of Node.js with an Express web server for communication with my main app. Here’s the process I settled on:
Use the API implementation in Ruby to generate the transaction
Store the unsigned transaction in the database
Request the signing and sending of the transaction by passing the ID of the newly created transaction to the Express web server
Let the docker container take care of handling the transaction and store relevant outputs (like the transaction signature) in the database under the same ID
This is what it looks like for creating a collection offer:
defcollection_buy_offer(attributes={})# Generate the unsigned buy offer transaction and store it
buy_offer_unsigned=MagicEdenApi.get_collection_buy_offer(attributes)buy_offer=BuyOffer.create(unsigned_creation: buy_offer_unsigned,status: :unsigned,collection: Collection.find_by(symbol: attributes[:symbol]),spot_price: attributes[:price])sleep1# Make request for docker container to sign and send it
RestClient.get"http://localhost:1500/buyoffer/create/#{buy_offer.id}"end
And here’s the method that ends up getting called in the docker container:
constcollectionBuyOffer=(transactionId)=>{client.query("SELECT * FROM buy_offers WHERE id = $1",[transactionId],(err,res)=>{consttransaction=Web3.Transaction.from(Buffer.from(JSON.parse(res.rows[0].unsigned_creation)));constpoolAddress=transaction._message.accountKeys[2].toString();transaction.partialSign(keypair);constserialized=transaction.serialize({requireAllSignatures:true,verifySignatures:true});connection.sendEncodedTransaction(serialized.toString('base64')).then((signature)=>{client.query("UPDATE buy_offers SET signature_creation = $1, pool_address = $2, status = 'active' WHERE id = $3",[signature,poolAddress,transactionId])}).catch((err)=>{client.query("UPDATE buy_offers SET status = 'invalid' WHERE id = $1",[transactionId])});});};
The other three transaction types we listed above are implemented in a similar way.
The trading logic
Now with all the tooling in place required to interact with the MagicEden marketplace, we can put it all together to define how our bot trades. I settled on having two main loops that would run continuously. The first one we’ll call the data fetcher and is responsible for constantly refreshing the data of each of the ~100 collections I want to trade on. The second we’ll call the main bot and handles all other tasks we want to do.
The data fetcher is self-explanatory, it updates the data (volume, floor price, best offer) for the first collection, then the second and so on. Once it has updated all collections, it starts over updating the first collection.
The main bot on the other hand does a few things. Here’s what the code looks like for that loop.
I’ll now go over what each line in that loop does, skipping over the boring details to give you an overview of how the bot is trading.
The get_tokens method queries the MagicEden API to get the current tokens held in our account. Some of these tokens we may have been holding for a while and already exist in our database, for these tokens we do nothing. For the new tokens that have appeared in our account however, we create a corresponding instance in our database with all the relevant information (the collection it belongs to, the image URL of the token…)
The get_transactions method checks for tokens we may have recently sold. If there are any, that method figures out how much we bought the token for, how much we sold it for, and stores that information in the database. This is what we use to calculate our profit.
The all_held.each loop iterates over each NFT we are currently holding and refreshes the data of the collections those NFTs belong to. This part is important because when we list our NFTs for sale, we want to make sure we are choosing our price based on the latest data available without having to wait for the data fetcher to update that specific collection.
The sell_nfts does a couple things. First it takes all NFTs that are not currently listed and lists them just under the current floor price for that collection (good thing we just updated the data!). Then for NFTs that are already listed, it checks whether the floor price in that collection has fallen below what we listed ours at and if so, it deletes that listing and creates a new one just under the new floor price. This ensures we sell our NFTs quickly by always offering the cheapest one in the collection.
The update_collection_offer_prices I’ll skip over, it’s a method I added after running into bugs sometimes when trying to cancel collection offers.
The refresh_active method is where it starts to get interesting. This method looks at all the collection offers we have placed, then it refreshes the data of the corresponding collections. Finally, if there is an offer higher than ours, it deletes our current offer and places another one just above the competitor who outbid us.
Lastly, the place_profitable_offers method does a few things:
It takes the list of all the collections we are tracking and filters them according to the following criteria
A minimum collection volume, under which we consider collections not liquid enough to trade (I set this to 50k SOL)
A minimum and maximum floor price, this ensures we are targetting NFTs that will both give us a good return but not lock up all our funds in a single big offer (I have this set from 5 SOL to 30 SOL for my bot which is trading with around 100 SOL)
A minimum and maximum spread, the spread is the relative size of the gap between the highest buy offer and the floor price. For a collection with a best offer of 9 SOL and a floor price of 10 SOL, the value of the best offer relative to the floor price is 90% so the spread is 10% or 0.1. The higher the spread, the more profit we can make, so why bother setting a max spread? I did this to remove “too good to be true” collections. I set the max spread to 0.5 because if a given collection meets my other criteria and has a 50% spread, my bot has probably made a mistake somewhere along the way and I shouldn’t trade it.
Once it has identified a list of “profitable collections” with favorable spreads, it proceeds with placing collection offers on each of those at a value just above the current best offer.
The last two lines I added for redundancy as sometimes the bot would not behave as expected, adding these helped to make the bot run more smoothly
The dashboard
An important part of the development process was to have access to visual feedback of what the bot was doing. For this purpose I implemented a simple Sinatra view displaying all the details I needed. Here’s what that ended up looking like:
The main elements of the interface are:
Some basic stats like profit and sales
A list of recent trades detailing how much each of them gained or lost
A list of NFTs currently held by my bot
A list of all the active collection offers placed by my bot