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
1 change: 1 addition & 0 deletions .claude/commands/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Run the following checks:
- Refactor if necessary
- Approach should be consistent across all concrete implementations
2. Should build cleanly and 'Definition of Done' criteria should all be met
3. In the first top 40 lines of each script in ./examples there's an example CLI command. Ignoring invalid_algo.py, make sure these work. Prefix them with `poetry run` otherwise they won't work.
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ Optional extras:
ctx.time_to_gate_closure() rather than hardcoding offsets.
- **time.sleep() pauses the real clock, not the simulated one.** The
validation pipeline catches this. Use ctx.wait() for simulated delays.
- **Use `poetry run python` instead of `python` or `python3`** as this loads
the necessary poetry settings and ensures the correct python path.

## Makefile targets

Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install lint typecheck test ci test-notebooks execute-notebooks
.PHONY: install lint typecheck test ci test-notebooks execute-notebooks clear-notice

install:
poetry install
Expand All @@ -20,3 +20,6 @@ test-notebooks:

execute-notebooks:
poetry run jupyter nbconvert --to notebook --execute --inplace notebooks/*.ipynb

clear-notice:
rm -f ~/.config/nexa/notice_shown 2>/dev/null
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,14 @@ nexa compare \
--products NO1_DA --data-dir ./data \
--output reports/comparison.html # .html or .json

# Or select individual classes from a single file with name:path:ClassName
nexa compare \
"conservative:examples/multi_algo_comparison.py:ConservativeAlgo" \
"moderate:examples/multi_algo_comparison.py:ModerateAlgo" \
"aggressive:examples/multi_algo_comparison.py:AggressiveAlgo" \
--exchange nordpool --start 2026-03-01 --end 2026-03-31 \
--products NO1_DA --data-dir ./data

nexa validate my_algo.py --exchange nordpool
nexa compile my_algo.py --output my_algo.so
```
Expand Down
2 changes: 1 addition & 1 deletion examples/ml_da_algo.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

Run::

python python examples/ml_da_algo.py --data-dir tests/fixtures \\
python examples/ml_da_algo.py --data-dir tests/fixtures \\
--start 2026-03-01 --end 2026-03-31 --zone NO1

The script will train a model from the DA price history in the Parquet
Expand Down
8 changes: 5 additions & 3 deletions examples/multi_algo_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

python examples/multi_algo_comparison.py

Or via the CLI::
Or via the CLI (use ``name:path:ClassName`` to select individual classes from
this file)::

nexa compare \\
"conservative:examples/multi_algo_comparison.py" \\
"aggressive:examples/multi_algo_comparison.py" \\
"conservative:examples/multi_algo_comparison.py:ConservativeAlgo" \\
"moderate:examples/multi_algo_comparison.py:ModerateAlgo" \\
"aggressive:examples/multi_algo_comparison.py:AggressiveAlgo" \\
--exchange nordpool \\
--start 2026-03-01 \\
--end 2026-03-31 \\
Expand Down
81 changes: 72 additions & 9 deletions src/nexa_backtest/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import importlib.util
import inspect
import os
import sys
from datetime import datetime
from decimal import Decimal
Expand Down Expand Up @@ -107,6 +108,13 @@ def cli() -> None:
"(.onnx → ONNX, .pkl/.joblib → scikit-learn). Repeat for multiple models."
),
)
@click.option(
"--quiet",
"-q",
is_flag=True,
default=False,
help="Suppress non-essential output including the first-run notice.",
)
def run_command(
algo_file: str,
exchange: str,
Expand All @@ -119,6 +127,7 @@ def run_command(
run_validation: bool,
strict: bool,
model_specs: tuple[str, ...],
quiet: bool,
) -> None:
"""Run a backtest from ALGO_FILE and print the PnL summary.

Expand Down Expand Up @@ -194,6 +203,11 @@ def run_command(
except Exception as exc:
raise click.ClickException(f"Failed to write output to '{output}': {exc}") from exc

if not quiet and not os.environ.get("NEXA_QUIET"):
from nexa_backtest.cli.notice import maybe_show_notice

maybe_show_notice()


@cli.command("compare")
@click.argument("algo_specs", nargs=-1, required=True)
Expand Down Expand Up @@ -237,6 +251,13 @@ def run_command(
"Summary is always printed to stdout."
),
)
@click.option(
"--quiet",
"-q",
is_flag=True,
default=False,
help="Suppress non-essential output including the first-run notice.",
)
def compare_command(
algo_specs: tuple[str, ...],
exchange: str,
Expand All @@ -246,16 +267,28 @@ def compare_command(
data_dir: str,
capital: float,
output: str | None,
quiet: bool,
) -> None:
"""Run multiple algos against the same data and compare results.

ALGO_SPECS are algo file paths, optionally prefixed with a display name:
ALGO_SPECS are algo file paths, optionally prefixed with a display name
and/or a class name:

\b
# Separate files, one class each
nexa compare conservative:algos/conservative.py aggressive:algos/aggressive.py \\
--exchange nordpool --start 2026-03-01 --end 2026-03-31 \\
--products NO1_DA --data-dir ./data

\b
# Single file containing multiple SimpleAlgo subclasses
nexa compare \\
conservative:examples/multi_algo_comparison.py:ConservativeAlgo \\
moderate:examples/multi_algo_comparison.py:ModerateAlgo \\
aggressive:examples/multi_algo_comparison.py:AggressiveAlgo \\
--exchange nordpool --start 2026-03-01 --end 2026-03-31 \\
--products NO1_DA --data-dir ./data

If no display name is given (just a path), the filename without extension
is used as the display name. Maximum 8 algos.
"""
Expand All @@ -269,20 +302,25 @@ def compare_command(

algos: dict[str, Any] = {}
for spec in algo_specs:
if ":" in spec:
display_name, _, algo_path = spec.partition(":")
parts = spec.split(":", 2)
if len(parts) == 3:
display_name, algo_path, class_name = parts
elif len(parts) == 2:
display_name, algo_path = parts
class_name = None
else:
algo_path = spec
display_name = Path(algo_path).stem
class_name = None

if display_name in algos:
raise click.ClickException(
f"Duplicate display name '{display_name}'. "
"Use 'name:path' format to provide unique names."
"Use 'name:path' or 'name:path:ClassName' format to provide unique names."
)

try:
algo_or_class = _load_algo(algo_path)
algo_or_class = _load_algo(algo_path, class_name=class_name)
except (NexaBacktestError, AlgoError) as exc:
raise click.ClickException(str(exc)) from exc

Expand Down Expand Up @@ -330,6 +368,11 @@ def compare_command(
f"Failed to write comparison output to '{output}': {exc}"
) from exc

if not quiet and not os.environ.get("NEXA_QUIET"):
from nexa_backtest.cli.notice import maybe_show_notice

maybe_show_notice()


# ------------------------------------------------------------------
# Algo discovery helpers
Expand All @@ -353,25 +396,34 @@ def _load_module(path: str) -> ModuleType:
return module


def _load_algo(path: str) -> type[SimpleAlgo] | Any:
def _load_algo(path: str, *, class_name: str | None = None) -> type[SimpleAlgo] | Any:
"""Find a runnable algo in ``path``.

Accepts either a unique :class:`~nexa_backtest.algo.SimpleAlgo` subclass
or a unique ``@algo``-decorated async function. ``@algo`` functions take
priority when both are present; an error is raised if multiple candidates
of either kind are found.

When ``class_name`` is provided, the discovered subclasses are filtered to
the one with that exact name. This allows a single file containing
multiple :class:`~nexa_backtest.algo.SimpleAlgo` subclasses to be used
with the ``nexa compare`` command via the ``name:path:ClassName`` spec
format.

Args:
path: Path to a Python file.
class_name: Optional name of the specific ``SimpleAlgo`` subclass to
load. When omitted the file must contain exactly one subclass.

Returns:
A :class:`~nexa_backtest.algo.SimpleAlgo` subclass (to be instantiated
by the caller) or an ``@algo``-decorated callable (ready to pass
directly to :class:`~nexa_backtest.engines.backtest.BacktestEngine`).

Raises:
:class:`click.ClickException`: If no valid algo is found, or if
multiple candidates are found.
:class:`click.ClickException`: If no valid algo is found, if multiple
candidates are found without a ``class_name`` selector, or if the
named class does not exist in the file.
"""
module = _load_module(path)

Expand All @@ -395,12 +447,23 @@ def _load_algo(path: str) -> type[SimpleAlgo] | Any:
)
return algo_fns[0]

if class_name is not None:
matched = [c for c in subclasses if c.__name__ == class_name]
if not matched:
available = ", ".join(c.__name__ for c in subclasses) if subclasses else "none"
raise click.ClickException(
f"Class '{class_name}' not found in '{path}'. "
f"Available SimpleAlgo subclasses: {available}."
)
return matched[0]

if subclasses:
if len(subclasses) > 1:
names = ", ".join(c.__name__ for c in subclasses)
raise click.ClickException(
f"Multiple SimpleAlgo subclasses found in '{path}': {names}. "
"Move the unused classes to a separate file."
"Use 'name:path:ClassName' format to select a specific class, "
"or move the unused classes to a separate file."
)
return subclasses[0]

Expand Down
52 changes: 52 additions & 0 deletions src/nexa_backtest/cli/notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""First-run support notice for the nexa CLI."""

import os
import sys
from pathlib import Path

from nexa_backtest import __version__

NOTICE = """\

--------------------------------------------------------------------------------
Thanks for using nexa-backtest.
Support tiers & priority fixes: https://github.com/phasenexa/nexa-backtest/blob/main/SUPPORT.md
Community & questions: https://github.com/phasenexa/nexa-backtest/discussions
--------------------------------------------------------------------------------"""


def _config_dir() -> Path:
"""Return the nexa config directory.

On Windows uses ``%APPDATA%\\nexa`` (roaming profile), falling back to
``~/AppData/Roaming/nexa``. On all other platforms uses
``$XDG_CONFIG_HOME/nexa``, falling back to ``~/.config/nexa``.
"""
if sys.platform == "win32":
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
else:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "nexa"


def _marker_path() -> Path:
return _config_dir() / "notice_shown"


def maybe_show_notice() -> None:
"""Show the support notice once, then never again.

Prints the notice to stderr on first invocation of any nexa CLI command.
After displaying, writes a marker file containing the current version so
the notice is not shown again. On filesystem errors, silently does nothing.
"""
try:
if _marker_path().exists():
return

print(NOTICE, file=sys.stderr)

_config_dir().mkdir(parents=True, exist_ok=True)
_marker_path().write_text(__version__)
except OSError:
pass
14 changes: 14 additions & 0 deletions src/nexa_backtest/cli/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import json
import os
import sys

import click
Expand Down Expand Up @@ -56,13 +57,21 @@
"Repeat for multiple models."
),
)
@click.option(
"--quiet",
"-q",
is_flag=True,
default=False,
help="Suppress non-essential output including the first-run notice.",
)
def validate_command(
algo_file: str,
exchange: str,
strict: bool,
skip: str,
output_json: bool,
model_specs: tuple[str, ...],
quiet: bool,
) -> None:
"""Validate ALGO_FILE against the target exchange before running.

Expand Down Expand Up @@ -111,6 +120,11 @@ def validate_command(
)

# Exit codes
if not quiet and not os.environ.get("NEXA_QUIET"):
from nexa_backtest.cli.notice import maybe_show_notice

maybe_show_notice()

if not result.passed:
if strict and result.warning_count > 0 and result.error_count == 0:
sys.exit(2)
Expand Down
4 changes: 2 additions & 2 deletions src/nexa_backtest/exchanges/nordpool.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,12 @@ def gate_closure_offset(self, product_type: str = "IDC") -> timedelta:
"""Return the gate closure offset for a given product type.

Args:
product_type: ``"DA"`` or ``"IDC"``.
product_type: ``"DA"``, ``"IDA"``, or ``"IDC"``.

Returns:
Time before delivery start that gate closes.
"""
if product_type == "DA":
if product_type in ("DA", "IDA"):
return NORDPOOL_DA_GATE_CLOSURE
return NORDPOOL_IDC_GATE_CLOSURE

Expand Down
Loading
Loading