A minimal Modal starter for the Explaining Markets competition. Deploy a signed webhook receiver, verify events, and submit predictions from Python.
Edit predict.py. Everything else is plumbing.
predict.py ← your strategy lives here
modal_app.py ← FastAPI app + webhook handler (don't touch)
src/explaining_markets/ ← config, verifier, API client, helpers (plumbing)
tests/ ← predict shape + webhook verification
When an event fires, the competition sends a signed webhook to your URL. This
app verifies the signature, calls your predict(event), and POSTs the result back
to the API — all before it ACKs the webhook, so you're always inside your deadline.
This repo uses uv — install it from the uv installation guide.
Prefer pip? Run
python -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"instead ofuv sync, and drop theuv runprefix from every command below.
uv syncIf you're new to Modal, create a free account and authenticate (one time — skip if you already have a Modal token on this machine):
uv run modal setupGo to Explaining Markets and click Sign in at the top right, then Create an account, and complete the sign-up flow.
Once you're in, create a submission from the Submissions page and give it a public name. You'll land on its Overview tab, which has a Setup checklist that walks you through the rest:
Credentials → Webhook URL → Submission is live → Verify your endpoint works
The next steps map onto that checklist; the submission goes live automatically once the first two are done.
Click Initialize credentials (the checklist's first item) to mint your API key and webhook signing secret. A dialog shows them once, under the heading "Ready to paste into .env", already formatted — exactly the two lines this starter needs:
EM_API_KEY=...
EM_WEBHOOK_SECRET=whsec_...
Click Copy (the dialog won't let you continue until you do) — you won't see these again, so don't close it before the next step. (The API key authenticates your prediction requests; the signing secret verifies incoming webhooks.)
Note: If you ever need new credentials — e.g., because they were accidentally leaked — that same item becomes Replace credentials. Clicking it mints a new set you can use to continue from Step 3.
Create your .env from the template, then paste the copied box into it, replacing
the two placeholder lines:
cp .env.example .envThat's the whole secret setup — Modal reads .env automatically at deploy time, so
there's no command to run. (.env is gitignored; never commit it. Add
OPENAI_API_KEY here too if you want real LLM predictions instead of the baseline.)
uv run modal deploy modal_app.pyModal prints a persistent public URL like
https://<your-workspace>--explaining-markets.modal.run. That URL is your webhook
URL — copy it as-is, nothing to append. The deployment keeps running after you
close your laptop.
Back on the Overview checklist, do Webhook URL: paste the URL from the previous
step (it must be reachable over HTTPS in production; http:// is allowed in dev)
and click Save webhook URL. As soon as credentials and a URL are both set,
Submission is live flips on automatically — no extra action.
The last item, Verify your endpoint works, is optional but recommended. Click
Send test event to send a synthetic delivery. Your handler verifies it, sees
event_type == "TEST", and ACKs with 200 without submitting; the checklist then
shows "Your endpoint responded successfully." If it doesn't appear right away,
check the Health tab for rolling delivery counters.
This is the only file you edit. predict(event) is called once per event after
verification; return one prediction per focal asset:
def predict(event: dict) -> list[dict]:
return [
{"identifier_value": "AAPL", "predicted_percentile": 0.92},
]predicted_percentile is a float in [0, 1] — where you think the asset's
next-day unexpected return lands in its historical distribution (0 = worst,
0.50 = median, 1 = best). The default implementation asks an OpenAI model for a
calibrated percentile; with no OPENAI_API_KEY set it returns 0.5 so the
round-trip works before you plug in your real model.
Re-deploy after editing:
uv run modal deploy modal_app.pyOnly your first submission for an event is scored — re-POSTing the same event is accepted but won't overwrite it, so get it right the first time.
uv run pytestBoth suites run fully offline — no API key, no network. One checks that
predict() returns the right shape; the other verifies the webhook verifier
against the competition's frozen, published signing vectors.
Webhook signatures cover the exact bytes the server sent. The most common
mistakes (all handled correctly by modal_app.py, but worth knowing if you
customize it):
- Re-serializing the body before verification.
json.dumps(json.loads(body))reorders keys and adds spaces — verification fails. Always verify the raw bytes. - Using
request.json()instead ofrequest.body(). Same issue: the parsed dict is no longer the original byte string. The handler readsawait request.body(). - Ignoring the timestamp. The verifier defaults to a 5-minute tolerance. If
your clock drifts, pass
tolerance_seconds=toverify_webhook. - Not deduping on
Webhook-Id. The server retries on 5xx and timeout, so the same event can arrive more than once. This app dedupes via amodal.Dict.
If predictions aren't landing: confirm your .env has EM_API_KEY and
EM_WEBHOOK_SECRET filled in (then re-deploy so Modal reloads it), that the
submission shows as live (the checklist's Submission is live item), and that
you pasted the deploy URL into the portal. The Health tab's prediction counter
should increment for non-TEST events.
If modal deploy errors that it can't find .env, you're missing the file —
cp .env.example .env and fill it in. Modal needs it present at deploy time.
For queue-based processing, swapping the vendored verifier for a published
package, and other extensions, see docs/advanced.md.
