Contributing

Contributions to PyQuantLib are welcome!

Getting Started

For setup instructions, see Building from Source.

git clone https://github.com/quantales/pyquantlib.git
cd pyquantlib
python -m venv venv
source venv/bin/activate
pip install -r requirements-dev.txt
pip install -e .
pytest

Development Workflow

Running Tests

pytest                         # All tests
pytest --cov=pyquantlib        # With coverage
pytest tests/test_date.py      # Specific file
pytest -v                      # Verbose

Linting

ruff check tests/ pyquantlib/

Adding or Updating Bindings

Workflow

When adding or modifying bindings, follow these steps:

  1. C++ bindings: Create/update .cpp file in src/

  2. Tests: Add tests in tests/

  3. Build and verify: pip install -e . && pytest

  4. API docs: Update examples in docs/api/ if needed

  5. Stubs: Maintainer regenerates (contributors skip this step)

File Checklist

  • Add/update .cpp file in appropriate src/ subdirectory

  • Declare binding function in include/pyquantlib/pyquantlib.h

  • Register in module’s all.cpp

  • Consider hidden handle constructors for classes using handles

  • Add tests in tests/

  • Update API docs examples (if user-facing API changed)

API Documentation

API docs in docs/api/ use a consistent 3-level heading structure:

# Module Name (h1)
  ## Group Name (h2) - mirrors QuantLib subdirectory
    ### ClassName (h3) - one per class

Each class entry uses Sphinx autoclass to pull documentation from C++ docstrings:

### NewClassName

Brief description (one line).

```{eval-rst}
.. autoclass:: pyquantlib.NewClassName
   :members:
   :undoc-members:
```

```python
# Optional usage example
engine = ql.NewClassName(process)
```

Part

Source

autoclass directive

Automated from C++ docstrings

Brief description

Manual (one line)

Usage examples

Manual (optional)

For new classes, the minimum required is the autoclass directive with a brief description.

Example

// src/quotes/simplequote.cpp
#include "pyquantlib/pyquantlib.h"
#include <ql/quotes/simplequote.hpp>
#include <pybind11/pybind11.h>

namespace py = pybind11;
using namespace QuantLib;

void ql_quotes::simplequote(py::module_& m) {
    py::class_<SimpleQuote, Quote, ext::shared_ptr<SimpleQuote>>(m, "SimpleQuote",
        "Simple quote for market data.")
        .def(py::init<Real>(), py::arg("value"))
        .def("setValue", &SimpleQuote::setValue, py::arg("value"))
        .def("reset", &SimpleQuote::reset);
}

Code Conventions

File Headers

Each file lists its contributors. When adding or substantially modifying a file, add the appropriate copyright line.

Includes

// GOOD: Specific headers
#include <ql/quotes/simplequote.hpp>
#include <ql/time/date.hpp>

// BAD: Avoid umbrella header
#include <ql/quantlib.hpp>

Docstrings

Docstrings in pybind11 bindings are automatically pulled into the API documentation via Sphinx autoclass. Keep them concise:

// GOOD
.def("value", &Quote::value, "Returns the current value.")

// AVOID
.def("value", &Quote::value, "This method returns the current value of the quote object.")

Class-level docstrings appear in the API reference, so write them as brief descriptions:

py::class_<SimpleQuote, Quote, ext::shared_ptr<SimpleQuote>>(
    m, "SimpleQuote", "Simple quote with settable value.")

Common Pitfalls

Bridge-Pattern Classes

QuantLib’s DayCounter and Calendar use the bridge (pimpl) pattern. Their default constructors create objects with no implementation (null internal pointer). These “empty” objects throw errors when used, e.g., “no day counter implementation provided”.

In pybind11 bindings, default argument values are evaluated at module import time. If a binding uses DayCounter() as a default, the invalid object is created during import, which can cause import failures if any code path touches it.

There are three fixes, depending on the situation:

// BAD: Causes import failure
py::arg("dayCounter") = DayCounter()

// FIX 1: Concrete default (when any valid value works)
py::arg("dayCounter") = Actual365Fixed()

// FIX 2: Required argument (when no sensible default exists)
py::arg("dayCounter")

// FIX 3: py::none() sentinel (when DayCounter() carries semantic meaning)
// Used when the null default means "use the index's day counter" or similar
.def(py::init([](/* ... */, const py::object& dayCounter, /* ... */) {
    DayCounter dc;
    if (!dayCounter.is_none())
        dc = dayCounter.cast<DayCounter>();
    // ... pass dc to C++ constructor
}), py::arg("dayCounter") = py::none())

Situation

Fix

Default is arbitrary (any valid value works)

Replace with a concrete default

No sensible default exists in C++

Make the parameter required

Default is a sentinel with semantic meaning

Use py::none() + lambda

Most bindings fall into the first category. The third applies when the C++ null default triggers fallback behavior (e.g., FloatingRateCoupon using the index’s day counter, or Null<Natural>() for optional lookback days). Getting this wrong produces silent, incorrect results.

See The Bridge Pattern Trap for the full story.

Note

The same py::none() + lambda pattern works for Null<T>() sentinels, which pybind11 also cannot convert as default arguments.

Enum Pass-by-Reference

pybind11 enum values are singletons. Passing them by reference to C++ functions that modify them corrupts the singleton for all subsequent uses in the Python session.

// BAD: corrupts enum singleton
.def("check", [](const Foo& self, SomeEnum& e) {
    return self.check(e);  // e modified in place → singleton corrupted
})

// GOOD: pass by value, return tuple with modified value
.def("check", [](const Foo& self, SomeEnum e) {
    bool result = self.check(e);
    return py::make_tuple(result, e);
})

Note

This behavior is not explicitly documented in pybind11, but is a consequence of how py::enum_ implements singletons internally. pybind11 v3 introduced py::native_enum which uses Python’s native enum module and is recommended for new bindings. This may behave differently but has not been tested in PyQuantLib.

See The Enum Singleton Problem for the full story of how this issue was discovered and debugged.

Trampoline Classes

See Internals for trampoline implementation details and guidelines.

Error Handling in Tests

PyQuantLib raises two types of exceptions:

Exception

Source

When

RuntimeError

PyQuantLib wrapper

Validation in the binding code (e.g., invalid RNG type)

ql.Error

QuantLib internals

QuantLib’s own error checks (e.g., invalid parameters)

# RuntimeError: PyQuantLib wrapper validates input
with pytest.raises(RuntimeError, match="Unsupported RNG type"):
    ql.MCEuropeanEngine(process, "invalid_rng", ...)

# ql.Error: QuantLib validates internally
with pytest.raises(ql.Error, match="two underlyings"):
    ql.StulzEngine(process_array)  # requires exactly 2 processes

Use RuntimeError for errors thrown in the wrapper code (std::runtime_error) and ql.Error for errors originating from QuantLib.

Type Stubs

PyQuantLib includes .pyi stub files for IDE autocompletion and type checking. These are generated using pybind11-stubgen.

pybind11-stubgen produces non-deterministic import ordering across platforms. The same bindings on Windows vs Linux generate identical stubs but with imports in different order:

# Windows might produce:
from pyquantlib._pyquantlib import GeneralizedBlackScholesProcess as BlackScholesMertonProcess
from pyquantlib._pyquantlib import GeneralizedBlackScholesProcess

# Linux might produce:
from pyquantlib._pyquantlib import GeneralizedBlackScholesProcess
from pyquantlib._pyquantlib import GeneralizedBlackScholesProcess as BlackScholesMertonProcess

This causes spurious diffs and merge conflicts. To avoid this, the maintainer regenerates stubs on a single platform (Windows) after merging PRs.

Contributors can regenerate stubs locally, but should not include regenerated stubs in pull requests.

python scripts/stubgen.py

Note

Stub validation is not included in CI due to the cross-platform non-determinism described above.

Questions?

Open an issue on GitHub.