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
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import or execute that strategy Python.

| Scope | Status |
| --- | --- |
| Latest public release | [v1.6.0](https://github.com/vntrevx/NFI_BackTestEngine/releases/tag/v1.6.0) |
| Latest public release | [v1.6.1](https://github.com/vntrevx/NFI_BackTestEngine/releases/tag/v1.6.1) |
| Five-year Spot | Certified independently by v1.0.0 |
| Five-year Futures | Certified independently by v1.1.0 |
| Current `main` | v1.6.0 Full Native Strategy release; no new combined Full X7 certification claim |
| Current `main` | v1.6.1 product update; no new combined Full X7 certification claim |

The Spot and Futures certificates remain valid for their own sealed strategy,
configuration, data, wheel, and host. They are not a same-candidate Spot-versus-Futures
Expand Down Expand Up @@ -158,8 +158,26 @@ nfi-bte --version
nfi-bte doctor
```

The latest public installer and a source checkout of `main` return `nfi-bte 1.6.0`,
the Full Native Strategy release.
The latest public installer and a source checkout of `main` return `nfi-bte 1.6.1`.

### Keep the CLI updated

Update an installed CLI to the latest public release with one command:

```text
nfi-bte update
```

Successful commands check GitHub Releases at most once every 24 hours. When a newer release is
available, the CLI prints one line to stderr without changing the command result:

```text
Update available: 1.6.0 -> 1.6.1. Run `nfi-bte update`.
```

The updater reuses the active `uv tool`, `pipx`, or Python environment. Source
checkouts remain developer-managed and must be updated through Git and `uv sync`.
Set `NFI_BTE_DISABLE_UPDATE_CHECK=1` to disable the automatic version check.

## Quick start

Expand Down Expand Up @@ -316,6 +334,7 @@ cleanup](docs/clean.md).
| Command | Purpose |
| --- | --- |
| `nfi-bte run` | Run or resume native research |
| `nfi-bte update` | Update the installed CLI to the latest release |
| `nfi-bte strategy check ...` | Check a newly downloaded NFI revision |
| `nfi-bte doctor` | Inspect the current machine |
| `nfi-bte reference research ...` | Run official Freqtrade |
Expand Down
35 changes: 35 additions & 0 deletions docs/releases/v1.6.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# NFI Backtest Engine v1.6.1

v1.6.1 adds an in-product update path for installed NFI Backtest Engine CLIs.

## Update command

Run one command to install the latest stable GitHub release:

```text
nfi-bte update
```

The updater selects the wheel for the current supported platform from GitHub
Releases, verifies its published SHA-256 digest, and delegates installation to the
active `uv tool`, `pipx`, or Python environment. A source checkout remains
developer-managed and is never replaced by the command.

## Update notice

Successful commands check the latest stable GitHub release at most once every 24
hours. When the installed version is older, the CLI writes a single notice to stderr
without changing the command result:

```text
Update available: 1.6.0 -> 1.6.1. Run `nfi-bte update`.
```

Set `NFI_BTE_DISABLE_UPDATE_CHECK=1` to disable this check. CI and source checkouts
skip it automatically.

## Certification boundary

This product update changes CLI distribution behavior, not simulation semantics.
It remains a product release with `combined_full_x7_certified=false`. Existing Spot
and Futures certificates remain bound to their original versions and evidence.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "nfi-backtest-engine"
version = "1.6.0"
version = "1.6.1"
description = "Exact-parity research backtesting infrastructure for NFI strategies"
readme = "README.md"
requires-python = ">=3.12,<3.15"
Expand Down
13 changes: 12 additions & 1 deletion python/nfi_backtest_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
from .commands import (
system as system_commands,
)
from .commands import (
update as update_commands,
)
from .config_loader import load_effective_config
from .errors import NfiBacktestError
from .hardware import create_execution_profile
Expand All @@ -44,6 +47,7 @@
)
from .reference_runtime import load_reference_leverage_tiers
from .state_trace import TraceMismatch
from .update_check import maybe_print_update_notice


def _add_project_setup_arguments(parser: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -105,6 +109,8 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
subcommands = parser.add_subparsers(dest="command_name", required=True)

subcommands.add_parser("update", help="update the installed CLI to the latest release")

fixture = subcommands.add_parser("fixture", help="manage benchmark fixtures")
fixture_commands = fixture.add_subparsers(dest="fixture_command", required=True)
validate = fixture_commands.add_parser("validate", help="validate and hash-check a fixture")
Expand Down Expand Up @@ -1301,6 +1307,8 @@ def _dispatch_command(
return certify_commands.execute(args)
if command_name in release_commands.COMMAND_NAMES:
return release_commands.execute(args)
if command_name in update_commands.COMMAND_NAMES:
return update_commands.execute(args)
raise AssertionError(f"unhandled command: {command_name}")


Expand All @@ -1313,7 +1321,10 @@ def main(argv: Sequence[str] | None = None) -> int:
raw_args = raw_args[:separator]
args = build_parser().parse_args(raw_args)
try:
return _dispatch_command(args, benchmark_command=benchmark_command)
result = _dispatch_command(args, benchmark_command=benchmark_command)
if result == 0 and args.command_name != "update":
maybe_print_update_notice(__version__)
return result
except ParityMismatch as exc:
print(str(exc), file=sys.stderr)
return 1
Expand Down
129 changes: 129 additions & 0 deletions python/nfi_backtest_engine/commands/update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Installed CLI update command."""

from __future__ import annotations

import hashlib
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from urllib.request import Request, urlopen

from .. import __version__
from ..errors import NfiBacktestError
from ..update_check import LatestRelease, fetch_latest_release, is_newer_release

COMMAND_NAMES = frozenset({"update"})
DOWNLOAD_TIMEOUT_SECONDS = 30.0


def _is_source_checkout() -> bool:
repository_root = Path(__file__).resolve().parents[3]
return (repository_root / "pyproject.toml").is_file() and (
repository_root / ".git"
).exists()


def _wheel_suffix() -> str:
system = platform.system()
machine = platform.machine().lower()
if system == "Linux" and machine in {"x86_64", "amd64"}:
return "manylinux2014_x86_64.whl"
if system == "Linux" and machine in {"aarch64", "arm64"}:
return "manylinux2014_aarch64.whl"
if system == "Darwin" and machine == "arm64":
return "macosx_11_0_arm64.whl"
if system == "Windows" and machine in {"amd64", "x86_64"}:
return "win_amd64.whl"
raise NfiBacktestError(f"no release wheel is available for {system} {machine}")


def _download_release_wheel(release: LatestRelease, destination: Path) -> Path:
suffix = _wheel_suffix()
matching = [asset for asset in release.assets if asset.name.endswith(suffix)]
if len(matching) != 1:
raise NfiBacktestError(
f"expected one {suffix} wheel in v{release.version}; found {len(matching)}"
)
asset = matching[0]
headers = {"User-Agent": "nfi-bte updater"}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(asset.download_url, headers=headers)
wheel_path = destination / asset.name
hasher = hashlib.sha256()
with (
urlopen(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response,
wheel_path.open("wb") as output,
):
while chunk := response.read(1024 * 1024):
output.write(chunk)
hasher.update(chunk)
if hasher.hexdigest() != asset.sha256:
wheel_path.unlink(missing_ok=True)
raise NfiBacktestError("downloaded wheel SHA-256 differs from the GitHub release")
return wheel_path


def _select_upgrade_command(wheel_path: Path) -> list[str]:
executable = Path(sys.executable).resolve().as_posix().lower()
uv = shutil.which("uv")
if uv is not None and "/uv/tools/" in executable:
return [
uv,
"tool",
"install",
"--force",
"--python",
"3.12",
str(wheel_path),
]

pipx = shutil.which("pipx")
if pipx is not None and "/pipx/venvs/" in executable:
return [pipx, "install", "--force", str(wheel_path)]

if uv is not None:
return [
uv,
"pip",
"install",
"--python",
sys.executable,
"--upgrade",
str(wheel_path),
]
return [sys.executable, "-m", "pip", "install", "--upgrade", str(wheel_path)]


def execute(args: object) -> int:
"""Upgrade an installed CLI through the environment's package manager."""
if getattr(args, "command_name", None) != "update":
raise AssertionError("unhandled update command")
if _is_source_checkout():
raise NfiBacktestError(
"self-update is unavailable in a source checkout; update the checkout "
"and run `uv sync --extra dev --frozen`"
)

try:
release = fetch_latest_release()
except (OSError, ValueError) as exc:
raise NfiBacktestError(f"could not read the latest GitHub release: {exc}") from exc
if not is_newer_release(release.version, __version__):
print(f"Already up to date: {__version__}.")
return 0

with tempfile.TemporaryDirectory(prefix="nfi-bte-update-") as temporary_directory:
wheel_path = _download_release_wheel(release, Path(temporary_directory))
command = _select_upgrade_command(wheel_path)
completed = subprocess.run(command, check=False)
if completed.returncode != 0:
raise NfiBacktestError("update failed; the installed version was left unchanged")

print(f"Updated to NFI Backtest Engine {release.version}.")
return 0
Loading
Loading