diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc0257a..5cb90a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: name: ruff format entry: uv run ruff format language: system - types: [python] + types_or: [python, jupyter] - id: ty-check name: ty check entry: task typecheck @@ -49,7 +49,7 @@ repos: types: [jupyter] - id: tests name: tests with coverage - entry: env COVERAGE_FILE=/tmp/joint-client-python-pre-commit.coverage uv run pytest -q -p no:cacheprovider --cov=jointfm_client --cov-report=term-missing:skip-covered --cov-fail-under=90 + entry: env COVERAGE_FILE=/tmp/joint-client-python-pre-commit.coverage uv run pytest -q -n auto -p no:cacheprovider --cov=jointfm_client --cov-report=term-missing:skip-covered --cov-fail-under=90 language: system pass_filenames: false always_run: true \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 43334e7..64d925c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1 @@ -Agents must ground their thoughts in facts, not assumptions: before planning, claiming, or editing, read the relevant material — source, configs, data, docs, test output — and only act on beliefs backed by something just read or run. Agents must explain what each command will do and why it is being run before running it. Agents must run `task pre-commit` and fix all reported issues before reporting success to the user. After `task pre-commit` succeeds, show the diff and explain why each change is necessary before reporting success to the user. Agents must never perform destructive git operations unless the user explicitly instructs the agent to run that specific operation — no `git push --force`, no `git reset --hard`, no branch/tag deletion, no history rewrites (`rebase`, `commit --amend` on published commits, `filter-branch`), no `git clean -fdx`, no `--no-verify` to bypass hooks, and no discarding of uncommitted work. If a task seems to require a destructive git operation and the user has not explicitly asked for it, stop and ask the user to run it. +Agents must ground their thoughts in facts, not assumptions: before planning, claiming, or editing, read the relevant material — source, configs, data, docs, test output — and only act on beliefs backed by something just read or run. Agents must run ad-hoc Python and CLI commands through `task run -- ` (which wraps `uv run` with the project's canonical parameters), never bare `python` or hand-written `uv run` invocations — this keeps every invocation from accidentally re-resolving or mutating the `.venv` and `uv.lock`. Agents must explain what each command will do and why it is being run before running it. Before asking the user a question, agents must first explain the corresponding context and terminology — what the question concerns, why it arises, and what any project-specific terms mean — so the user can answer without digging through the code themselves. Agents must run `task pre-commit` and fix all reported issues before reporting success to the user. After `task pre-commit` succeeds, show the diff and explain why each change is necessary before reporting success to the user. Agents must never perform destructive or state-changing git operations unless the user explicitly instructs the agent to run that specific operation — no `git push --force`, no `git reset --hard`, no `git stash` (which hides uncommitted work), no branch/tag deletion, no history rewrites (`rebase`, `commit --amend` on published commits, `filter-branch`), no `git clean -fdx`, no `--no-verify` to bypass hooks, and no discarding of uncommitted work. Read-only inspection commands (`git status`, `git diff`, `git log`, `git show`) are always allowed. If a task seems to require a state-changing git operation and the user has not explicitly asked for it, stop and ask the user to run it. Agents must never mention temporary planning identifiers — phase names or numbers such as "Phase 3g", milestone, sprint, or ticket codes — in docstrings, comments, help or description strings, error messages, or test docstrings; those labels are deleted when the plan is retired and leave readers with a dangling reference nobody can decode, so describe the concept by its lasting behavior or stable configuration key instead (phase names belong only in roadmap and planning docs, and linking to such a doc by its actual filename is fine). Agents must not mention or recommend key rotation (rotating API keys, tokens, or other credentials) — the user takes care of key rotation themselves. ripgrep (`rg`) is available (installed by `task setup` via `task install:ripgrep`); prefer it for fast code and text search. diff --git a/README.md b/README.md index d067308..f5fe174 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ transport: connect_seconds: 5.0 read_seconds: 60.0 retry: - max_attempts: 3 + max_attempts: 5 backoff_seconds: 1 ``` @@ -152,6 +152,7 @@ Checked-in example notebooks live under `notebooks/`. Every example starts with: ```python from jointfm_client import bootstrap_notebook + bootstrap_notebook(add_src_root=True) ``` @@ -169,7 +170,7 @@ The current V1 forecast request contract is: - `time_column`: required for `"absolute_datetime"`, and used for ordered ordinal or continuous histories when supplied - `query_times`: non-empty future forecast times only - `requested_columns`: optional column names or integer column indices, with duplicates rejected -- `n_samples`: positive sample count for sampled forecasts and quantile estimation. When `return_mode="samples"` exceeds a service-reported sample cap, `forecast_samples(...)` automatically resubmits capped prediction batches and returns one merged `SampleForecastResult`. +- `n_samples`: positive sample count for sampled forecasts and quantile estimation. When `return_mode="samples"` exceeds the `max_sample_count` advertised by the deployment's health metadata, `forecast_samples(...)` splits the request into capped prediction batches up front and returns one merged `SampleForecastResult`. V1 column descriptors support the server fields `name`, `modality`, `role`, `nullable`, `vocabulary_size`, `level_count`, `mapping`, `lower_bound`, `upper_bound`, `time_value_kind`, `time_value_scale_seconds`, `time_value_use_local_normalized_time`, `time_value_calendar_id`, and `time_value_timezone`. @@ -303,10 +304,19 @@ Use `forecast_samples(...)` for sampled trajectories or `forecast_quantiles(...) - `task check`: run the static code quality gate (typos, lint, format check, type checks) - `task release:dry`: preview the next SemVer bump without changing any files - `task release`: cut a SemVer release with Commitizen (writes `CHANGELOG.md`, bumps versions, creates tag) +- `task release:publish`: push the release commit and its tag to `origin`, which triggers the PyPI publish workflow - `task pre-commit`: run every configured pre-commit hook Contributors do not need to add copyright or license headers manually. The `insert-license` pre-commit hook runs [skywalking-eyes](https://github.com/apache/skywalking-eyes) (via the `apache/skywalking-eyes` Docker image, so a running Docker daemon is required) to stamp the standard Apache-2.0 header (`Copyright 2026 DataRobot, Inc. and its affiliates.` followed by the standard "Licensed under the Apache License, Version 2.0" notice) into every `.py` file, and the companion `insert-license-notebooks` hook stamps the same notice into a leading markdown cell of every notebook the first time you run `task pre-commit`. Verify the headers are present at any time with `task license-check`. +Your user must belong to the `docker` group so the hook can reach the daemon: + +```shell +sudo usermod -aG docker "$USER" +``` + +Group membership is captured when a process starts, so it must be in effect **before** the editor or its language server launches — start a fresh login session (or reboot) after adding yourself to the group, otherwise the commit hook inherits the old groups and fails with a Docker permission error. + ## Versioning & Commits The package follows strict [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are cut with [Commitizen](https://commitizen-tools.github.io/commitizen/), driven by [Conventional Commits](https://www.conventionalcommits.org/), so the commit log is the source of truth for what a release contains. @@ -384,7 +394,7 @@ After that, every future `task release` finds its base tag automatically. ```bash task release:dry # preview the next version + CHANGELOG entries task release # bump, write CHANGELOG.md, create the annotated tag -git push && git push --tags +task release:publish # push the bump commit and the tag ``` `task release` first runs `task release:check` (clean tree, on `main`, in sync with `origin/main`), then calls `cz bump` which: @@ -395,7 +405,9 @@ git push && git push --tags - bumps `version =` in `pyproject.toml`, `__version__` in `src/jointfm_client/__init__.py`, and the "Current SDK package version" line in this README, - commits the bump and creates the annotated tag. -Pushing is left manual so you can inspect the bump first. Override the inferred bump level only when needed: `task release -- --increment minor`. +Publishing is a separate task on purpose, so you can inspect the inferred bump before anything leaves your machine — Commitizen derives the version from commit messages, and a stray `feat:` where you meant `fix:` is only fixable while the release is still local. Override the inferred bump level when needed: `task release -- --increment minor`. + +`task release:publish` re-checks that the working tree is clean, that you are on `main`, that the tag points at `HEAD`, and that `origin` does not already have the tag, prints the commits about to be pushed, asks for confirmation on a terminal, and then pushes `main` and that single tag. It pushes one explicit tag rather than `git push --tags`, so unrelated local tags are never published. Pushing the `v*` tag triggers the [`Publish to PyPI`](.github/workflows/publish.yml) workflow, which rebuilds and validates the distribution with `task build` and uploads it to PyPI via [`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish) — keeping the git tag, the wheel filename, `jointfm_client.__version__`, and the PyPI version all in lockstep. diff --git a/Taskfile.yaml b/Taskfile.yaml index 7aa5c7c..d3328ac 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -10,6 +10,10 @@ tasks: default: cmd: task --list --sort alphanumeric + run: + desc: "Run a command inside the project environment (usage: task run -- python ...)" + cmd: uv run {{.CLI_ARGS}} + setup: desc: Create or reuse the local Python environment and install repo tooling cmds: @@ -17,6 +21,7 @@ tasks: - uv sync --all-groups --extra notebooks - uv run python -m ipykernel install --sys-prefix --name joint-client-python --display-name "Python (joint-client-python)" - uv run pre-commit install --hook-type pre-commit --hook-type commit-msg + - task: install:ripgrep - task: install:typos - cmd: | if [ -f .env ]; then @@ -45,11 +50,11 @@ tasks: test: desc: Run the unit test suite - cmd: uv run pytest + cmd: uv run pytest -n auto coverage: desc: Run tests with coverage enforcement - cmd: uv run pytest --cov=jointfm_client --cov-report=term-missing --cov-fail-under=91 + cmd: uv run pytest -n auto --cov=jointfm_client --cov-report=term-missing --cov-fail-under=90 build: desc: Build and validate the source distribution and wheel @@ -129,7 +134,59 @@ tasks: echo " bumped version in: pyproject.toml, src/jointfm_client/__init__.py, README.md" echo " updated: CHANGELOG.md, uv.lock" echo " created tag: v$NEXT" - echo " publish with: git push && git push --tags" + echo " publish with: task release:publish" + + release:publish: + desc: Push the release commit and its tag to origin (triggers the PyPI publish workflow) + cmds: + - task: release:require-tag + - | + TAG="v$(uv run cz version --project)" + echo "[1/5] checking working tree is clean..." + if [ -n "$(git status --porcelain)" ]; then + echo "FAIL: working tree has uncommitted changes; commit them first" + exit 1 + fi + echo "[2/5] checking current branch is main..." + BRANCH="$(git symbolic-ref --short HEAD)" + if [ "$BRANCH" != "main" ]; then + echo "FAIL: releases are published from main (currently on $BRANCH)" + exit 1 + fi + echo "[3/5] checking $TAG points at HEAD..." + if [ "$(git rev-parse "$TAG^{commit}")" != "$(git rev-parse HEAD)" ]; then + echo "FAIL: $TAG does not point at HEAD." + echo "The wheel published from this tag must match the commit on main." + echo "This usually means the release PR was squashed or rebased instead" + echo "of merged, so the tagged commit is no longer part of main." + exit 1 + fi + echo "[4/5] checking $TAG is not already published..." + git fetch --quiet origin main + if [ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]; then + echo "FAIL: origin already has $TAG; PyPI does not allow reusing a version." + echo "Cut a new release instead of republishing this one." + exit 1 + fi + echo "[5/5] commits to be pushed to origin/main:" + git --no-pager log --oneline origin/main..HEAD + echo + if [ -t 0 ]; then + printf 'Push main and %s to origin? This publishes %s to PyPI. [y/N] ' "$TAG" "$TAG" + read -r CONFIRMATION + case "$CONFIRMATION" in + y|Y|yes|YES) ;; + *) + echo "OK: aborted, nothing was pushed" + exit 1 + ;; + esac + fi + git push origin main + git push origin "refs/tags/$TAG" + echo + echo "OK: pushed main and $TAG to origin" + echo " the Publish to PyPI workflow now builds and uploads $TAG" typos: desc: Run the spelling checker @@ -152,6 +209,30 @@ tasks: exit 1 fi + install:ripgrep: + desc: Install ripgrep using the available system package manager + cmds: + - | + if command -v rg >/dev/null 2>&1; then + exit 0 + fi + if command -v apt >/dev/null 2>&1; then + sudo apt update && sudo apt install -y ripgrep + elif command -v dnf >/dev/null 2>&1; then + rpm -q spal-release >/dev/null 2>&1 || sudo dnf install -y spal-release + sudo dnf install -y ripgrep + elif command -v yum >/dev/null 2>&1; then + sudo yum install -y ripgrep + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -Sy --noconfirm ripgrep + elif command -v brew >/dev/null 2>&1; then + brew install ripgrep + else + echo "No supported package manager found for automatic ripgrep installation." + echo "Install ripgrep manually, then rerun: task setup" + exit 1 + fi + install:typos: desc: Install typos using Homebrew when available, otherwise download a release binary with curl cmds: diff --git a/config.sample.yaml b/config.sample.yaml index ba9ecad..55693c3 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -29,9 +29,9 @@ transport: connect_seconds: 5.0 read_seconds: 60.0 retry: - max_attempts: 3 + max_attempts: 5 backoff_seconds: 1 - max_backoff_seconds: 30.0 + max_backoff_seconds: 60.0 status_codes: - 408 - 429 diff --git a/docs/api-reference.md b/docs/api-reference.md index 7a1b452..91a4710 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,7 +8,7 @@ This reference covers the supported public Python surface exported by `jointfm_c | --- | --- | | `JointFMClient` | Synchronous client for hosted or local JointFM endpoints. Use `from_env()` for `.env` and `config.yaml` backed hosted settings, `health()` for typed service metadata, `predict(payload)` for low-level JSON prediction, `forecast(...)` for validated tabular forecasts, and the `forecast_mean(...)`, `forecast_samples(...)`, and `forecast_quantiles(...)` convenience methods for typed forecast results. `health()` probes `GET /healthz` for local deployments and POSTs `{"request_type": "health"}` to `predict_url` for hosted DataRobot deployments because the DataRobot deployment gateway only proxies the unstructured prediction route. | -`JointFMClient.from_env()` loads `config.yaml`, optional `.env` values, and process environment variables. `JointFMClient.health(cache=True)` caches health metadata only when requested. `JointFMClient.predict(payload)` requires `payload["model_version"]`; high-level forecast helpers resolve the configured model version when the caller does not pass one explicitly. When `forecast_samples(...)` requests more samples than the service cap allows, the client discovers the cap from the structured service error, resubmits capped prediction batches, and returns one merged `SampleForecastResult`. +`JointFMClient.from_env()` loads `config.yaml`, optional `.env` values, and process environment variables. `JointFMClient.health(cache=True)` caches health metadata only when requested. `JointFMClient.predict(payload)` requires `payload["model_version"]`; high-level forecast helpers resolve the configured model version when the caller does not pass one explicitly. When `forecast_samples(...)` requests an explicit `n_samples`, the client learns the deployment's `max_sample_count` from health metadata before the first prediction, splits oversized requests into capped prediction batches, and returns one merged `SampleForecastResult`. Clients configured without a reachable health route fall back to discovering the cap from the structured service error. ## Contract Classes @@ -156,7 +156,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE` | `query_times` | Yes | Non-empty future forecast horizon values. Absolute datetimes are encoded timezone-stably. | | `time_column` | For absolute datetime, optional otherwise | Name of the history time column. It must not duplicate a modeled column name. | | `requested_columns` | Optional | Output column names or integer indices. Duplicates are rejected. Defaults to all modeled columns. | -| `n_samples` | Samples and quantiles controls | Positive sample count when sampling controls are needed. Oversized sample forecasts are batched automatically after the service reports its cap. | +| `n_samples` | Samples and quantiles controls | Positive sample count when sampling controls are needed. Oversized sample forecasts are batched automatically against the cap advertised in health metadata. | | `quantiles` | Quantiles mode | Quantile levels in `(0, 1)`, required for `return_mode="quantiles"`. | | `seed` | Optional | Integer random seed for reproducible stochastic outputs. | | `time_scale_seconds` | Optional | Positive scale for continuous time indexes. | @@ -220,7 +220,7 @@ The string literals are exposed as `PREDICT_REQUEST_TYPE`, `HEALTH_REQUEST_TYPE` | `supported_return_modes` | Must match the SDK V1 return modes (`mean`, `samples`, `quantiles`, `log_prob`). | | `supported_time_index_modes` | Must match the SDK V1 time-index modes. | | `time_index_encoding` | Time-index encoding advertised by the service. | -| `max_sample_count` | Maximum sample-count budget the service accepts in a single prediction. Oversized requests are batched automatically by the client. | +| `max_sample_count` | Maximum sample-count budget the service accepts in a single prediction. The client reads it during health probes and batches oversized sample requests locally, so the service never has to reject them. | | `data_generation` | Optional capability block describing the deployed checkpoint's advertised data-generation capacity. Absent on legacy checkpoints; present payloads expose `sampler_type`, `min_features`, `max_features`, `min_targets`, `max_targets`, `t_input`, `t_output`, `n_input`, and `n_output`. | ## Docstring Enforcement diff --git a/notebooks/forecast_csv.ipynb b/notebooks/forecast_csv.ipynb index 7a9653e..40a00cf 100644 --- a/notebooks/forecast_csv.ipynb +++ b/notebooks/forecast_csv.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -67,32 +68,38 @@ "\n", "from jointfm_client import JointFMClient, plan_forecast_columns\n", "\n", - "HISTORY_PATH = Path('notebooks/history.csv')\n", - "OUTPUT_PATH = Path('notebooks/forecast.csv')\n", - "FEATURE_COLUMNS = ['equity_index_level', 'treasury_10y_yield', 'eur_usd_rate']\n", - "TARGET_COLUMNS = ['portfolio_nav', 'realized_volatility']\n", + "HISTORY_PATH = Path(\"notebooks/history.csv\")\n", + "OUTPUT_PATH = Path(\"notebooks/forecast.csv\")\n", + "FEATURE_COLUMNS = [\"equity_index_level\", \"treasury_10y_yield\", \"eur_usd_rate\"]\n", + "TARGET_COLUMNS = [\"portfolio_nav\", \"realized_volatility\"]\n", "REQUESTED_COLUMNS = FEATURE_COLUMNS + TARGET_COLUMNS\n", "INPUT_STEPS = 100\n", "OUTPUT_HORIZONS = 10\n", "EXPECTED_COLUMNS = FEATURE_COLUMNS + TARGET_COLUMNS\n", "QUERY_TIMES = list(range(INPUT_STEPS, INPUT_STEPS + OUTPUT_HORIZONS))\n", - "QUERY_TIMES_ARGUMENT = ','.join(str(query_time) for query_time in QUERY_TIMES)\n", + "QUERY_TIMES_ARGUMENT = \",\".join(str(query_time) for query_time in QUERY_TIMES)\n", "\n", "history = pd.read_csv(HISTORY_PATH, dtype=float)\n", "if list(history.columns) != EXPECTED_COLUMNS:\n", - " raise ValueError(f'Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}')\n", + " raise ValueError(\n", + " f\"Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}\"\n", + " )\n", "if len(history) != INPUT_STEPS:\n", - " raise ValueError(f'Expected {INPUT_STEPS} history rows, got {len(history)}')\n", + " raise ValueError(f\"Expected {INPUT_STEPS} history rows, got {len(history)}\")\n", "non_float_columns = [\n", - " column_name for column_name, dtype in history.dtypes.items() if dtype.kind != 'f'\n", + " column_name for column_name, dtype in history.dtypes.items() if dtype.kind != \"f\"\n", "]\n", "if non_float_columns:\n", - " raise ValueError(f'Legacy JointFM model requires float columns: {non_float_columns!r}')\n", + " raise ValueError(\n", + " f\"Legacy JointFM model requires float columns: {non_float_columns!r}\"\n", + " )\n", "non_positive_columns = [\n", " column_name for column_name in history.columns if (history[column_name] <= 0).any()\n", "]\n", "if non_positive_columns:\n", - " raise ValueError(f'Legacy JointFM model requires positive columns: {non_positive_columns!r}')\n", + " raise ValueError(\n", + " f\"Legacy JointFM model requires positive columns: {non_positive_columns!r}\"\n", + " )\n", "\n", "client = JointFMClient.from_env()\n", "plan = plan_forecast_columns(\n", @@ -105,25 +112,25 @@ "\n", "target_column_arguments = []\n", "for column_name in plan.target_columns:\n", - " target_column_arguments.extend(['--target-column', column_name])\n", + " target_column_arguments.extend([\"--target-column\", column_name])\n", "requested_column_arguments = []\n", "for column_name in REQUESTED_COLUMNS:\n", - " requested_column_arguments.extend(['--requested-column', column_name])\n", + " requested_column_arguments.extend([\"--requested-column\", column_name])\n", "\n", "subprocess.run(\n", " [\n", " sys.executable,\n", - " '-m',\n", - " 'jointfm_client.cli',\n", - " 'forecast-csv',\n", + " \"-m\",\n", + " \"jointfm_client.cli\",\n", + " \"forecast-csv\",\n", " str(HISTORY_PATH),\n", " str(OUTPUT_PATH),\n", - " '--query-times',\n", + " \"--query-times\",\n", " QUERY_TIMES_ARGUMENT,\n", " *target_column_arguments,\n", " *requested_column_arguments,\n", - " '--seed',\n", - " '7',\n", + " \"--seed\",\n", + " \"7\",\n", " ],\n", " capture_output=True,\n", " text=True,\n", @@ -132,14 +139,16 @@ "forecast = pd.read_csv(OUTPUT_PATH)\n", "expected_forecast_rows = OUTPUT_HORIZONS * len(REQUESTED_COLUMNS)\n", "if len(forecast) != expected_forecast_rows:\n", - " raise ValueError(f'Expected {expected_forecast_rows} forecast rows, got {len(forecast)}')\n", + " raise ValueError(\n", + " f\"Expected {expected_forecast_rows} forecast rows, got {len(forecast)}\"\n", + " )\n", "forecast" ] } ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/notebooks/forecast_mean.ipynb b/notebooks/forecast_mean.ipynb index 4dc86fb..88883e8 100644 --- a/notebooks/forecast_mean.ipynb +++ b/notebooks/forecast_mean.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -65,9 +66,9 @@ "\n", "from jointfm_client import JointFMClient, plan_forecast_columns\n", "\n", - "HISTORY_PATH = Path('notebooks/history.csv')\n", - "FEATURE_COLUMNS = ['equity_index_level', 'treasury_10y_yield', 'eur_usd_rate']\n", - "TARGET_COLUMNS = ['portfolio_nav', 'realized_volatility']\n", + "HISTORY_PATH = Path(\"notebooks/history.csv\")\n", + "FEATURE_COLUMNS = [\"equity_index_level\", \"treasury_10y_yield\", \"eur_usd_rate\"]\n", + "TARGET_COLUMNS = [\"portfolio_nav\", \"realized_volatility\"]\n", "INPUT_STEPS = 100\n", "OUTPUT_HORIZONS = 10\n", "EXPECTED_COLUMNS = FEATURE_COLUMNS + TARGET_COLUMNS\n", @@ -75,9 +76,11 @@ "\n", "history = pd.read_csv(HISTORY_PATH, dtype=float)\n", "if list(history.columns) != EXPECTED_COLUMNS:\n", - " raise ValueError(f'Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}')\n", + " raise ValueError(\n", + " f\"Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}\"\n", + " )\n", "if len(history) != INPUT_STEPS:\n", - " raise ValueError(f'Expected {INPUT_STEPS} history rows, got {len(history)}')\n", + " raise ValueError(f\"Expected {INPUT_STEPS} history rows, got {len(history)}\")\n", "\n", "client = JointFMClient.from_env()\n", "plan = plan_forecast_columns(\n", @@ -97,7 +100,9 @@ "forecast = result.to_pandas_tidy()\n", "expected_forecast_rows = OUTPUT_HORIZONS * len(plan.requested_columns)\n", "if len(forecast) != expected_forecast_rows:\n", - " raise ValueError(f'Expected {expected_forecast_rows} forecast rows, got {len(forecast)}')\n", + " raise ValueError(\n", + " f\"Expected {expected_forecast_rows} forecast rows, got {len(forecast)}\"\n", + " )\n", "forecast" ] } diff --git a/notebooks/forecast_quantiles.ipynb b/notebooks/forecast_quantiles.ipynb index 7d696ee..24bd566 100644 --- a/notebooks/forecast_quantiles.ipynb +++ b/notebooks/forecast_quantiles.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -65,10 +66,10 @@ "\n", "from jointfm_client import JointFMClient, plan_forecast_columns\n", "\n", - "HISTORY_PATH = Path('notebooks/history.csv')\n", - "FEATURE_COLUMNS = ['equity_index_level', 'treasury_10y_yield', 'eur_usd_rate']\n", - "TARGET_COLUMNS = ['portfolio_nav', 'realized_volatility']\n", - "QUANTILES = [0.1, 0.3, 0.5, 0.7,0.9]\n", + "HISTORY_PATH = Path(\"notebooks/history.csv\")\n", + "FEATURE_COLUMNS = [\"equity_index_level\", \"treasury_10y_yield\", \"eur_usd_rate\"]\n", + "TARGET_COLUMNS = [\"portfolio_nav\", \"realized_volatility\"]\n", + "QUANTILES = [0.1, 0.3, 0.5, 0.7, 0.9]\n", "INPUT_STEPS = 100\n", "OUTPUT_HORIZONS = 10\n", "EXPECTED_COLUMNS = FEATURE_COLUMNS + TARGET_COLUMNS\n", @@ -76,9 +77,11 @@ "\n", "history = pd.read_csv(HISTORY_PATH, dtype=float)\n", "if list(history.columns) != EXPECTED_COLUMNS:\n", - " raise ValueError(f'Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}')\n", + " raise ValueError(\n", + " f\"Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}\"\n", + " )\n", "if len(history) != INPUT_STEPS:\n", - " raise ValueError(f'Expected {INPUT_STEPS} history rows, got {len(history)}')\n", + " raise ValueError(f\"Expected {INPUT_STEPS} history rows, got {len(history)}\")\n", "\n", "client = JointFMClient.from_env()\n", "plan = plan_forecast_columns(\n", @@ -99,14 +102,16 @@ "forecast = result.to_pandas_tidy()\n", "expected_forecast_rows = OUTPUT_HORIZONS * len(plan.requested_columns) * len(QUANTILES)\n", "if len(forecast) != expected_forecast_rows:\n", - " raise ValueError(f'Expected {expected_forecast_rows} forecast rows, got {len(forecast)}')\n", + " raise ValueError(\n", + " f\"Expected {expected_forecast_rows} forecast rows, got {len(forecast)}\"\n", + " )\n", "forecast" ] } ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/notebooks/forecast_samples.ipynb b/notebooks/forecast_samples.ipynb index 6788af3..0145114 100644 --- a/notebooks/forecast_samples.ipynb +++ b/notebooks/forecast_samples.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -65,9 +66,9 @@ "\n", "from jointfm_client import JointFMClient, plan_forecast_columns\n", "\n", - "HISTORY_PATH = Path('notebooks/history.csv')\n", - "FEATURE_COLUMNS = ['equity_index_level', 'treasury_10y_yield', 'eur_usd_rate']\n", - "TARGET_COLUMNS = ['portfolio_nav', 'realized_volatility']\n", + "HISTORY_PATH = Path(\"notebooks/history.csv\")\n", + "FEATURE_COLUMNS = [\"equity_index_level\", \"treasury_10y_yield\", \"eur_usd_rate\"]\n", + "TARGET_COLUMNS = [\"portfolio_nav\", \"realized_volatility\"]\n", "N_SAMPLES = 10\n", "INPUT_STEPS = 100\n", "OUTPUT_HORIZONS = 2\n", @@ -76,9 +77,11 @@ "\n", "history = pd.read_csv(HISTORY_PATH, dtype=float)\n", "if list(history.columns) != EXPECTED_COLUMNS:\n", - " raise ValueError(f'Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}')\n", + " raise ValueError(\n", + " f\"Expected columns {EXPECTED_COLUMNS!r}, got {list(history.columns)!r}\"\n", + " )\n", "if len(history) != INPUT_STEPS:\n", - " raise ValueError(f'Expected {INPUT_STEPS} history rows, got {len(history)}')\n", + " raise ValueError(f\"Expected {INPUT_STEPS} history rows, got {len(history)}\")\n", "\n", "client = JointFMClient.from_env()\n", "plan = plan_forecast_columns(\n", @@ -99,14 +102,16 @@ "forecast = result.to_pandas_tidy()\n", "expected_forecast_rows = OUTPUT_HORIZONS * len(plan.requested_columns) * N_SAMPLES\n", "if len(forecast) != expected_forecast_rows:\n", - " raise ValueError(f'Expected {expected_forecast_rows} forecast rows, got {len(forecast)}')\n", + " raise ValueError(\n", + " f\"Expected {expected_forecast_rows} forecast rows, got {len(forecast)}\"\n", + " )\n", "forecast" ] } ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/notebooks/forecast_trading.ipynb b/notebooks/forecast_trading.ipynb index b98f9c6..d328bca 100644 --- a/notebooks/forecast_trading.ipynb +++ b/notebooks/forecast_trading.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -120,16 +121,16 @@ "# BTC-USD is the classifier target. Other crypto majors and macro drivers are\n", "# sent alongside as additional targets so the joint model conditions the\n", "# Bitcoin marginal on the rest of the crypto complex and the macro backdrop.\n", - "PRICE_TARGET = 'BTC-USD'\n", - "ADDITIONAL_CRYPTO_SYMBOLS = ('ETH-USD', 'SOL-USD', 'BNB-USD', 'XRP-USD')\n", + "PRICE_TARGET = \"BTC-USD\"\n", + "ADDITIONAL_CRYPTO_SYMBOLS = (\"ETH-USD\", \"SOL-USD\", \"BNB-USD\", \"XRP-USD\")\n", "ECONOMIC_DRIVER_SYMBOLS = (\n", - " '^GSPC', # S&P 500 index level\n", - " '^IXIC', # Nasdaq Composite index level\n", - " '^VIX', # CBOE volatility index\n", - " '^TNX', # 10-year US Treasury yield (quoted as yield-in-percent x 10)\n", - " 'DX-Y.NYB', # ICE US Dollar index spot\n", - " 'GC=F', # Gold futures (USD/oz)\n", - " 'CL=F', # WTI crude oil futures (USD/bbl)\n", + " \"^GSPC\", # S&P 500 index level\n", + " \"^IXIC\", # Nasdaq Composite index level\n", + " \"^VIX\", # CBOE volatility index\n", + " \"^TNX\", # 10-year US Treasury yield (quoted as yield-in-percent x 10)\n", + " \"DX-Y.NYB\", # ICE US Dollar index spot\n", + " \"GC=F\", # Gold futures (USD/oz)\n", + " \"CL=F\", # WTI crude oil futures (USD/bbl)\n", ")\n", "ALL_SYMBOLS = (PRICE_TARGET,) + ADDITIONAL_CRYPTO_SYMBOLS + ECONOMIC_DRIVER_SYMBOLS\n", "\n", @@ -151,8 +152,7 @@ "CONFIDENCE_THRESHOLD = 0.55\n", "\n", "# Hard cap on |position|. 1.0 = fully invested, <1.0 = de-levered.\n", - "KELLY_CAP = 1.0\n", - "\n" + "KELLY_CAP = 1.0" ] }, { @@ -169,10 +169,12 @@ "health = client.health(cache=True)\n", "capacity = health.data_generation\n", "if capacity is None:\n", - " raise ValueError('Deployment /healthz response is missing the data_generation block')\n", + " raise ValueError(\n", + " \"Deployment /healthz response is missing the data_generation block\"\n", + " )\n", "if max(HORIZONS) > capacity.n_output:\n", " raise ValueError(\n", - " f'Largest horizon {max(HORIZONS)} exceeds deployment n_output={capacity.n_output}'\n", + " f\"Largest horizon {max(HORIZONS)} exceeds deployment n_output={capacity.n_output}\"\n", " )\n", "\n", "# yfinance pins a few internal calls to the soon-to-be-removed pandas\n", @@ -180,31 +182,39 @@ "# the notebook output stays readable. Errors and our own warnings still bubble.\n", "with warnings.catch_warnings():\n", " warnings.filterwarnings(\n", - " 'ignore',\n", - " message=r'Timestamp\\.utcnow is deprecated',\n", - " module=r'yfinance\\..*',\n", + " \"ignore\",\n", + " message=r\"Timestamp\\.utcnow is deprecated\",\n", + " module=r\"yfinance\\..*\",\n", " )\n", " raw_yahoo = yf.download(\n", " list(ALL_SYMBOLS),\n", - " period='max',\n", - " interval='1d',\n", + " period=\"max\",\n", + " interval=\"1d\",\n", " auto_adjust=False,\n", " progress=False,\n", " actions=False,\n", " threads=False,\n", " )\n", - "if not isinstance(raw_yahoo.columns, pd.MultiIndex) or 'Close' not in raw_yahoo.columns.get_level_values(0):\n", - " raise ValueError('Yahoo Finance response does not expose a Close field')\n", - "close_frame = raw_yahoo['Close'].sort_index()\n", - "missing_symbols = [symbol for symbol in ALL_SYMBOLS if symbol not in close_frame.columns]\n", + "if not isinstance(\n", + " raw_yahoo.columns, pd.MultiIndex\n", + ") or \"Close\" not in raw_yahoo.columns.get_level_values(0):\n", + " raise ValueError(\"Yahoo Finance response does not expose a Close field\")\n", + "close_frame = raw_yahoo[\"Close\"].sort_index()\n", + "missing_symbols = [\n", + " symbol for symbol in ALL_SYMBOLS if symbol not in close_frame.columns\n", + "]\n", "if missing_symbols:\n", - " raise ValueError(f'Yahoo Finance did not return Close columns for {missing_symbols!r}')\n", + " raise ValueError(\n", + " f\"Yahoo Finance did not return Close columns for {missing_symbols!r}\"\n", + " )\n", "close_frame = close_frame.loc[:, list(ALL_SYMBOLS)].astype(float)\n", "\n", "# Crypto trades 7 days a week, equities and futures 5 days. Snap to a\n", "# business-day calendar and forward-fill within each series so per-symbol\n", "# holidays do not punch holes through the joint history.\n", - "business_index = pd.date_range(close_frame.index.min(), close_frame.index.max(), freq='B')\n", + "business_index = pd.date_range(\n", + " close_frame.index.min(), close_frame.index.max(), freq=\"B\"\n", + ")\n", "business_close = close_frame.reindex(business_index).ffill()\n", "\n", "# A column is \"dead\" when Yahoo returned the symbol but every row is NaN\n", @@ -217,9 +227,9 @@ "dead_symbols = [symbol for symbol, ts in first_valid_per_symbol.items() if ts is None]\n", "if dead_symbols:\n", " raise ValueError(\n", - " f'Yahoo Finance returned no usable Close data for {dead_symbols!r}; '\n", - " f'the ticker may have been delisted or renamed. Replace it in '\n", - " f'ECONOMIC_DRIVER_SYMBOLS / ADDITIONAL_CRYPTO_SYMBOLS.'\n", + " f\"Yahoo Finance returned no usable Close data for {dead_symbols!r}; \"\n", + " f\"the ticker may have been delisted or renamed. Replace it in \"\n", + " f\"ECONOMIC_DRIVER_SYMBOLS / ADDITIONAL_CRYPTO_SYMBOLS.\"\n", " )\n", "\n", "# Trim leading rows where any symbol has not yet started trading (e.g. SOL-USD\n", @@ -230,26 +240,27 @@ "aligned = business_close.loc[common_start:]\n", "if aligned.isna().any().any():\n", " bad_columns = aligned.columns[aligned.isna().any()].tolist()\n", - " raise ValueError(f'Residual NaN after alignment in columns: {bad_columns!r}')\n", + " raise ValueError(f\"Residual NaN after alignment in columns: {bad_columns!r}\")\n", "if len(aligned) == 0:\n", - " raise ValueError(f'No aligned Yahoo history available for {list(ALL_SYMBOLS)!r}')\n", + " raise ValueError(f\"No aligned Yahoo history available for {list(ALL_SYMBOLS)!r}\")\n", "history_length = min(capacity.n_input, len(aligned))\n", "history = aligned.iloc[-history_length:].copy().reset_index(drop=True)\n", "\n", "last_btc_price = float(history[PRICE_TARGET].iloc[-1])\n", "if last_btc_price <= 0.0:\n", - " raise ValueError(f'Last observed {PRICE_TARGET} must be positive, got {last_btc_price}')\n", + " raise ValueError(\n", + " f\"Last observed {PRICE_TARGET} must be positive, got {last_btc_price}\"\n", + " )\n", "\n", "window_start = aligned.index[-history_length].date()\n", "window_end = aligned.index[-1].date()\n", "print(\n", - " f'History window: {window_start} -> {window_end} '\n", - " f'({history_length} business days; deployment cap n_input={capacity.n_input})'\n", + " f\"History window: {window_start} -> {window_end} \"\n", + " f\"({history_length} business days; deployment cap n_input={capacity.n_input})\"\n", ")\n", - "print(f'Symbols ({len(ALL_SYMBOLS)}): {list(ALL_SYMBOLS)}')\n", - "print(f'Last observed {PRICE_TARGET}: {last_btc_price:,.2f}')\n", - "print(f'Forecast horizons (business days ahead): {HORIZONS}')\n", - "\n" + "print(f\"Symbols ({len(ALL_SYMBOLS)}): {list(ALL_SYMBOLS)}\")\n", + "print(f\"Last observed {PRICE_TARGET}: {last_btc_price:,.2f}\")\n", + "print(f\"Forecast horizons (business days ahead): {HORIZONS}\")" ] }, { @@ -288,9 +299,10 @@ "price_samples = result.to_numpy() # (sample, horizon, column)\n", "expected_shape = (N_SAMPLES, len(HORIZONS), 1)\n", "if price_samples.shape != expected_shape:\n", - " raise ValueError(f'Expected sample tensor of shape {expected_shape}, got {price_samples.shape}')\n", - "price_samples = price_samples[:, :, 0] # drop the singleton requested-column axis\n", - "\n" + " raise ValueError(\n", + " f\"Expected sample tensor of shape {expected_shape}, got {price_samples.shape}\"\n", + " )\n", + "price_samples = price_samples[:, :, 0] # drop the singleton requested-column axis" ] }, { @@ -312,8 +324,8 @@ "if negative_count > 0:\n", " fraction = negative_count / price_samples.size\n", " print(\n", - " f'WARNING: {negative_count} of {price_samples.size} {PRICE_TARGET} samples '\n", - " f'({fraction:.2%}) were negative; clamped to 0.0.'\n", + " f\"WARNING: {negative_count} of {price_samples.size} {PRICE_TARGET} samples \"\n", + " f\"({fraction:.2%}) were negative; clamped to 0.0.\"\n", " )\n", " price_samples = np.maximum(price_samples, 0.0)\n", "\n", @@ -323,9 +335,11 @@ "}\n", "\n", "pd.DataFrame(\n", - " {f'R_{horizon}': pd.Series(simple_returns_by_horizon[horizon]).describe() for horizon in HORIZONS}\n", - ").round(6)\n", - "\n" + " {\n", + " f\"R_{horizon}\": pd.Series(simple_returns_by_horizon[horizon]).describe()\n", + " for horizon in HORIZONS\n", + " }\n", + ").round(6)" ] }, { @@ -342,7 +356,9 @@ "FEASIBLE_BRACKET_SAFETY = 1e-6\n", "\n", "\n", - "def decision_probabilities(returns: np.ndarray, *, eps_long: float, eps_short: float) -> dict[str, float]:\n", + "def decision_probabilities(\n", + " returns: np.ndarray, *, eps_long: float, eps_short: float\n", + ") -> dict[str, float]:\n", " \"\"\"Tail probabilities of crossing the long / short thresholds.\n", "\n", " Returns the empirical mass strictly above ``+eps_long``, strictly below ``-eps_short``,\n", @@ -350,7 +366,7 @@ " \"\"\"\n", " p_long = float(np.mean(returns > eps_long))\n", " p_short = float(np.mean(returns < -eps_short))\n", - " return {'p_long': p_long, 'p_short': p_short, 'p_flat': 1.0 - p_long - p_short}\n", + " return {\"p_long\": p_long, \"p_short\": p_short, \"p_flat\": 1.0 - p_long - p_short}\n", "\n", "\n", "def kelly_fraction(returns: np.ndarray, *, max_leverage: float) -> float:\n", @@ -364,8 +380,8 @@ " r = np.asarray(returns, dtype=float)\n", " r_max = float(r.max())\n", " r_min = float(r.min())\n", - " feasible_upper = (-1.0 / r_min) if r_min < 0.0 else float('inf')\n", - " feasible_lower = (-1.0 / r_max) if r_max > 0.0 else float('-inf')\n", + " feasible_upper = (-1.0 / r_min) if r_min < 0.0 else float(\"inf\")\n", + " feasible_lower = (-1.0 / r_max) if r_max > 0.0 else float(\"-inf\")\n", " f_upper = min(feasible_upper - FEASIBLE_BRACKET_SAFETY, max_leverage)\n", " f_lower = max(feasible_lower + FEASIBLE_BRACKET_SAFETY, -max_leverage)\n", " if f_lower >= f_upper:\n", @@ -406,32 +422,34 @@ " action is ``flat`` and the position is zero. When the Kelly optimum disagrees\n", " in sign with the gated action, the position is clipped to zero.\n", " \"\"\"\n", - " probabilities = decision_probabilities(returns, eps_long=eps_long, eps_short=eps_short)\n", - " p_long = probabilities['p_long']\n", - " p_short = probabilities['p_short']\n", + " probabilities = decision_probabilities(\n", + " returns, eps_long=eps_long, eps_short=eps_short\n", + " )\n", + " p_long = probabilities[\"p_long\"]\n", + " p_short = probabilities[\"p_short\"]\n", " if p_long >= confidence_threshold and p_long > p_short:\n", - " action = 'long'\n", + " action = \"long\"\n", " elif p_short >= confidence_threshold and p_short > p_long:\n", - " action = 'short'\n", + " action = \"short\"\n", " else:\n", - " action = 'flat'\n", + " action = \"flat\"\n", " raw_kelly = kelly_fraction(returns, max_leverage=kelly_cap)\n", - " if action == 'long':\n", + " if action == \"long\":\n", " position = float(np.clip(raw_kelly, 0.0, kelly_cap))\n", - " elif action == 'short':\n", + " elif action == \"short\":\n", " position = float(np.clip(raw_kelly, -kelly_cap, 0.0))\n", " else:\n", " position = 0.0\n", " return {\n", - " 'action': action,\n", - " 'p_long': p_long,\n", - " 'p_short': p_short,\n", - " 'p_flat': probabilities['p_flat'],\n", - " 'mean_return': float(np.mean(returns)),\n", - " 'std_return': float(np.std(returns, ddof=1)),\n", - " 'kelly_fraction': raw_kelly,\n", - " 'position': position,\n", - " }\n" + " \"action\": action,\n", + " \"p_long\": p_long,\n", + " \"p_short\": p_short,\n", + " \"p_flat\": probabilities[\"p_flat\"],\n", + " \"mean_return\": float(np.mean(returns)),\n", + " \"std_return\": float(np.std(returns, ddof=1)),\n", + " \"kelly_fraction\": raw_kelly,\n", + " \"position\": position,\n", + " }" ] }, { @@ -464,7 +482,7 @@ " confidence_threshold=CONFIDENCE_THRESHOLD,\n", " kelly_cap=KELLY_CAP,\n", ")\n", - "pd.Series(single_decision, name=f'horizon_{SINGLE_HORIZON}').to_frame()\n" + "pd.Series(single_decision, name=f\"horizon_{SINGLE_HORIZON}\").to_frame()" ] }, { @@ -492,7 +510,7 @@ "source": [ "per_horizon = pd.DataFrame(\n", " {\n", - " f'horizon_{horizon}': decide(\n", + " f\"horizon_{horizon}\": decide(\n", " simple_returns_by_horizon[horizon],\n", " eps_long=EPS_LONG,\n", " eps_short=EPS_SHORT,\n", @@ -503,31 +521,31 @@ " }\n", ").T\n", "\n", - "action_set = set(per_horizon['action'].tolist())\n", - "if action_set == {'long'}:\n", - " combined_action = 'long'\n", - "elif action_set == {'short'}:\n", - " combined_action = 'short'\n", + "action_set = set(per_horizon[\"action\"].tolist())\n", + "if action_set == {\"long\"}:\n", + " combined_action = \"long\"\n", + "elif action_set == {\"short\"}:\n", + " combined_action = \"short\"\n", "else:\n", - " combined_action = 'flat'\n", + " combined_action = \"flat\"\n", "\n", - "mean_kelly = float(per_horizon['kelly_fraction'].mean())\n", - "if combined_action == 'long':\n", + "mean_kelly = float(per_horizon[\"kelly_fraction\"].mean())\n", + "if combined_action == \"long\":\n", " combined_position = float(np.clip(mean_kelly, 0.0, KELLY_CAP))\n", - "elif combined_action == 'short':\n", + "elif combined_action == \"short\":\n", " combined_position = float(np.clip(mean_kelly, -KELLY_CAP, 0.0))\n", "else:\n", " combined_position = 0.0\n", "\n", - "print(f'Combined multi-horizon action : {combined_action}')\n", - "print(f'Combined Kelly-sized position : {combined_position:+.4f}')\n", - "per_horizon\n" + "print(f\"Combined multi-horizon action : {combined_action}\")\n", + "print(f\"Combined Kelly-sized position : {combined_position:+.4f}\")\n", + "per_horizon" ] } ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/notebooks/pandas_result_conversion.ipynb b/notebooks/pandas_result_conversion.ipynb index 63e23bf..d7950c9 100644 --- a/notebooks/pandas_result_conversion.ipynb +++ b/notebooks/pandas_result_conversion.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -64,7 +65,9 @@ "\n", "from jointfm_client import ForecastResponse\n", "\n", - "payload = json.loads(Path('tests/fixtures/forecast_quantiles_response.json').read_text())\n", + "payload = json.loads(\n", + " Path(\"tests/fixtures/forecast_quantiles_response.json\").read_text()\n", + ")\n", "result = ForecastResponse.from_payload(payload)\n", "tidy = result.to_pandas_tidy()\n", "wide = result.to_pandas_wide()\n", @@ -75,7 +78,7 @@ ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/notebooks/predict_json.ipynb b/notebooks/predict_json.ipynb index 3e914e4..dc04ab7 100644 --- a/notebooks/predict_json.ipynb +++ b/notebooks/predict_json.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -65,20 +66,20 @@ "from jointfm_client import JointFMClient\n", "\n", "client = JointFMClient.from_env()\n", - "payload = json.loads(Path('tests/fixtures/forecast_mean_request.json').read_text())\n", - "payload['model_version'] = client.health(cache=True).model_version\n", + "payload = json.loads(Path(\"tests/fixtures/forecast_mean_request.json\").read_text())\n", + "payload[\"model_version\"] = client.health(cache=True).model_version\n", "response = client.predict(payload)\n", "\n", - "print('Payload:')\n", + "print(\"Payload:\")\n", "print(json.dumps(payload, indent=2))\n", - "print('\\nResponse:')\n", + "print(\"\\nResponse:\")\n", "print(json.dumps(response, indent=2))" ] } ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/notebooks/service_health.ipynb b/notebooks/service_health.ipynb index 2055c39..6364123 100644 --- a/notebooks/service_health.ipynb +++ b/notebooks/service_health.ipynb @@ -34,6 +34,7 @@ "outputs": [], "source": [ "from jointfm_client import bootstrap_notebook\n", + "\n", "bootstrap_notebook(add_src_root=True)" ] }, @@ -73,7 +74,7 @@ ], "metadata": { "kernelspec": { - "display_name": "joint-client-python (3.13.3.final.0)", + "display_name": "joint-client-python (3.13.3)", "language": "python", "name": "python3" }, diff --git a/pyproject.toml b/pyproject.toml index 179874c..1ca5513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ "debugpy", "pytest", "pytest-cov", + "pytest-xdist", "types-pyyaml", "ty", "numexpr", diff --git a/src/jointfm_client/client.py b/src/jointfm_client/client.py index 6ae5b1f..05a1a3a 100644 --- a/src/jointfm_client/client.py +++ b/src/jointfm_client/client.py @@ -168,6 +168,7 @@ def health(self, *, cache: bool = False, refresh: bool = False) -> HealthMetadat payload, expected_model_version=expected_model_version ) metadata = HealthMetadata.from_payload(payload) + self._sample_batch_cap = metadata.max_sample_count if cache: self._health_metadata = metadata return metadata @@ -178,6 +179,12 @@ def _uses_predict_route_for_health(self) -> bool: return False return self.settings.deployment_selector != "local_service" + def _health_route_configured(self) -> bool: + """Return whether a health probe can be issued with the current settings.""" + if self._uses_predict_route_for_health(): + return self.predict_url is not None + return self.health_url is not None + def _fetch_hosted_health_payload(self) -> Mapping[str, Any]: """POST a minimal health discriminator to the hosted predict URL.""" predict_url = self._require_predict_url("health") @@ -294,7 +301,7 @@ def forecast( nullable_columns=nullable_columns, bounds=bounds, ) - sample_cap = _known_sample_batch_cap(payload, self._sample_batch_cap) + sample_cap = self._resolve_sample_batch_cap(payload) if sample_cap is not None: return self._forecast_sample_batches(predict_url, payload, sample_cap) @@ -459,6 +466,22 @@ def _forecast_payload_from_rows( schema_version=schema_version, ) + def _resolve_sample_batch_cap(self, payload: Mapping[str, Any]) -> int | None: + """Return the batch size for an oversized sample request, if one applies. + + The deployment advertises its sampling budget as ``max_sample_count`` in + `/healthz`, so an explicit sample request probes health once and batches + locally instead of sending a request the service is guaranteed to reject. + The cap is remembered for the client's lifetime. Clients configured + without a reachable health route fall back to learning the cap from the + service's ``INPUT_SIZE_EXCEEDED`` response. + """ + if _requested_sample_count(payload) is None: + return None + if self._sample_batch_cap is None and self._health_route_configured(): + self.health() + return _known_sample_batch_cap(payload, self._sample_batch_cap) + def _forecast_sample_batches( self, predict_url: str, diff --git a/src/jointfm_client/configuration.py b/src/jointfm_client/configuration.py index e9c88db..7a627a1 100644 --- a/src/jointfm_client/configuration.py +++ b/src/jointfm_client/configuration.py @@ -144,9 +144,9 @@ def _validate_positive_finite(cls, value: float) -> float: class RetryConfig(_ConfigModel): """Default retry policy for transient HTTP failures.""" - max_attempts: int = 3 + max_attempts: int = 5 backoff_seconds: float = 1 - max_backoff_seconds: float = 30.0 + max_backoff_seconds: float = 60.0 status_codes: tuple[int, ...] = (408, 429, 500, 502, 503, 504) @field_validator("max_attempts") diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 1cd3376..331fce4 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -123,6 +123,7 @@ def test_example_notebooks_start_with_bootstrap_cell() -> None: notebook_paths = sorted((repo_root / "notebooks").glob("*.ipynb")) expected_bootstrap_source = ( "from jointfm_client import bootstrap_notebook\n" + "\n" "bootstrap_notebook(add_src_root=True)" ) diff --git a/tests/test_transport.py b/tests/test_transport.py index 0f86e07..d56f50a 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -898,6 +898,97 @@ def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ] +def test_client_forecast_samples_batches_from_advertised_health_sample_cap() -> None: + """Client batches oversized sample requests without a rejected predict call.""" + + class HealthCapTransport: + """Health Cap Transport (test helper).""" + + def __init__(self) -> None: + """Init.""" + self.payloads: list[Mapping[str, Any]] = [] + self.health_count = 0 + self.sample_offset = 0 + + def get_json(self, url: str) -> Mapping[str, Any]: + """Get json.""" + assert url == "http://127.0.0.1:8080/healthz" + self.health_count += 1 + payload = _health_payload() + payload["max_sample_count"] = 2 + return payload + + def post_json(self, url: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: + """Post json.""" + assert url == "http://127.0.0.1:8080/predict" + self.payloads.append(dict(payload)) + sample_count = payload["n_samples"] + assert isinstance(sample_count, int) + samples = [ + [[float(sample_index)]] + for sample_index in range( + self.sample_offset, + self.sample_offset + sample_count, + ) + ] + self.sample_offset += sample_count + response_payload = _forecast_response_payload(return_mode="samples") + outputs = cast(dict[str, object], response_payload["outputs"]) + outputs["samples"] = samples + diagnostics = cast(dict[str, object], response_payload["diagnostics"]) + diagnostics["seed"] = payload.get("seed") + return response_payload + + settings = JointFMSettings( + datarobot_endpoint=None, + datarobot_api_token=None, + health_url="http://127.0.0.1:8080/healthz", + predict_url="http://127.0.0.1:8080/predict", + deployment_selector="local_service", + schema_version="v1", + model_version="jointfm-inference:0.2.0+ckpt.sdk-test", + local_base_url="http://127.0.0.1:8080", + ) + transport = HealthCapTransport() + client = JointFMClient(settings=settings, transport=transport) + schema = DataFrameSchema( + columns=(ColumnSpec(name="target", modality="numeric", role="target"),), + time_index_mode="ordinal", + ) + + result = client.forecast_samples( + [{"target": 10.0}, {"target": 11.0}], + schema=schema, + query_times=[2], + requested_columns=["target"], + n_samples=5, + seed=7, + ) + + assert isinstance(result, SampleForecastResult) + assert result.samples == ( + ((0.0,),), + ((1.0,),), + ((2.0,),), + ((3.0,),), + ((4.0,),), + ) + assert transport.health_count == 1 + assert [payload["n_samples"] for payload in transport.payloads] == [2, 2, 1] + + client.forecast_samples( + [{"target": 10.0}, {"target": 11.0}], + schema=schema, + query_times=[2], + requested_columns=["target"], + n_samples=2, + seed=7, + ) + + assert transport.health_count == 1 + assert [payload["n_samples"] for payload in transport.payloads] == [2, 2, 1, 2] + + def test_client_predict_raises_typed_service_error_for_success_payload_errors() -> None: """Client predict raises typed service error for success payload errors.""" settings = JointFMSettings( diff --git a/uv.lock b/uv.lock index 230d41e..6f755ae 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-22T16:12:10.224528154Z" +exclude-newer = "2026-06-27T06:14:48.272858792Z" exclude-newer-span = "P14D" [[package]] @@ -491,6 +491,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -666,6 +675,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "ty" }, { name = "types-pyyaml" }, @@ -694,6 +704,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "ty" }, { name = "types-pyyaml" }, @@ -1425,6 +1436,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"