Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@ name: Upload Python Package
on:
push:
tags:
- "*"
# Release tags are bare version numbers: 3.1.2, 3.1.2.dev0
- "[0-9]*"
# A `v` prefix is NOT a valid release tag. It is matched here only so the
# job can fail with an explanatory error instead of silently doing nothing.
- "v[0-9]*"

jobs:
build-n-publish:
name: Build and publish Python 🐍 distributions 📦 to PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
# full history so the master-ancestry check below can run
fetch-depth: 0
- name: Set up Python 3.10
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
Expand All @@ -19,11 +26,61 @@ jobs:
run: >-
python -m
pip install
build wheel
build wheel packaging
--user
- name: Verify tag format and version match
env:
TAG_NAME: ${{ github.ref_name }}
run: |
python - <<'PY'
import os
import pathlib
import re
import sys
from packaging.version import Version

tag = os.environ["TAG_NAME"]
if tag.startswith("v"):
sys.exit(
f"::error::Release tags must be bare version numbers. "
f"`{tag}` has a `v` prefix; delete it and tag `{tag[1:]}` instead."
)
pkg = re.search(
r'numerapi_version\s*=\s*"([^"]+)"',
pathlib.Path("setup.py").read_text(),
).group(1)
print(f"tag={tag} setup.py={pkg}")
if Version(tag) != Version(pkg):
sys.exit(
f"::error::Tag {tag} does not match setup.py numerapi_version={pkg}"
)
print(f"::notice::publishing numerapi {Version(pkg)}")
PY
- name: Final releases must come from master
env:
TAG_NAME: ${{ github.ref_name }}
run: |
python - <<'PY' > prerelease.txt
import os
from packaging.version import Version

print("yes" if Version(os.environ["TAG_NAME"]).is_prerelease else "no")
PY
if [ "$(cat prerelease.txt)" = "no" ]; then
git fetch --no-tags --quiet origin master
if git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then
echo "final release from a commit on master: ok"
else
echo "::error::Final release tags must point at a commit on master; $GITHUB_SHA is not one. Cut pre-releases (e.g. 3.1.2.dev0) from preview instead."
exit 1
fi
else
echo "pre-release: master-ancestry check skipped"
fi
rm -f prerelease.txt
- name: Build a binary wheel and a source tarball
run: >-
python setup.py sdist && python setup.py bdist_wheel
python -m build
- name: Publish distribution 📦 to PyPI
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
Expand Down
20 changes: 13 additions & 7 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,28 @@ on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v2
- name: Set up Python 3.10
uses: actions/setup-python@v2
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_tests.txt
python -m pip install . -r requirements_tests.txt
- name: Require pandas 3 on Python 3.14
if: matrix.python-version == '3.14'
run: python -m pip install "pandas>=3,<4"
- name: Run tests
run: python -m pytest --import-mode=append tests/ --cov=./ --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v5
with:
directory: ./coverage/reports/
fail_ci_if_error: false
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Changelog
Notable changes to this project.

## [3.1.2] - Unreleased
- support Python 3.14 and pandas 3: require `pandas>=2.3.3` on Python 3.14 and
keep `pandas>=1.1.0` below it
- declare `python_requires>=3.10` and advertise Python 3.10 through 3.14
- 3.1.0 and 3.1.1 were never released; 3.1.0 was published briefly and
withdrawn from PyPI

## [3.0.0] - 2026-08-07
- remove the six deprecated Corr/MMC multiplier projections from
`list_rounds`; use the identity-preserving `roundScoreConfigs` list instead

## [2.24.0] - 2026-08-03
- add exact `roundScoreConfigs` identities, scoring windows, and payout settings
to `list_rounds` for Classic, Signals, and Crypto
Expand Down
195 changes: 195 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Releasing numerapi

numerapi ships to **PyPI only**. There are no container images, no ECR, and no
branch-triggered deploys in this repo — if you are thinking of the
`master` → prod / `staging` → staging image flow from our other repos, that does
not apply here.

## The model

Two rules explain everything else:

1. **`numerapi_version` in `setup.py` is the release.** The git tag is only the
trigger. Whatever that string says is what lands on PyPI.
2. **The version string picks the channel**, not the branch. A
[PEP 440](https://peps.python.org/pep-0440/) pre-release (`3.2.0.dev0`) is
invisible to `pip install numerapi`; a final version (`3.2.0`) is what
everyone gets by default.

| Ref | Role |
| --- | --- |
| `<user>/<topic>` | all work; branch off `preview` |
| `preview` | integration branch; **beta** releases are cut here |
| `master` | released state; **final** releases are cut here |
| `X.Y.Z.devN` tag | publishes a pre-release |
| `X.Y.Z` tag | publishes a final release |

Nothing publishes on a branch push. Only pushing a tag publishes.

| What a user runs | What they get |
| --- | --- |
| `pip install numerapi` / `pip install -U numerapi` | latest **final** version |
| `pip install 'numerapi==3.2.0.dev0'` | that exact pre-release |
| `pip install --pre numerapi` | latest including pre-releases |

## Conventions

- **Tags are bare version numbers and must match `setup.py` exactly:** `3.2.0`,
`3.2.0.dev0`. A `v` prefix is not allowed — CI rejects `v3.2.0` with an
explicit error.
- Use the canonical PEP 440 spelling with the dot: `3.2.0.dev0`, not `3.2.0dev0`.
Both normalize to the same release, but the canonical form avoids confusion.
- Pre-releases use `.devN`. Increment `N` for each beta on the same version line.
- **A version number can never be reused.** PyPI permanently rejects re-uploading
a version, even one that was deleted. If you burn a number, move to the next.

## Develop without releasing

```bash
git checkout preview && git pull
git checkout -b josh/some-feature
# ... work ...
git push -u origin josh/some-feature
gh pr create --base preview
```

Tests and lint run on every push. No tag means nothing is published. `preview`
can sit ahead of `master` indefinitely — that is what it is for. Leave
`setup.py` alone until you are actually cutting something.

## Cut a beta (from `preview`)

For beta users who need the code before it is stable.

```bash
git checkout preview && git pull

# 1. setup.py: numerapi_version = "3.2.0.dev0"
# 2. CHANGELOG.md: open an entry
# ## [3.2.0] - Unreleased
# - what changed
git commit -am "numerapi 3.2.0.dev0"
git push origin preview # publishes nothing

# 3. tag and push — this is the release event
git tag 3.2.0.dev0
git push origin 3.2.0.dev0
```

Verify:

```bash
gh run list --workflow=pypi.yml --limit 1 # expect success
pip install 'numerapi==3.2.0.dev0' # what beta users run
pip install -U numerapi # must NOT be the dev version
```

Tell beta users to install the exact version. Note that `pip index versions` and
the simple index can lag a few minutes behind a successful publish on CDN cache;
an exact-version install works immediately.

For the next beta, repeat with `.dev1`, `.dev2`, …

## Promote to a final release (from `master`)

Flip the version to final **on `preview`, as the last commit before merging**, so
`master` never holds a pre-release string and picks up the release version
atomically at merge.

```bash
git checkout preview && git pull

# 1. setup.py: numerapi_version = "3.2.0" (drop the .devN suffix)
# 2. CHANGELOG.md: date the entry, e.g. ## [3.2.0] - 2026-08-17
git commit -am "numerapi 3.2.0"
git push origin preview

# 3. merge preview into master
gh pr create --base master --head preview --title "numerapi 3.2.0"
gh pr merge <n> --merge

# 4. tag master
git checkout master && git pull
grep numerapi_version setup.py # must read exactly 3.2.0
git tag 3.2.0
git push origin 3.2.0

# 5. keep preview caught up so it does not drift
git checkout preview && git merge master && git push origin preview
```

Verify with `pip install -U numerapi`.

Then bump the numerapi pin in `tournament-monorepo` (`shared`, `init-round`,
`integration-test`, `compute-pickle-scheduler`). That PR moving through the
monorepo's own staging → master is what carries the new numerapi into staging
and prod images. Never pin a `.devN` version in anything that reaches prod.

## Hotfix a released version

Use this when `master` is released and `preview` holds unreleased work you do not
want to ship yet.

```bash
git checkout -b hotfix/3.2.1 master # branch off master, NOT preview
# fix + setup.py 3.2.1 + CHANGELOG entry
gh pr create --base master
# after merge:
git checkout master && git pull
git tag 3.2.1 && git push origin 3.2.1
git checkout preview && git merge master && git push origin preview
```

## Documentation

Read the Docs is fully automatic — there is nothing to tag or move.

- `/en/latest/` tracks `master`.
- `/en/stable/` tracks the greatest **non-pre-release** semver tag, so cutting
`3.2.0` promotes it; `3.2.0.dev0` is correctly ignored.

Do not create a tag or branch named `stable`. That overrides the automatic
behavior above, has to be force-moved by hand on every release, and silently goes
stale when someone forgets. One used to exist here and was removed for exactly
those reasons. The trade-off is that a docs-only fix reaches `/en/latest/`
immediately but does not appear on `/en/stable/` until the next release; if that
matters, cut a patch release.

## What CI enforces

`.github/workflows/pypi.yml` runs on tag pushes that start with a digit (and on
`v`-prefixed tags, solely to reject them). It refuses to publish unless:

1. **The tag has no `v` prefix.** `v3.2.0` fails with an error telling you to
re-tag as `3.2.0`.
2. **The tag matches `setup.py`.** Compared as normalized PEP 440 versions, so
`3.2.0dev0` and `3.2.0.dev0` are equivalent, but `3.2.0` against a `setup.py`
of `3.2.0.dev0` fails.
3. **Final releases point at a commit on `master`.** Pre-releases skip this
check, so betas can be cut from `preview` but a final one cannot.

`pytest.yml` (Python 3.10–3.14) and `ruff.yml` run on every push and PR.

## Troubleshooting

**`File already exists` on publish.** That version is already on PyPI. Bump to
the next number — you cannot re-upload, and you cannot fix it by deleting the
release on PyPI either.

**Tag mismatch error.** You tagged without bumping `setup.py`, or vice versa. Fix
`setup.py`, commit, delete the tag locally and on origin
(`git push origin :refs/tags/X.Y.Z`), then re-tag. Deleting a tag never publishes
anything.

**"Final release tags must point at a commit on master."** You tagged a
suffix-free version on `preview`. Either merge to `master` first, or cut it as a
`.devN` pre-release instead.

**Do not retro-tag old releases.** Any new tag starting with a digit triggers a
publish attempt that will fail on a duplicate version. Historical tags are
inconsistent (some `v`-prefixed, some not, several `.devN` tags that published
final versions before the guards existed) — leave them as they are.

**Do not delete the `3.0.0.dev2` tag.** The commit it points at is on no branch,
and that tag is the only thing keeping the source of the published 3.0.0
reachable.
33 changes: 10 additions & 23 deletions docs/round-score-configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Each item includes:
consistent with other date fields in numerapi. GraphQL float and integer fields
retain their normal Python JSON types.

## Migrating from legacy multiplier keys
## Legacy multiplier keys removed in 3.0.0

Before 2.24.0, `list_rounds()` requested server compatibility fields. For a
Signals round, a response could look like this even though the payout scores
Expand All @@ -34,8 +34,8 @@ were Alpha and MPC:
}
```

In 2.24.0 the exact identities are available without knowing score names in
advance:
In 3.0.0, the exact identities are the only payout configuration returned by
`list_rounds()`:

```python
{
Expand All @@ -57,29 +57,16 @@ advance:
"isPayout": True,
"defaultMultiplier": 0.8,
},
],
"defaultCorrMultiplier": None,
"defaultMmcMultiplier": None,
]
}
```

The six established Corr/MMC keys (`min`, `max`, and `default` for each) stay
in the returned round dictionary throughout numerapi 2.x. They are now
identity-safe projections: Corr keys select only a payout config whose `name`
is exactly `correlation`, MMC keys select only a payout config whose `name` is
exactly `meta_model_contribution`, and the keys are `None` when there is no
exact match. Alpha and FNC are never projected as Corr; MPC is never projected
as MMC. If multiple exact payout configs exist, the projection uses the config
with the newest `roundNumberStart`, then compares the numeric `version` values
as integers and uses `id` for a numeric-version tie. If multiple configs at the
newest start contain a non-numeric future version, the compatibility keys are
`None` rather than guessing an order. The complete list remains available
unchanged in either case.

These six compatibility keys are scheduled for removal in numerapi 3.0.0.
`list_rounds()` never exposed the three legacy TC multiplier fields, so this
migration does not introduce them. Code should migrate now by filtering
`roundScoreConfigs`, normally starting with `isPayout`.
The six Corr/MMC compatibility keys (`min`, `max`, and `default` for each) are
no longer added to the returned round dictionary. `list_rounds()` never
exposed the three legacy TC multiplier fields. Code should filter
`roundScoreConfigs`, normally starting with `isPayout`, and preserve each
configuration's `name`, `version`, and `scoreConfigId` rather than projecting
different scores into Corr or MMC roles.

## Deprecated performance endpoint

Expand Down
Loading
Loading