The World Cup had barely kicked off before the market gave us a little dose of shock education: it's not that strong teams never crash — it's that the way they crash is often more creative than we imagine.
Take Spain 0:0 Cape Verde. If you only looked at strength, ranking, and squad depth before the match, most people's gut reaction would be pretty simple: Spain should take this, right? But this is exactly where football is most interesting — and most tormenting for traders. You think you're buying "strong team wins," but what you're actually buying is a full 90-minute drama of fate. No goal in the first 15 minutes, and the odds twitch. Still 0:0 at the half hour, and the market starts to frown. Nothing by the 70th minute, and the win that looked rock-solid suddenly isn't so solid anymore.
This is when you run into a classic contradiction: arbitrage is great, but prediction is hard.
True arbitrage should, in theory, be risk-free. For instance, you find a set of mutually exclusive and collectively exhaustive contracts where, no matter what happens in the end, the portfolio pays out, and your entry cost is below the payout. It sounds wonderful — the trader's version of an afternoon nap. But that's exactly the problem: such opportunities are too rare, often vanish quickly when they appear, and may not even have enough capacity.
Prediction is another matter. You can judge that a strong team will probably win; you can analyze the lineup, form, schedule, and injuries; you can look at the implied probability from the odds. But the match won't cooperate with the script just because we analyzed it carefully. Football especially: an early red card, a goalpost, a ridiculous VAR call — any of these can turn beautiful logic into three seconds of silence in the post-match review.
So here's the question: if pure arbitrage is too scarce and pure prediction is too random, can we stand somewhere in between? The market itself has already given us some prior probabilities — the price of a strong-team win, the price of 0:0, the price of 0:1. These prices don't come from nowhere; they reflect the market's collective pricing of different paths. So can we use these prior probabilities, plus a bit of our own mathematical model, to construct an imperfect but more protective portfolio that pries open a "path-convergence" window?
That's the starting point of this idea.
It isn't traditional risk-free arbitrage, and it isn't pure gut-feel prediction either. It's more like a compromise: first acknowledge that the main probability the market gives us has reference value, then use low-probability paths to protect the most fragile part of the main judgment, while using a simple model to check whether the price is still acceptable.
Suppose in a given match, Brazil is clearly stronger than Haiti. The market prices a Brazil win at 0.89, which says a Brazil win is a very strong main path. But we don't just buy the Brazil win outright — we simultaneously watch two protective paths: 0:0 and 0:1. Because for a "strong team wins" judgment, the most uncomfortable early script is usually not every possible outcome, but the game staying stubbornly locked, or the weaker team stealing one.
So the initial portfolio becomes:
text
Brazil win Yes
0:0 Yes
0:1 Yes
Suppose the prices are:
text
Brazil win = 0.89
0:0 = 0.016
0:1 = 0.011
Total cost across the three legs:
text
C = 0.89 + 0.016 + 0.011 = 0.917
This bundle is not a complete event. It doesn't cover 1:1, 0:2, 2:2, or any number of weird scripts. So if you hold it to the final whistle, it can of course lose — and it can lose quite directly. But it has one important feature: it stitches together three probabilities the market has already given, forming a basket of paths around the main win.
If Brazil ultimately wins, the portfolio pays out 1. If the final score is 0:0 or 0:1, it also pays out 1. In other words, what you're buying is:
text
Brazil win ∪ 0:0 ∪ 0:1
As long as one of these paths occurs, the payout at expiry is 1. The cost is 0.917, leaving 0.083 of room within the covered paths. But the real key isn't at the final whistle — it's in the middle.
If Brazil scores early and the score moves to 1:0, then the two protective legs, 0:0 and 0:1, effectively go to zero, but the Brazil-win price rises. As soon as the sellable price of the Brazil win exceeds the initial total cost plus a little target profit, you can close the main win and converge the portfolio early.
The formula is simple:
text
Take-profit condition = Brazil win bid >= initial total cost C + target profit
For example, with a target profit of 0.02:
text
Trigger price = 0.917 + 0.02 = 0.937
If, after 1:0, the Brazil-win bid reaches 0.95, then:
text
Locked-in profit = 0.95 - 0.917 = 0.033
At this point we aren't making money off the final settlement — we're making it because the match entered a favorable branch, letting us sell the portfolio early. The process is a bit like the market opening a small window for you: don't stand at the window writing poetry — climb through it first.
But there's still a problem here: just because three legs look cheap, are they necessarily worth buying? Not necessarily. Market price is only the first layer of information. We also need our own reference ruler, however naive.
Here I used the most basic Poisson goal model.
A football scoreline can be roughly viewed as two teams each scoring as a random process over 90 minutes. Suppose the home team's expected goals over 90 minutes is λ_home and the away team's is λ_away. Then the probability of the home team scoring i goals and the away team scoring j goals can be written as:
text
P(i, j) = Pois(i; λ_home) × Pois(j; λ_away)
where:
text
Pois(k; λ) = e^(-λ) × λ^k / k!
It's certainly crude. Football isn't two independent lottery machines — after a goal, tactics change, red cards change things, mentalities shift. But its virtue is simplicity and transparency: at the very least it keeps us from acting purely on feel.
Before the match, the Poisson model can give us an initial reference. We can manually supply λ_home and λ_away, or we can let the program back them out from the market's exact-score markets. Scores like 0:0, 0:1, 1:0, 1:1, 2:0, 2:1, 3:0 — each of these score markets is equivalent to one probability point the market has provided. The program looks for a pair λ_home and λ_away such that the score probabilities computed by the Poisson model are as close as possible to these market prices.
In other words, the market is saying, "I think these scorelines are worth roughly this much." And the Poisson model murmurs back, "Then let me try to fit it, and see what goal intensity is implied behind this set of prices."
The strategy can be configured with:
python
CALIBRATE_LAMBDA_FROM_MARKET = True
MODEL_SCORE_SAMPLES = "0-0,0-1,1-0,1-1,2-0,2-1,3-0"
These scores are used only for modeling and do not participate in order placement. The legs actually traded are still:
text
Target team win
0:0
0:1
The code fits λ with a grid search. It's not complex, but it's intuitive:
python
def fit_lambdas_from_score_markets(quotes, model_score_legs, event_state=None):
samples = []
live_score = event_state.get("score_tuple") if event_state else None
minute = event_state.get("elapsed") if event_state else None
is_live = bool(live_score and minute not in [None, ""])
for leg in model_score_legs:
q = quotes.get(leg["name"])
p_market = quote_probability(q)
if p_market is None:
continue
target_h, target_a = parse_score_text(leg["score"])
if is_live:
current_h, current_a = live_score
if current_h > target_h or current_a > target_a:
continue
samples.append((target_h - current_h, target_a - current_a, p_market, leg["score"]))
else:
samples.append((target_h, target_a, p_market, leg["score"]))
if len(samples) < 2:
return None
best = None
for ih in range(5, 501, 5):
lh = ih / 100.0
for ia in range(5, 501, 5):
la = ia / 100.0
err = 0.0
for add_h, add_a, p_market, _score in samples:
p_model = poisson_pmf(add_h, lh) * poisson_pmf(add_a, la)
err += (p_model - p_market) ** 2
if best is None or err < best["err"]:
best = {"lambda_home": lh, "lambda_away": la, "err": err, "samples": samples}
if is_live:
ratio = max(0.01, max(0.0, 90.0 - float(minute)) / 90.0)
best["lambda_home"] = best["lambda_home"] / ratio
best["lambda_away"] = best["lambda_away"] / ratio
best["source"] = "live_score_markets"
else:
best["source"] = "pre_match_score_markets"
return best
Before the match, this λ is only an initial calibration. Since the game hasn't started, there's no live path to update; the score defaults to 0:0 and the remaining time is the full 90 minutes. At this stage we use it as an entry filter:
text
Model coverage probability = P(target team win) + P(0:0) + P(0:1)
and then require:
text
Model coverage probability - market cost >= safety margin
Only when the market price is cheap enough, and the model also thinks the basket has a slight edge, do we allow opening a position.
What's really interesting is after the match begins.
Once the match starts, the Poisson model is no longer static. Suppose the game reaches the 30th minute and the score is still 0:0. Then only 60 minutes remain, and the future goal intensity must shrink in proportion to the remaining time:
text
λ_home_remaining = λ_home × (90 - t) / 90
λ_away_remaining = λ_away × (90 - t) / 90
If the current score is already 1:0, then 0:0 and 0:1 can no longer occur. At that point the model must start from the current score and only compute how many more goals will be scored in the remaining time. Final scores like 2:0, 2:1, 3:0, 1:1 that are still possible are the ones eligible to participate in the new estimate.
This is the key to live Poisson updating: not mechanically shrinking the pre-match λ each minute, but re-estimating how the rest of the match might still unfold, combining the current score, the remaining time, and the exact-score markets that are still possible.
The final strategy becomes a three-layer judgment.
The first layer is market cost:
text
win_ask + 0:0_ask + 0:1_ask <= maximum allowed cost
The second layer is the Poisson filter:
text
Model coverage probability - market cost >= safety margin
The third layer is path convergence:
text
Current portfolio bid value >= initial cost + target profit
Only when the first and second layers are both satisfied do we consider entering. After entry, we no longer rely on the model to fantasize about the final outcome; we use the real bid quotes to judge whether we can close out. The model is responsible for raising entry quality; the order book is responsible for deciding whether we can cash in.
Contract lookup is kept as simple as possible. Polymarket's World Cup market slugs are quite regular. For example, a match's event slug is:
python
EVENT_SLUG = "fifwc-aut-jor-2026-06-17"
If we're protecting a Jordan win, the corresponding win-contract suffix is:
python
WIN_SUFFIX = "jor"
Then the three contracts needed for trading can be spelled out directly:
python
def yes_symbol(slug):
return slug + "_USDC.Yes"
def build_legs():
legs = [
{
"name": "win",
"slug": EVENT_SLUG + "-" + WIN_SUFFIX,
"symbol": yes_symbol(EVENT_SLUG + "-" + WIN_SUFFIX),
"kind": "win",
}
]
for score in parse_scores(PROTECT_SCORES):
legs.append(
{
"name": "score_" + score.replace("-", "_"),
"slug": EVENT_SLUG + "-exact-score-" + score,
"symbol": yes_symbol(EVENT_SLUG + "-exact-score-" + score),
"kind": "score",
"score": score,
}
)
return legs
Here, try not to use the team name for fuzzy search. Search Jordan, for instance, and you can easily turn up Michael B. Jordan, Jordan Pickford, and Jordan Spieth — and before your football strategy even gets going, the entertainment industry, an England goalkeeper, and a golfer have all sat down at the table first. Spelling out contracts from the event slug is much cleaner.
During the match we also need the live score. The first version can pull it directly from the Polymarket Gamma event:
python
def get_event_state():
data = get_json(GAMMA_BASE + "/events", slug=EVENT_SLUG)
e = data[0]
return {
"title": e.get("title"),
"score": e.get("score"),
"score_tuple": parse_score(e.get("score")),
"elapsed": e.get("elapsed"),
"period": e.get("period"),
"live": bool(e.get("live")),
"ended": bool(e.get("ended")),
"start_time": e.get("startTime"),
}
The portfolio's current sellable value uses the real bid quotes:
python
def basket_bid_value(legs, quotes):
total = 0.0
for leg in legs:
pos = positions.get(leg["name"], {})
amount = float(pos.get("amount", 0))
if amount <= 0:
continue
q = quotes.get(leg["name"])
if not q or q["bid"] is None:
continue
total += amount * q["bid"]
return total
Take-profit judgment:
python
cost = current_position_cost()
value = basket_bid_value(legs, quotes)
target = cost + TARGET_PROFIT * SHARES
if value >= target:
close_all()
That's the minimal version. It doesn't pretend to have solved football prediction, nor does it claim to arbitrage reliably. It only attempts to take that gray zone between "arbitrage" and "prediction" and study it a bit.
The beauty of arbitrage is certainty, but certainty is scarce. The temptation of prediction is the wide space it opens, but the randomness is large too. What this strategy does is treat the market's prediction prices as raw material, take one high-probability main judgment plus two low-probability protective paths, add a layer of crude filtering with the Poisson model, and try to construct a window that can converge early during the course of the match.
The risks must be stated clearly. This portfolio is not a complete event, and it is not risk-free arbitrage. It suits matches with a large gap in strength, where the main path is clear enough and the protective-path prices are low enough. If the match itself is a coin flip, or the protective legs are already expensive, forcing the trade is pointless. Worse, it doesn't cover all unfavorable paths — 1:1, 0:2, 2:2 can all leave the portfolio with a clear loss. So you must set position caps, maximum loss, and stop-loss rules; you can't treat it as arbitrage just because it "looks like arbitrage."
The Poisson model is no magic either. It's just a very plain mathematical foundation, helping us take one step forward from "I feel this price is good" to "at least I checked this price with a transparent model." It will be wrong, the market will be wrong, and we ourselves will be even more wrong. The most honest part of trading is probably admitting that all of these will be wrong, and then doing your best to keep any single error from flipping the whole table.
This attempt is shallow. It's more like asking a question: when pure arbitrage opportunities grow scarcer and pure prediction is too hard, can we use the probabilities the market has already given to construct trades with more structure? Not to fantasize about eliminating risk, but to break risk apart and see clearly which paths it arrives by.
Maybe this isn't the answer, but it's a little hole worth digging into further. Prediction tells us which main road may be wider; the arbitrage mindset reminds us not to look only at direction but also at portfolio structure. Between the two, there may still be a lot of space to explore.
Strategy source code: Polymarket Football Path-Convergence Strategy
- 1





