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
101 changes: 85 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ env:
PYTHON_VERSION: "3.12.13"
PIP_DISABLE_PIP_VERSION_CHECK: "1"

# Every gate installs third-party packages from the hash-locked requirements/
# files and then the project itself with `--no-deps`, so CI tests exactly the
# versions the image ships. DEVELOPMENT.md describes how to regenerate them.
jobs:
# ---------------------------------------------------------------- gate 1
gate1-lint:
Expand All @@ -53,8 +56,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
- name: ruff check
run: python -m ruff check src tests --output-format=github
# Format enforcement is scoped to the code this rebuild owns. The
Expand All @@ -76,8 +81,55 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
# `pip install --no-deps` trusts the lock, so prove the lock still honours
# pyproject.toml. `pip check` covers the base dependencies only; the script
# also walks the `test` and `dev` extras, so a pin bumped in pyproject.toml
# without regenerating requirements/ fails here instead of testing old code.
# It also holds every runtime pin equal across both files, so the suites
# run against the versions the image ships.
- name: The lock satisfies every declared requirement
run: |
python -m pip check
python - <<'PY'
import re
import sys
from importlib.metadata import PackageNotFoundError, distribution, version
from pathlib import Path

from packaging.requirements import Requirement
from packaging.utils import canonicalize_name

def pins(path):
text = Path(path).read_text()
return dict(re.findall(r"^([A-Za-z0-9._-]+)==([^\s;\\]+)", text, re.M))

project = "idea2strategy-backtest-engine"
stale = []
runtime, dev = pins("requirements/runtime.txt"), pins("requirements/dev.txt")
for name, pinned in sorted(runtime.items()):
if dev.get(name) != pinned:
stale.append(f"{name}: runtime.txt pins {pinned}, dev.txt pins {dev.get(name)}")
for raw in distribution(project).requires or []:
req = Requirement(raw)
if canonicalize_name(req.name) == project:
continue # `dev` names `test` through the project itself
if req.marker and not any(req.marker.evaluate({"extra": e}) for e in ("test", "dev")):
continue
try:
found = version(req.name)
except PackageNotFoundError:
stale.append(f"{req}: missing from requirements/dev.txt")
continue
if not req.specifier.contains(found, prereleases=True):
stale.append(f"{req}: the lock pins {found}")
if stale:
sys.exit("requirements/ is stale, regenerate it (DEVELOPMENT.md):\n " + "\n ".join(stale))
print("requirements/dev.txt satisfies pyproject.toml, extras included")
PY
- name: mypy
run: python -m mypy

Expand All @@ -92,8 +144,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[test]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
# PYTHONUNBUFFERED and `--timeout` exist because this gate once died at the
# 20-minute job limit having printed *nothing*: pytest block-buffers when
# stdout is a pipe, so the kill discarded every line and the log named no
Expand Down Expand Up @@ -125,8 +179,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
- name: Contract tests
run: python -m pytest tests/test_contracts.py -p no:cacheprovider -q
# The audit found two copies of the COM06 fixture that had already drifted:
Expand All @@ -153,8 +209,14 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
# The runtime lock alone, as in the image: a module that imports something
# only the test or dev lock provides fails here, and `pip check` fails if
# pyproject.toml declares a runtime dependency the lock does not satisfy.
- name: Install project (validates packaging and console entry points)
run: python -m pip install -e .
run: |
python -m pip install --require-hashes -r requirements/runtime.txt
python -m pip install --no-deps -e .
python -m pip check
# Import-only: it must be possible to load the API app, the worker and the
# persistence layer without a database, because the runtime must not touch
# one at import time.
Expand Down Expand Up @@ -194,8 +256,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
- name: Contribution contract tests
run: >-
python -m pytest tests/persistence/test_migration_contribution.py
Expand Down Expand Up @@ -251,7 +315,8 @@ jobs:
cache: pip
- name: Install project and audit tooling
run: |
python -m pip install -e .
python -m pip install --require-hashes -r requirements/runtime.txt
python -m pip install --no-deps -e .
python -m pip install pip-audit
- name: pip-audit
# Fails when any installed dependency has a known advisory. `--strict`
Expand Down Expand Up @@ -322,8 +387,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[test]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
- name: Docker is available
run: docker version
- name: Pre-pull the database image
Expand Down Expand Up @@ -403,8 +470,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[test]"
- name: Install (hash-locked)
run: |
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
# The local and S3 adapters must satisfy one identical contract: same key,
# same checksum, same metadata semantics. The same contract test therefore
# runs against both backends, and the S3 leg must not skip here.
Expand Down
36 changes: 36 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,42 @@ python -m mypy
테스트 전용 데이터이며 시드도 마이그레이션도 아닙니다.
- 테스트 먼저 씁니다. 카드마다 실패하는 테스트를 먼저 커밋합니다.

## 의존성 잠금

`pyproject.toml` 은 허용 범위를, `requirements/` 는 그 범위 안에서 실제로 설치하는 정확한
버전과 해시를 기록합니다.

| 파일 | 내용 | 사용하는 곳 |
|---|---|---|
| `requirements/runtime.txt` | 런타임 의존성 | `Dockerfile`, CI gate5·gate7 |
| `requirements/dev.txt` | 런타임 + `test`·`dev` extra | 나머지 CI gate, 로컬 개발 |

설치는 항상 두 단계입니다. 잠금 파일을 해시 검증으로 설치한 뒤 프로젝트 자체는 의존성 없이
설치합니다.

```text
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
```

재생성은 저장소 루트에서 uv(0.13.1에서 확인)로 합니다.

```text
uv pip compile pyproject.toml --python-version 3.12 --universal --generate-hashes -o requirements/runtime.txt
uv pip compile pyproject.toml --python-version 3.12 --universal --generate-hashes --extra dev -o requirements/dev.txt
```

- `pyproject.toml` 의 의존성을 바꾸면 같은 커밋에서 두 파일을 다시 생성합니다. 출력 파일이
이미 있으면 uv는 기존 버전을 유지하고 바뀐 요구만 다시 풉니다.
- 버전을 올릴 때만 `--upgrade-package <이름>` (전체는 `--upgrade`) 을 붙이고, 두 명령에 같은
옵션을 줍니다. 두 파일에 함께 있는 패키지는 버전이 같아야 합니다.
- `--universal` 이므로 어느 OS에서 생성해도 결과가 같고, 플랫폼 전용 패키지는 환경 마커로
남습니다. Linux amd64·arm64 이미지와 macOS·Windows 개발 환경이 같은 파일을 씁니다.
- 잠금이 어긋나면 CI가 실패합니다. gate5와 이미지 빌드는 `pip check` 로 런타임 의존성을,
gate2는 `test`·`dev` extra와 두 파일 사이의 버전 일치까지 검사합니다.
- 빌드 백엔드 `hatchling==1.27.0` 은 `pyproject.toml` 에서 버전만 고정되어 있고 해시
검증 대상이 아닙니다. pip가 격리된 빌드 환경에 따로 설치하며 이미지에는 남지 않습니다.

## 고정 정책

- 슬리피지 0.05%, 수수료 0.2%. `backtest.runs` 는 `fee_policy_id`,
Expand Down
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,21 @@ ENV PYTHONDONTWRITEBYTECODE=1 \

WORKDIR /app

# Third-party packages come only from the hash-locked runtime set, as wheels, so
# a rebuild installs the same bytes and a tampered or substituted file fails.
# See DEVELOPMENT.md for how the lock is regenerated.
COPY requirements/runtime.txt ./requirements/runtime.txt
RUN pip install --no-cache-dir --require-hashes --only-binary=:all: \
-r requirements/runtime.txt

COPY pyproject.toml README.md ./
COPY src ./src
COPY db ./db

RUN pip install --no-cache-dir .
# `--no-deps`: the project itself adds nothing that is not already locked, and
# `pip check` fails the build if pyproject.toml declares something the lock lacks.
RUN pip install --no-cache-dir --no-deps . \
&& pip check

# The runtime does not invoke Perl. Debian marks perl-base Essential, but this
# slim image has no installed reverse dependency on it. Purge it after the
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,18 @@ Python 3.12 (`>=3.12,<3.13`) 전용입니다.

```text
python -m venv .venv
.venv\Scripts\python -m pip install -e ".[dev]"
.venv\Scripts\python -m pip install --require-hashes -r requirements\dev.txt
.venv\Scripts\python -m pip install --no-deps -e .
.venv\Scripts\python -m pytest # Docker 없는 빠른 단위 스위트
.venv\Scripts\python -m pytest -m docker # Testcontainers PostgreSQL 16 통합
.venv\Scripts\python -m ruff check src tests
.venv\Scripts\python -m mypy
.venv\Scripts\backtest-api # 환경변수 필요, 아래 참조
```

의존성은 해시로 잠긴 `requirements/` 에서만 설치합니다. 재생성 방법은 `DEVELOPMENT.md` 의
"의존성 잠금"을 보십시오.

기본 `pytest` 실행은 `-m 'not docker'` 로 설정되어 있어 Docker를 건드리지 않습니다.
`-m docker` 를 주면 그 설정을 덮어써 통합 스위트만 실행합니다.

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ dependencies = [
"psutil==7.2.2",
"psycopg[binary]>=3.2,<4.0",
"pyarrow==25.0.0",
"sqlalchemy>=2.0,<3.0",
# Capped below the next minor, not the next major: SQLAlchemy 2.1 retyped `Result`,
# which failed the type gate, so moving past 2.1 is a deliberate bump.
"sqlalchemy>=2.0,<2.2",
"tzdata==2026.3",
"uvicorn==0.52.1",
]
Expand All @@ -38,6 +40,7 @@ test = [
]
dev = [
"idea2strategy-backtest-engine[test]",
# The same release data-pipeline pins, so the two type gates agree. Bump both together.
"mypy==2.3.0",
"mutmut==3.3.1",
"ruff==0.16.1",
Expand Down
Loading
Loading