Submission contract
You fetch the open cases, submit one file of predictions, and are scored weekly. There is no
endpoint to expose and no service to keep running. Contract version
2026-09-transfers-distributional-v0.
GET /api/tasks/transfers/spec the frozen vocabulary
GET /api/tasks/transfers/cases the cases open for prediction
POST /api/tasks/transfers/submissions your predictions (Authorization: Bearer <key>)
Why submission and not an endpoint
Because of where entity resolution happens.
A case identifies a player the way any provider-neutral system must: by name, date of birth,
position, clubs and transfer date. No provider identifiers are sent, and case_id is an Arena
string that means nothing in your database.
Under a live endpoint you would match that against your own records inside an HTTP request, under a timeout. A mis-resolution there is not a signal about your model — it is noise in your score. Submission moves it offline: resolve once, at your own pace, and flag anything ambiguous rather than guessing.
The second reason is access. A transfer's prediction window is weeks wide and there are roughly 650 cases a season. Requiring a live service to answer them would select for infrastructure maturity rather than forecasting skill.
The case list
{
"case_id": "sample-transfer-001",
"contract_version": "2026-09-transfers-distributional-v0",
"player": {
"age_at_transfer": 27,
"broad_role": "CM",
"citizenships": [
"Brazil"
],
"date_of_birth": "1999-03-14",
"foot": "right",
"height_m": 1.81,
"macro_role_family": "Midfield",
"name": "Sample Player"
},
"target": {
"competition": "Premier League",
"metrics": {
"key_passes": {
"bands": [
[
0,
46.7
],
[
46.7,
76.6
],
[
76.6,
91.7
],
[
91.7,
97.1
],
[
97.1,
100
]
],
"labels": [
"0",
"1",
"2",
"3",
"4+"
]
},
"pass_success": {
"bands": [
[
0,
9.8
],
[
9.8,
20.3
],
[
20.3,
30.1
],
[
30.1,
40.2
],
[
40.2,
50.4
],
[
50.4,
60.3
],
[
60.3,
70.2
],
[
70.2,
80.1
],
[
80.1,
90.2
],
[
90.2,
100
]
],
"labels": [
"0-69.7",
"70-76.7",
"76.9-80.6",
"80.7-83.3",
"83.6-85.7",
"85.9-88.1",
"88.2-90.2",
"90.3-92.8",
"92.9-97.1",
"97.2+"
]
},
"tackles_won": {
"bands": [
[
0,
31.4
],
[
31.4,
66.2
],
[
66.2,
87.4
],
[
87.4,
100
]
],
"labels": [
"0",
"1",
"2-3",
"4+"
]
},
"total_passes": {
"bands": [
[
0,
9.9
],
[
9.9,
20.1
],
[
20.1,
30.8
],
[
30.8,
40.4
],
[
40.4,
51.3
],
[
51.3,
61.5
],
[
61.5,
71.3
],
[
71.3,
81
],
[
81,
91.1
],
[
91.1,
100
]
],
"labels": [
"0-7",
"8-14",
"15-21",
"22-27",
"28-33",
"34-39",
"40-46",
"47-55",
"56-71",
"72+"
]
}
},
"peer_group": "CM",
"season_label": "26/27"
},
"task": "transfers",
"transfer": {
"date": "2026-08-08",
"fee_eur": 42000000,
"from_club": {
"competition": "Ligue 1",
"country": "France",
"name": "Sample Source FC"
},
"market_value_at_transfer_eur": 38000000,
"to_club": {
"competition": "Premier League",
"country": "England",
"name": "Sample Target FC"
},
"transfer_type": "permanent"
}
}
Notes
player.broad_roleandplayer.macro_role_familycome from the role taxonomy and select the metric mask and the peer pool.- Each metric carries its own
bandsandlabels. Band counts range from 3 to 10 and differ between metrics inside a single case. You never hold a grid of your own. labelssay what each band means in real values for that destination, so you read "0 goals" rather than "percentile 0 to 78". A label of-marks a band no observed value falls into.registry_player_fa_idis a stable anchor.case_idchanges with every transfer; the player id does not. Map it to your own identifier once and reuse it across seasons.transfer.from_clubis context, not a scoring input, and itscompetitionmay benull.- No provider identifiers are sent, which is why resolution is on you and why the case list is published early enough to do it properly.
Metric masks
| Broad role | Metrics requested |
|---|---|
GK | savestotal_passespass_success |
CB | clearancesaerials_wontackles_wonpass_success |
FB | tackles_woninterceptionsaccurate_crossestotal_passes |
DM | tackles_woninterceptionstotal_passespass_success |
CM | total_passespass_successkey_passestackles_won |
AM | key_passesshotsdribbles_wontotal_passes |
W | dribbles_wonkey_passesshotsaccurate_crosses |
ST | shotsgoalsaerials_wonkey_passes |
The submission
One file, one model version, one entry per case you are answering.
{
"task": "transfers",
"contract_version": "2026-09-transfers-distributional-v0",
"model_version": "acme-recruitment-4.2.1",
"predictions": [
{
"case_id": "tr_2627_premier-league_000184",
"predictions": {
"total_passes": [0.02, 0.04, 0.07, 0.10, 0.13, 0.16, 0.17, 0.15, 0.10, 0.06],
"pass_success": [0.05, 0.14, 0.26, 0.33, 0.22],
"key_passes": [0.38, 0.31, 0.18, 0.09, 0.04],
"tackles_won": [0.12, 0.21, 0.28, 0.25, 0.14]
},
"confidence": 0.72
},
{ "case_id": "tr_2627_laliga_000091", "refused": true, "reason": "unsupported_role" }
]
}
Rules, per entry
predictionscontains exactly the metrics that case asked for — no more, no fewer.- One non-negative float per band in that metric's own grid, in the same order.
labelsneed no echo. - Each array sums to
1.0within1e-6. Arrays are never renormalised for you. A distribution that does not sum to one is a bug in your model, and rescaling it silently would hide that bug rather than surface it. model_versionis required, at the file level, and is recorded against every case in that submission. Two models means two submissions.confidenceis optional, per case, in[0, 1], and never affects the objective score.
Validation is a dry run first
POST …/submissions?dry_run=true returns the same per-case report and stores nothing. Run it
as often as you like.
| Verdict | Meaning |
|---|---|
accepted | will be scored |
rejected | with the exact reason: wrong band count, does not sum to one, unknown case |
already_answered | you have submitted this case before; it is never overwritten |
Only accepted entries are stored. Rejected ones stay open, so partial submissions are normal — forty cases today, thirty next week.
Refusals
Mark a case refused with a reason. Refusal is per case and all-or-nothing: answer every metric in
that case, or refuse it.
A refused case is not scored as a maximally wrong prediction. A model that knows a case is outside its scope is behaving correctly, and scoring that as error would make good judgement indistinguishable from bad modelling. It reduces coverage instead:
coverage = scored cases ÷ scorable cases
published beside the skill score, so declining is visible and costed without corrupting the accuracy measure.
Joining at any point in the season
Registration is open all season, and a case does not close because the player has started playing. A benchmark that closed cases on first appearance would be shut to any provider whose decision took a few months — which is most of them.
If you join in November you forecast from November, using everything already observable including the matches already played. That is a legitimate forecast. What it costs is published rather than hidden:
- You are scored only on appearances after your lock. You may use the first ten matches as input; you are graded on match eleven onward. Nobody is scored on football they had already watched, which is what keeps an early and a late entrant comparable.
- The baseline is computed on the same window, so your skill score means the same thing as everyone else's.
- Fewer scored appearances means a wider interval and longer as
provisional. - Your information level — how much of the season had already happened when you locked — is published as its own column.
Errors
| Situation | Response |
|---|---|
| Missing or unknown key | 401 |
| Malformed file, unparseable JSON | 400, nothing stored |
| Valid file, some entries invalid | 200 with a per-case report; valid entries stored |
Wrong contract_version | 409, nothing stored |
Individual case problems are never a whole-file failure. A single bad band count should not cost you the other 119 predictions.
Band policy
Grids are derived from the reference pool rather than fixed in the contract:
| Rule | Value |
|---|---|
| Derived per | competitionbroad_rolemetric |
| Bands per metric, at most | 5 |
pass_success, at most | 10 |
total_passes, at most | 10 |
| Bands per metric, at least | 3 |
| Smallest share a band may hold | 2.5% |
| Distributions must sum to 1 within | 0.000001 |
Bands are cut at cumulative mass, which is why a band's percentile width equals its share of peer appearances — and therefore why predicting the widths is exactly the league base rate, and scores zero.
Versioning
The contract version pins the band grids, the role taxonomy, the metric vocabulary and masks, the case shape and the submission rules together. A change to any of them is a new contract version, and a submission naming an old one is rejected rather than silently reinterpreted.
The role and metric taxonomies carry their own version identifiers, so a scored round records which mapper and which registry produced its cases.