Skip to content
Merged
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
101 changes: 101 additions & 0 deletions .github/workflows/research-lock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Research lock

on:
pull_request:

permissions:
contents: read

jobs:
verify:
name: Frozen research install (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install the lock resolver
run: python -m pip install uv==0.13.4

- name: Validate the committed lock
run: |
uv lock --check
git diff --exit-code -- uv.lock

- name: Install the frozen research environment
env:
UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/crypto-research-environment
run: |
uv sync --frozen --extra research --no-editable
uv pip check --python "${UV_PROJECT_ENVIRONMENT}/bin/python"

- name: Verify installed versions, origins and public import
env:
UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/crypto-research-environment
run: |
cd "${RUNNER_TEMP}"
"${UV_PROJECT_ENVIRONMENT}/bin/python" -I - "${GITHUB_WORKSPACE}" <<'PY'
import importlib
import importlib.metadata
import json
from pathlib import Path
import sys
import sysconfig
import tomllib

source = Path(sys.argv[1])
project = tomllib.loads((source / "pyproject.toml").read_text())["project"]
lock = tomllib.loads((source / "uv.lock").read_text())
prefix = "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@"
pins = [value.removeprefix(prefix) for value in project["dependencies"] if value.startswith(prefix)]
assert len(pins) == 1
qpk = importlib.metadata.distribution("quant-platform-kit")
direct_url = json.loads(qpk.read_text("direct_url.json"))
assert direct_url["vcs_info"]["commit_id"] == pins[0]
assert direct_url["vcs_info"]["vcs"] == "git"
assert direct_url["url"] == "https://github.com/QuantStrategyLab/QuantPlatformKit.git"

installed = {name: importlib.metadata.version(name) for name in ("numpy", "pandas")}
locked = {
name: [package["version"] for package in lock["package"] if package["name"] == name]
for name in installed
}
assert all(installed[name] in locked[name] for name in installed)
assert sys.prefix != sys.base_prefix and sys.flags.isolated
roots = tuple(Path(sysconfig.get_path(key)).resolve() for key in ("purelib", "platlib"))
assert all(root.is_relative_to(Path(sys.prefix).resolve()) for root in roots)
modules = ("crypto_strategies", "quant_platform_kit", "numpy", "pandas", "crypto_strategies.entrypoints")
assert not any(name in sys.modules for name in modules)
network_attempts = []

def network_guard(event, arguments):
if event in {"socket.connect", "socket.bind", "socket.getaddrinfo"}:
network_attempts.append(event)
raise RuntimeError("network access attempted during the import probe")

sys.addaudithook(network_guard)
importlib.import_module("crypto_strategies.entrypoints")
origins = {name: str(Path(sys.modules[name].__file__).resolve()) for name in modules}
assert all(any(Path(origin).is_relative_to(root) for root in roots) for origin in origins.values())
assert not network_attempts
print(json.dumps({
"runtime_import_succeeded": True,
"installed_versions": installed,
"locked_versions": locked,
"qpk_commit": pins[0],
"module_origins": origins,
"network_attempts": network_attempts,
}, indent=2))
PY
65 changes: 40 additions & 25 deletions docs/adr/0002-declare-runtime-monitoring-dependencies.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0002: Declare runtime monitoring dependencies

Status: proposed; dependency adoption requires owner approval
Status: accepted

## Context

Expand Down Expand Up @@ -55,7 +55,7 @@ configured proxy was unreachable. That was an environment failure, not evidence
of the missing dependency. The successful CI installations above establish the
normal-install result independently.

## Proposed decision
## Decision

Declare `numpy` and `pandas` as normal Crypto runtime requirements, preserving
the QPK source pin, package version, runtime imports and strategy implementation.
Expand All @@ -71,23 +71,35 @@ existing constraints to pip. Constraints restrict resolution; they do not
install otherwise undeclared packages. This case verifies installation and
entrypoint-import compatibility, not the complete consumer runtime.

Keep the `research` extra unchanged. Add normal dependency edges to Crypto's
root `uv.lock` record using the already locked NumPy and Pandas nodes, and add
the corresponding normal metadata entries. No resolved package version,
artifact, source revision, resolution marker or research edge changes. This is
a five-line root metadata update, not a lock regeneration.
The initial runtime declaration change preserved the `research` extra and added
normal dependency edges to the root lock record without changing resolved
package versions. The lock reconciliation below is a separate change.

### Existing research lock inconsistency
### Shared project lock reconciliation

The baseline project requests `pandas>=3.1.0` for `research`, while the lock's
research metadata requests `>=3.0.5` and its Pandas node is 3.0.5. The mismatch
predates this proposal. Resolving it can change versions or the research
contract and is outside the authorized packaging declaration.
The project declares `pandas>=3.1.0` for `research`. The previous lock instead
recorded a `>=3.0.5` research edge and Pandas 3.0.5. The reconciled lock selects
Pandas 3.1.0 and records the unchanged declared constraint. Other package
versions and source revisions, including QPK, remain unchanged.

The lock is generated with uv 0.13.4 using
`uv lock --upgrade-package pandas==3.1.0`. The resolver updates the lock format
revision from 3 to 5 and reorders the existing resolution markers and root
dependency metadata. The marker set is unchanged. Pandas source and wheel
metadata come directly from that resolver output. CI validates the lock with
the same uv version; compatibility with earlier resolver versions is untested.

This repository has one shared `uv.lock`. Its Pandas selection applies to both
normal frozen sync and research sync; it is not an extra-specific lock. The
normal wheel requirements remain unbounded. Downstream consumer locks and the
Binance Pandas 3.0.3 installation constraint remain unchanged.

The QPK pin-consistency check validates shared Git references; it does not prove
general lock consistency or research-extra reproducibility. This proposal makes
no claim that the existing research lock is coherent. Reconciliation requires a
separate reviewed change and owner approval for any dependency-version change.
general lock consistency. Regression tests compare the research declarations,
locked metadata and selected versions. A separate CI job checks the committed
lock, performs a frozen research installation and verifies installed versions
and the public entrypoint import. Generating a lock candidate alone is not a
successful frozen-install check.

## Validation

Expand All @@ -107,8 +119,9 @@ with the existing Binance numeric constraints. Each job:
Offline tests cover the verifier's rejection paths. The previous CI jobs remain
intact, including strategy tests, Ruff, package build and shared-pin checks. No
existing skip or assertion is weakened. Runtime source is byte-for-byte
unchanged; normal installation and full repository CI must pass on the reviewed
proposal before it is considered ready for adoption review.
unchanged. Normal installation, frozen research installation and full repository
CI must pass on the independently reviewed change. Repository acceptance uses
automated checks, independent AI review and risk classification.

These checks cover the public entrypoint import and existing test suite. They do
not establish the dependency closure of every optional runtime module, exercise
Expand Down Expand Up @@ -141,14 +154,16 @@ establish a complete installation or preserve the current failure boundary.
## Consequences, adoption and rollback

The declaration enlarges the normal installation and participates in consumer
dependency resolution. Green CI is packaging evidence; it does not authorize a
production dependency update. The proposal remains a draft until the owner
approves adoption. No production pin or deployment changes with this proposal.

If adopted and later reverted, restore the previous consumer package and lock
state through a reviewed revert. Keep the reproduction record: reverting the
declaration restores the known normal-install gap. Resolve the separate
research lock inconsistency without silently changing its contract.
dependency resolution. The shared project lock selects Pandas 3.1.0 without
changing a downstream production pin or deploying a consumer. Green CI remains
packaging and test evidence; it does not authorize live trading or deployment.

The runtime declaration and shared-lock reconciliation can be reverted
separately through reviewed changes. Reverting the declaration restores the
known normal-install gap. Reverting only the lock reconciliation restores the
recorded research constraint mismatch; it does not remove the runtime
dependency declarations. Preserve the reproduction and verification records
when assessing either rollback.

[crypto-deps]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/ec0b330081213f56c3ff98164ec00ba26c10dfc9/pyproject.toml#L11-L19
[crypto-common]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/ec0b330081213f56c3ff98164ec00ba26c10dfc9/src/crypto_strategies/entrypoints/_common.py#L9-L14
Expand Down
43 changes: 43 additions & 0 deletions tests/test_qsl_compat_metadata.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import tomllib
from pathlib import Path

from packaging.requirements import Requirement
import pytest


ROOT = Path(__file__).resolve().parents[1]
QPK_REVISION = "c7646a7168b3dafa763ef7751a182d23e8de7790"
Expand Down Expand Up @@ -56,3 +59,43 @@ def test_qpk_pin_lock_and_ci_are_dependency_enabled() -> None:
assert "--no-deps" not in ci
assert "python -m pip install -e ." in ci
assert "python -m pip check" in ci


def test_research_requirements_match_lock_metadata() -> None:
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
lock = tomllib.loads((ROOT / "uv.lock").read_text())
requirements = [
Requirement(value)
for value in pyproject["project"]["optional-dependencies"]["research"]
]
root_package = next(package for package in lock["package"] if package["name"] == "crypto-strategies")
locked_requirements = [
requirement
for requirement in root_package["metadata"]["requires-dist"]
if requirement.get("marker") == "extra == 'research'"
]

assert sorted(locked_requirements, key=lambda item: item["name"]) == sorted(
[
{"name": requirement.name, "specifier": str(requirement.specifier), "marker": "extra == 'research'"}
for requirement in requirements
],
key=lambda item: item["name"],
)


@pytest.mark.parametrize("dependency", ["numpy", "pandas"])
def test_locked_research_versions_satisfy_declared_requirement(dependency: str) -> None:
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
lock = tomllib.loads((ROOT / "uv.lock").read_text())
requirement = next(
requirement
for value in pyproject["project"]["optional-dependencies"]["research"]
if (requirement := Requirement(value)).name == dependency
)
locked_versions = [package["version"] for package in lock["package"] if package["name"] == dependency]

assert locked_versions, f"{dependency} is missing from uv.lock"
assert all(requirement.specifier.contains(version) for version in locked_versions), (
f"{dependency} versions {locked_versions} do not satisfy {requirement.specifier}"
)
Loading