diff --git a/pyproject.toml b/pyproject.toml index d2c9701..146c5dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,9 @@ Changelog = "https://github.com/initial-d/ml-quant-trading/blob/main/CHANGELOG.m [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +"mlquant.configs" = ["*.yaml"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra -q --strict-markers" diff --git a/src/mlquant/cli/main.py b/src/mlquant/cli/main.py index e4581ad..3953703 100644 --- a/src/mlquant/cli/main.py +++ b/src/mlquant/cli/main.py @@ -9,6 +9,7 @@ import json import math import pickle +from importlib.resources import as_file, files from pathlib import Path import click @@ -104,13 +105,25 @@ def cli() -> None: @click.option( "--config", "config_path", - default="configs/small.yaml", - show_default=True, + default=None, + show_default="packaged small config", type=click.Path(exists=True), ) @click.pass_context -def cmd_demo(ctx: click.Context, config_path: str) -> None: +def cmd_demo(ctx: click.Context, config_path: str | None) -> None: """Run the complete synthetic factor-to-backtest pipeline.""" + if config_path is not None: + _run_demo(ctx, config_path) + return + + default_config = files("mlquant.configs").joinpath("small.yaml") + with as_file(default_config) as default_path: + click.echo("Using packaged default config (override with --config PATH).\n") + _run_demo(ctx, str(default_path)) + + +def _run_demo(ctx: click.Context, config_path: str) -> None: + """Run all demo stages with an explicit, existing config path.""" stages = ( ("1/5 Generate deterministic synthetic data", cmd_gen_data), ("2/5 Compute the 213-factor tensor", cmd_features), diff --git a/src/mlquant/configs/__init__.py b/src/mlquant/configs/__init__.py new file mode 100644 index 0000000..b8765f6 --- /dev/null +++ b/src/mlquant/configs/__init__.py @@ -0,0 +1 @@ +"""Packaged configuration files for zero-setup CLI entry points.""" diff --git a/src/mlquant/configs/small.yaml b/src/mlquant/configs/small.yaml new file mode 100644 index 0000000..9debc5d --- /dev/null +++ b/src/mlquant/configs/small.yaml @@ -0,0 +1,41 @@ +# Small synthetic config — runs end-to-end in <1 minute on a laptop CPU. +# This is the configuration that `make paper` and CI exercise. + +seed: 42 +artifacts_dir: artifacts/small +limit_pct: 0.098 +costs_bps: 5.0 +cov_lookback: 60 +hidden: 64 +loss_gamma: 0.1 + +synthetic: + n_stocks: 200 + n_dates: 500 + start_date: "2022-01-04" + annual_drift: 0.05 + annual_vol: 0.30 + market_beta: 0.6 + halt_prob: 0.005 + limit_pct: 0.10 + seed: 42 + device: cpu + +train: + epochs: 8 + batch_size: 1024 + lr: 1.0e-3 + weight_decay: 1.0e-5 + grad_clip: 1.0 + device: cpu + log_every: 50 + save_dir: artifacts/small/checkpoints + val_split: 0.1 + +portfolio: + risk_aversion: 5.0 + weight_cap: 0.05 + long_only: true + cash_weight: 0.0 + solver: SCS + shrinkage: true diff --git a/tests/test_cli.py b/tests/test_cli.py index 9074ce8..1f8db31 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from click.testing import CliRunner @@ -47,6 +48,34 @@ def test_demo_is_visible_in_cli_help(): assert "demo" in result.output +def test_demo_uses_packaged_config_outside_repository(monkeypatch, tmp_path): + calls = [] + + for command in (cmd_gen_data, cmd_features, cmd_train, cmd_portfolio, cmd_backtest): + name = command.name + + def callback(config_path, stage=name): + calls.append((stage, config_path, Path(config_path).read_text())) + + monkeypatch.setattr(command, "callback", callback) + + with CliRunner().isolated_filesystem(temp_dir=tmp_path): + result = CliRunner().invoke(cli, ["demo"]) + + assert result.exit_code == 0 + assert len(calls) == 5 + assert all(Path(path).name == "small.yaml" for _, path, _ in calls) + assert all("n_stocks: 200" in content for _, _, content in calls) + assert "Using packaged default config" in result.output + + +def test_packaged_demo_config_matches_repository_config(): + packaged = Path(__file__).parents[1] / "src" / "mlquant" / "configs" / "small.yaml" + repository = Path(__file__).parents[1] / "configs" / "small.yaml" + + assert packaged.read_text() == repository.read_text() + + def test_backtest_summary_is_shareable_and_strict_json(tmp_path): markdown_path, json_path = _write_backtest_summary( tmp_path,