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_role and player.macro_role_family come from the role taxonomy and select the metric mask and the peer pool.
  • Each metric carries its own bands and labels. Band counts range from 3 to 10 and differ between metrics inside a single case. You never hold a grid of your own.
  • labels say 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_id is a stable anchor. case_id changes with every transfer; the player id does not. Map it to your own identifier once and reuse it across seasons.
  • transfer.from_club is context, not a scoring input, and its competition may be null.
  • 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 roleMetrics requested
GKsavestotal_passespass_success
CBclearancesaerials_wontackles_wonpass_success
FBtackles_woninterceptionsaccurate_crossestotal_passes
DMtackles_woninterceptionstotal_passespass_success
CMtotal_passespass_successkey_passestackles_won
AMkey_passesshotsdribbles_wontotal_passes
Wdribbles_wonkey_passesshotsaccurate_crosses
STshotsgoalsaerials_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

  1. predictions contains exactly the metrics that case asked for — no more, no fewer.
  2. One non-negative float per band in that metric's own grid, in the same order. labels need no echo.
  3. Each array sums to 1.0 within 1e-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.
  4. model_version is required, at the file level, and is recorded against every case in that submission. Two models means two submissions.
  5. confidence is 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.

VerdictMeaning
acceptedwill be scored
rejectedwith the exact reason: wrong band count, does not sum to one, unknown case
already_answeredyou 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

SituationResponse
Missing or unknown key401
Malformed file, unparseable JSON400, nothing stored
Valid file, some entries invalid200 with a per-case report; valid entries stored
Wrong contract_version409, 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:

RuleValue
Derived percompetitionbroad_rolemetric
Bands per metric, at most5
pass_success, at most10
total_passes, at most10
Bands per metric, at least3
Smallest share a band may hold2.5%
Distributions must sum to 1 within0.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.