From eb4fc4a59558ff77943183a46ad0f2dae0ad89e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eveno?= Date: Mon, 10 Apr 2023 17:40:22 +0200 Subject: [PATCH 1/5] Add basket option class and methods --- exotx/data/marketdata.py | 65 +++++++-- exotx/instruments/asian_option.py | 2 + exotx/instruments/basket_option.py | 133 ++++++++++++++++++ exotx/instruments/basket_type.py | 36 +++++ exotx/models/blackscholesmodel.py | 4 +- exotx/models/hestonmodel.py | 7 +- exotx/tests/conftest.py | 4 +- exotx/tests/data/test_marketdata.py | 4 +- exotx/tests/instruments/test_asian_option.py | 8 +- .../tests/instruments/test_autocallable_bs.py | 8 +- .../instruments/test_autocallable_heston.py | 4 +- .../tests/instruments/test_barrier_option.py | 3 +- exotx/tests/models/test_hestonmodel.py | 4 +- 13 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 exotx/instruments/basket_option.py create mode 100644 exotx/instruments/basket_type.py diff --git a/exotx/data/marketdata.py b/exotx/data/marketdata.py index 6f9cc4d..d0f963e 100644 --- a/exotx/data/marketdata.py +++ b/exotx/data/marketdata.py @@ -9,33 +9,40 @@ class MarketData: def __init__(self, - spot: float, + underlying_spots: List[float], risk_free_rate: float, dividend_rate: float, reference_date: Union[datetime, str, None] = None, expiration_dates: List[Union[datetime, str]] = None, strikes: List[float] = None, data: List[List[float]] = None, - black_scholes_volatility: float = None) -> None: + underlying_black_scholes_volatilities: List[float] = None, + correlation_matrix: List[List[float]] = None) -> None: # set the reference date self._set_reference_date(reference_date) - # set the underlying spot value - self._set_underlying_spot(spot) + # set underlying spot values + self._set_underlying_spots(underlying_spots) # set market rate curves self._set_rate_curves(dividend_rate, risk_free_rate) # set the volatility surface - self._set_volatility_surface(black_scholes_volatility, data, expiration_dates, strikes) + self._set_volatility_surface(underlying_black_scholes_volatilities, data, expiration_dates, strikes) + + # set the correlation matrix + self._set_correlation_matrix(correlation_matrix) + + # region setters # TODO: Have a proper rate curves stripper service - def _set_rate_curves(self, dividend_rate, risk_free_rate): + def _set_rate_curves(self, dividend_rate, risk_free_rate) -> None: self.risk_free_rate = risk_free_rate self.dividend_rate = dividend_rate # TODO: Allow for multiple volatility surfaces for each underlying - def _set_volatility_surface(self, black_scholes_volatility, data, expiration_dates, strikes): + def _set_volatility_surface(self, underlying_black_scholes_volatilities: List[float], data, expiration_dates, + strikes) -> None: self.expiration_dates: List[datetime] = [] if not expiration_dates: self.expiration_dates = None @@ -47,14 +54,19 @@ def _set_volatility_surface(self, black_scholes_volatility, data, expiration_dat self.expiration_dates.append(expiration_date) self.strikes = strikes self.data = data - self.black_scholes_volatility = black_scholes_volatility - # TODO: Allow for multiple underlying spots to be defined, may need a proper container class - def _set_underlying_spot(self, spot): - assert spot > 0 - self.spot = spot + if underlying_black_scholes_volatilities: + for vol in underlying_black_scholes_volatilities: + assert vol >= 0, f"Invalid volatility: {vol}" + self.underlying_black_scholes_volatilities = underlying_black_scholes_volatilities # set to None if it does not exist, OK - def _set_reference_date(self, reference_date: Union[datetime, str, None]): + # TODO: Allow for multiple underlying spots to be defined, may need a proper container class or a dict + def _set_underlying_spots(self, underlying_spots: List[float]) -> None: + for underlying_spot in underlying_spots: + assert underlying_spot > 0, f"Invalid underlying spot {underlying_spot}" + self.underlying_spots = underlying_spots + + def _set_reference_date(self, reference_date: Union[datetime, str, None]) -> None: if isinstance(reference_date, str): reference_date = datetime.strptime(reference_date, '%Y-%m-%d') elif isinstance(reference_date, datetime): @@ -64,9 +76,28 @@ def _set_reference_date(self, reference_date: Union[datetime, str, None]): reference_date = datetime.today() self.reference_date: datetime = reference_date + def _set_correlation_matrix(self, correlation_matrix: Union[List[List[float]], None]) -> None: + if correlation_matrix: + for rows in correlation_matrix: + for rho in rows: + assert 1 >= rho >= -1, "Invalid correlation matrix" + self.correlation_matrix = correlation_matrix + + # endregion + + # region getters def get_ql_reference_date(self) -> ql.Date: return ql.Date().from_date(self.reference_date) + def get_correlation_matrix(self) -> ql.Matrix: + matrix = ql.Matrix(len(self.correlation_matrix), len(self.correlation_matrix)) + for i in range(len(self.correlation_matrix)): + matrix[i][i] = 1.0 + for j in range(i + 1, len(self.correlation_matrix)): + matrix[i][j] = self.correlation_matrix[i][j] + matrix[j][i] = self.correlation_matrix[i][j] + return matrix + # TODO: Get these from a proper rate curve stripper service def get_yield_curve(self, day_counter) -> ql.YieldTermStructureHandle: flat_forward = ql.FlatForward(self.get_ql_reference_date(), self.risk_free_rate, day_counter) @@ -76,6 +107,9 @@ def get_dividend_curve(self, day_counter) -> ql.YieldTermStructureHandle: flat_forward = ql.FlatForward(self.get_ql_reference_date(), self.dividend_rate, day_counter) return ql.YieldTermStructureHandle(flat_forward) + # endregion + + # region serialization/deserialization @classmethod def from_json(cls, data: dict): schema = MarketDataSchema() @@ -90,19 +124,20 @@ def to_json(self, format_type: str = "dict"): return json.dumps(my_json) else: raise NotImplementedError(f"Invalid format type {format_type} when dumping") + # endregion # region Schema class MarketDataSchema(Schema): - spot = fields.Float(required=True) + underlying_spots = fields.List(fields.Float(required=True)) risk_free_rate = fields.Float(required=True) dividend_rate = fields.Float(required=True) reference_date = fields.DateTime(format='%Y-%m-%d', allow_none=True) expiration_dates = fields.List(fields.DateTime(format='%Y-%m-%d'), allow_none=True) strikes = fields.List(fields.Float(), allow_none=True) data = fields.List(fields.List(fields.Float), allow_none=True) - black_scholes_volatility = fields.Float(allow_none=True) + underlying_black_scholes_volatilities = fields.List(fields.Float(allow_none=True)) @post_load def make_market_data(self, data, **kwargs) -> MarketData: diff --git a/exotx/instruments/asian_option.py b/exotx/instruments/asian_option.py index 794422c..0c47ae2 100644 --- a/exotx/instruments/asian_option.py +++ b/exotx/instruments/asian_option.py @@ -151,6 +151,7 @@ def from_json(cls, json_data): # endregion +# region Schema class AsianOptionSchema(Schema): strike = fields.Float() maturity = fields.Date(format="%Y-%m-%d") @@ -162,3 +163,4 @@ class AsianOptionSchema(Schema): @post_load def make_asian_option(self, data, **kwargs) -> AsianOption: return AsianOption(**data) +# endregion diff --git a/exotx/instruments/basket_option.py b/exotx/instruments/basket_option.py new file mode 100644 index 0000000..b83b023 --- /dev/null +++ b/exotx/instruments/basket_option.py @@ -0,0 +1,133 @@ +from datetime import datetime +from typing import Union + +import QuantLib as ql +from marshmallow import Schema, fields, post_load + +from exotx.enums.enums import RandomNumberGenerator +from exotx.helpers.dates import convert_maturity_to_ql_date +from exotx.instruments.basket_type import BasketType, convert_basket_type, BasketTypeField +from exotx.instruments.instrument import Instrument +from exotx.instruments.option_type import convert_option_type_to_ql, OptionType, OptionTypeField +from exotx.utils.pricing_configuration import PricingConfiguration + + +class BasketOption(Instrument): + def __init__(self, + strike: float, + maturity: Union[str, datetime], + option_type: Union[str, OptionType], + basket_type: Union[str, BasketType]): + assert strike >= 0, "Invalid strike: cannot be negative" + self.strike = strike + self.maturity = convert_maturity_to_ql_date(maturity) + self.option_type = convert_option_type_to_ql(option_type) + self.basket_type = convert_basket_type(basket_type) + + def price(self, market_data, static_data, pricing_config: PricingConfiguration, seed: int = 1) -> dict: + # set the reference date + reference_date: ql.Date = market_data.get_ql_reference_date() + ql.Settings.instance().evaluationDate = reference_date + + # create the product + ql_payoff = ql.PlainVanillaPayoff(self.option_type, self.strike) + ql_exercise = ql.EuropeanExercise(self.maturity) + + ql_basket_payoff = self._basket_type_to_payoff(ql_payoff) + ql_option = ql.BasketOption(ql_basket_payoff, ql_exercise) + + # set the pricing engine + ql_engine = self._get_ql_pricing_engine(market_data, static_data, pricing_config, seed) + ql_option.setPricingEngine(ql_engine) + + # price + price = ql_option.NPV() + if pricing_config.compute_greeks: + delta = ql_option.delta() + gamma = ql_option.gamma() + theta = ql_option.theta() + return {'price': price, 'delta': delta, 'gamma': gamma, 'theta': theta} + else: + return {'price': price} + + def _basket_type_to_payoff(self, payoff: ql.PlainVanillaPayoff): + """ + Converts a plain vanilla payoff to a basket payoff based on the basket type. + + Args: + payoff (ql.PlainVanillaPayoff): A plain vanilla payoff object from QuantLib. + + Returns: + ql.BasketPayoff: A QuantLib BasketPayoff object corresponding to the specified basket type. + + Raises: + Exception: If the basket type is not one of the supported types (MINBASKET, MAXBASKET, SPREADBASKET, AVERAGEBASKET). + """ + if self.basket_type == BasketType.MINBASKET: + return ql.MinBasketPayoff(payoff) + elif self.basket_type == BasketType.MAXBASKET: + return ql.MaxBasketPayoff(payoff) + elif self.basket_type == BasketType.SPREADBASKET: + return ql.SpreadBasketPayoff(payoff) + elif self.basket_type == BasketType.AVERAGEBASKET: + return ql.AverageBasketPayoff(payoff) + else: + raise Exception("Invalid basket type") + + @staticmethod + def _get_ql_pricing_engine(market_data, static_data, pricing_config: PricingConfiguration, seed: int): + """ + Constructs a QuantLib MCEuropeanBasketEngine for pricing basket options using Monte Carlo simulations. + + This function creates a list of Black-Scholes-Merton processes for each underlying asset, using the spot prices + and volatilities provided by the MarketData instance. The processes are then combined into a StochasticProcessArray, + taking into account the correlation matrix from the MarketData instance. + + Finally, a MCEuropeanBasketEngine is constructed using the StochasticProcessArray, along with the specified + random number generator, time steps per year, required samples, and random seed. + + Args: + market_data (MarketData): A MarketData instance containing the required market data, including underlying + spot prices, volatilities, and the correlation matrix. + static_data (StaticData): A StaticData instance containing static information such as calendar and day counter. + seed (int): A random seed for the Monte Carlo simulations. + + Returns: + ql.MCEuropeanBasketEngine: A QuantLib MCEuropeanBasketEngine for pricing basket options. + + Note: + The current implementation only supports MCEuropeanBasketEngine. Additional pricing engines can be added + based on the basket type and/or pricing configuration. + """ + + # set the reference date + reference_date = market_data.get_ql_reference_date() + + # set static data + calendar = static_data.get_ql_calendar() + day_counter = static_data.get_ql_day_counter() + + processes = [ql.BlackScholesMertonProcess(ql.QuoteHandle(ql.SimpleQuote(x)), + market_data.get_dividend_curve(), + market_data.get_yield_curve(), + ql.BlackVolTermStructureHandle( + ql.BlackConstantVol(reference_date, calendar, y, day_counter))) + for x, y in zip(market_data.underlying_spots, market_data.underlying_black_scholes_volatilities)] + multi_processes = ql.StochasticProcessArray(processes, market_data.get_correlation_matrix()) + + # TODO: Consider different pricing engines based on self.basket_type and/or pricing_config + return ql.MCEuropeanBasketEngine(multi_processes, RandomNumberGenerator.PSEUDORANDOM, timeStepsPerYear=1, + requiredSamples=10000, seed=seed) + + +# region Schema +class BasketOptionSchema(Schema): + strike = fields.Float() + maturity = fields.Date(format="%Y-%m-%d") + option_type = OptionTypeField() + basket_type = BasketTypeField() + + @post_load + def make_basket_option(self, data, **kwargs) -> BasketOption: + return BasketOption(**data) +# endregion diff --git a/exotx/instruments/basket_type.py b/exotx/instruments/basket_type.py new file mode 100644 index 0000000..6a82c54 --- /dev/null +++ b/exotx/instruments/basket_type.py @@ -0,0 +1,36 @@ +from enum import Enum +from typing import Union + +from marshmallow import fields, ValidationError + + +class BasketType(Enum): + MINBASKET = 'minbasket' + MAXBASKET = 'maxbasket' + SPREADBASKET = 'spreadbasket' + AVERAGEBASKET = 'averagebasket' + + +def convert_basket_type(basket_type: Union[str, BasketType]) -> BasketType: + if isinstance(basket_type, str): + basket_type = basket_type.upper() + if basket_type not in BasketType.__members__: + raise ValueError( + f"Invalid basket type \"{basket_type}\", expected one of {list(BasketType.__members__.keys())}") + return BasketType[basket_type] + elif isinstance(basket_type, BasketType): + return basket_type + else: + raise Exception(f"Invalid basket type \"{type(basket_type)}\"") + + +class BasketTypeField(fields.Field): + def _serialize(self, value: BasketType, attr, obj, **kwargs) -> str: + return value.name + + def _deserialize(self, value: str, attr, data, **kwargs) -> BasketType: + try: + return BasketType[value] + except KeyError as error: + raise ValidationError( + f"Invalid basket type \"{value}\", expected one of {list(BasketType.__members__.keys())}") from error diff --git a/exotx/models/blackscholesmodel.py b/exotx/models/blackscholesmodel.py index fff8b4d..e44085b 100644 --- a/exotx/models/blackscholesmodel.py +++ b/exotx/models/blackscholesmodel.py @@ -18,13 +18,13 @@ def __init__(self, self._day_counter: ql.DayCounter = static_data.get_ql_day_counter() def setup(self) -> ql.BlackScholesMertonProcess: - spot_handle = ql.QuoteHandle(ql.SimpleQuote(self.market_data.spot)) + spot_handle = ql.QuoteHandle(ql.SimpleQuote(self.market_data.underlying_spots[0])) flat_ts = self.market_data.get_yield_curve(self._day_counter) dividend_yield = self.market_data.get_dividend_curve(self._day_counter) flat_vol_ts = ql.BlackVolTermStructureHandle( ql.BlackConstantVol(self._reference_date, self._calendar, - self.market_data.black_scholes_volatility, + self.market_data.underlying_black_scholes_volatilities[0], self._day_counter) ) return ql.BlackScholesMertonProcess(spot_handle, dividend_yield, flat_ts, flat_vol_ts) diff --git a/exotx/models/hestonmodel.py b/exotx/models/hestonmodel.py index 569b398..9f8bdd1 100644 --- a/exotx/models/hestonmodel.py +++ b/exotx/models/hestonmodel.py @@ -45,13 +45,14 @@ def _setup(self, initial_conditions: Tuple[float, ...] = None) -> Tuple[ql.Hesto process = ql.HestonProcess(self.market_data.get_yield_curve(self._day_counter), self.market_data.get_dividend_curve(self._day_counter), - ql.QuoteHandle(ql.SimpleQuote(self.market_data.spot)), + ql.QuoteHandle(ql.SimpleQuote(self.market_data.underlying_spots[0])), v0, kappa, theta, sigma, rho) model = ql.HestonModel(process) return process, model - def _setup_helpers(self, engine: ql.PricingEngine) -> Tuple[List[ql.HestonModelHelper], List[Tuple[ql.Date, float]]]: + def _setup_helpers(self, engine: ql.PricingEngine) -> Tuple[ + List[ql.HestonModelHelper], List[Tuple[ql.Date, float]]]: helpers = [] grid_data = [] for i, date in enumerate([ql.Date().from_date(date) for date in self.market_data.expiration_dates]): @@ -60,7 +61,7 @@ def _setup_helpers(self, engine: ql.PricingEngine) -> Tuple[List[ql.HestonModelH p = ql.Period(t, ql.Days) vols = self.market_data.data[i][j] helper = ql.HestonModelHelper( - p, self._calendar, self.market_data.spot, strike, + p, self._calendar, self.market_data.underlying_spots[0], strike, ql.QuoteHandle(ql.SimpleQuote(vols)), self.market_data.get_yield_curve(self._day_counter), self.market_data.get_dividend_curve(self._day_counter)) diff --git a/exotx/tests/conftest.py b/exotx/tests/conftest.py index ba56a60..5360b23 100644 --- a/exotx/tests/conftest.py +++ b/exotx/tests/conftest.py @@ -18,10 +18,10 @@ def my_static_data() -> StaticData: def my_market_data() -> MarketData: my_json = { 'reference_date': '2015-11-06', - 'spot': 100, + 'underlying_spots': [100], 'risk_free_rate': 0.08, 'dividend_rate': 0.04, - 'black_scholes_volatility': 0.25, + 'underlying_black_scholes_volatilities': [0.25], 'expiration_dates': [ '2015-12-06', '2016-01-06', '2016-02-06', '2016-03-06', '2016-04-06', '2016-05-06', '2016-06-06', '2016-07-06', '2016-08-06', '2016-09-06', '2016-10-06', '2016-11-06', diff --git a/exotx/tests/data/test_marketdata.py b/exotx/tests/data/test_marketdata.py index b3f6bc5..c33e05b 100644 --- a/exotx/tests/data/test_marketdata.py +++ b/exotx/tests/data/test_marketdata.py @@ -7,10 +7,10 @@ def test_market_data_from_json(): # Arrange my_json = { 'reference_date': '2015-11-06', - 'spot': 100, + 'underlying_spots': [100], 'risk_free_rate': 0.01, 'dividend_rate': 0, - 'black_scholes_volatility': 0.2 + 'underlying_black_scholes_volatilities': [0.2] } # Act diff --git a/exotx/tests/instruments/test_asian_option.py b/exotx/tests/instruments/test_asian_option.py index d30e58f..6f1626d 100644 --- a/exotx/tests/instruments/test_asian_option.py +++ b/exotx/tests/instruments/test_asian_option.py @@ -27,10 +27,10 @@ def my_static_data() -> StaticData: def my_market_data() -> MarketData: my_json = { 'reference_date': '2015-11-06', - 'spot': 80, + 'underlying_spots': [80], 'risk_free_rate': 0.05, 'dividend_rate': -0.03, - 'black_scholes_volatility': 0.20 + 'underlying_black_scholes_volatilities': [0.20] } return MarketData.from_json(my_json) @@ -136,10 +136,10 @@ def test_price_analytic_discrete_geometric_average_strike(my_asian_option: Asian my_asian_option.future_fixing_dates = [ql.Date().from_date(my_market_data.reference_date + timedelta(days=36 * i)) for i in range(1, 11)] my_asian_option.average_convention = average_convention - my_market_data.spot = 100 + my_market_data.underlying_spots = [100] my_market_data.risk_free_rate = 0.06 my_market_data.dividend_rate = 0.03 - my_market_data.black_scholes_volatility = 0.20 + my_market_data.underlying_black_scholes_volatilities = [0.20] # Act result = price(my_asian_option, my_market_data, my_static_data, my_pricing_config) diff --git a/exotx/tests/instruments/test_autocallable_bs.py b/exotx/tests/instruments/test_autocallable_bs.py index 8f959f3..29f2c90 100644 --- a/exotx/tests/instruments/test_autocallable_bs.py +++ b/exotx/tests/instruments/test_autocallable_bs.py @@ -10,16 +10,16 @@ @pytest.fixture def my_market_data() -> MarketData: reference_date = '2015-11-06' - spot = 100.0 - black_scholes_volatility = 0.2 + underlying_spots = [100.0] + underlying_black_scholes_volatilities = [0.2] risk_free_rate = 0.01 dividend_rate = 0.0 return MarketData(reference_date=reference_date, - spot=spot, + underlying_spots=underlying_spots, risk_free_rate=risk_free_rate, dividend_rate=dividend_rate, - black_scholes_volatility=black_scholes_volatility) + underlying_black_scholes_volatilities=underlying_black_scholes_volatilities) @pytest.fixture diff --git a/exotx/tests/instruments/test_autocallable_heston.py b/exotx/tests/instruments/test_autocallable_heston.py index 730d8ea..098a79f 100644 --- a/exotx/tests/instruments/test_autocallable_heston.py +++ b/exotx/tests/instruments/test_autocallable_heston.py @@ -10,7 +10,7 @@ @pytest.fixture def my_market_data() -> MarketData: reference_date = '2015-11-06' - spot = 100.0 + underlying_spots = [100.0] risk_free_rate = 0.01 dividend_rate = 0.0 expiration_dates = ['2015-12-06', '2016-01-06', '2016-02-06', '2016-03-06', '2016-04-06', @@ -46,7 +46,7 @@ def my_market_data() -> MarketData: [0.34891, 0.34154, 0.33539, 0.3297, 0.33742, 0.33337, 0.32881, 0.32492]] return MarketData(reference_date=reference_date, - spot=spot, + underlying_spots=underlying_spots, risk_free_rate=risk_free_rate, dividend_rate=dividend_rate, expiration_dates=expiration_dates, diff --git a/exotx/tests/instruments/test_barrier_option.py b/exotx/tests/instruments/test_barrier_option.py index 14f81ec..6f40176 100644 --- a/exotx/tests/instruments/test_barrier_option.py +++ b/exotx/tests/instruments/test_barrier_option.py @@ -65,7 +65,8 @@ def test_barrier_option_fd_heston_barrier_engine_constant_vol(my_barrier_option: # Act model = 'fd-heston-barrier' # test if we retrieve the same price as BS - my_market_data.data = [[my_market_data.black_scholes_volatility] * len(i) for i in my_market_data.data] + my_market_data.data = [[my_market_data.underlying_black_scholes_volatilities[0]] * len(i) for i in + my_market_data.data] pv = price(my_barrier_option, my_market_data, my_static_data, model) # Assert diff --git a/exotx/tests/models/test_hestonmodel.py b/exotx/tests/models/test_hestonmodel.py index 0a76336..c992563 100644 --- a/exotx/tests/models/test_hestonmodel.py +++ b/exotx/tests/models/test_hestonmodel.py @@ -15,7 +15,7 @@ def my_static_data() -> StaticData: @pytest.fixture def my_market_data() -> MarketData: reference_date = '2015-11-06' - spot = 659.37 + underlying_spots = [659.37] risk_free_rate = 0.01 dividend_rate = 0.0 expiration_dates = ['2015-12-06', '2016-01-06', '2016-02-06', '2016-03-06', '2016-04-06', @@ -51,7 +51,7 @@ def my_market_data() -> MarketData: [0.34891, 0.34154, 0.33539, 0.3297, 0.33742, 0.33337, 0.32881, 0.32492]] return MarketData(reference_date=reference_date, - spot=spot, + underlying_spots=underlying_spots, expiration_dates=expiration_dates, strikes=strikes, data=data, From 5f437a0024b43437720bee07de9f9264b7cf9881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eveno?= Date: Mon, 10 Apr 2023 17:40:45 +0200 Subject: [PATCH 2/5] Code cleanup --- .github/workflows/python-package.yml | 40 +++++++++++------------ .github/workflows/python-publish.yml | 48 ++++++++++++++-------------- README.md | 19 +++++++++-- setup.py | 22 ++++++------- 4 files changed, 72 insertions(+), 57 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5408803..7376cdd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,25 +16,25 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10.2"] + python-version: [ "3.10.2" ] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 1eea3ea..08db556 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,7 +10,7 @@ name: Upload Python Package on: release: - types: [published] + types: [ published ] permissions: contents: read @@ -21,26 +21,26 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish distribution to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ - - name: Publish distribution to PyPI - if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@master + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish distribution to Test PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish distribution to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index 6df0e0e..aaef1db 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # exotx +

@@ -9,11 +10,14 @@

-exotx is a Python wrapper for the [QuantLib library](https://www.quantlib.org/), a powerful open-source library for quantitative finance. exotx provides a simple and user-friendly interface for pricing and analyzing financial derivatives using QuantLib's advanced numerical methods. +exotx is a Python wrapper for the [QuantLib library](https://www.quantlib.org/), a powerful open-source library for +quantitative finance. exotx provides a simple and user-friendly interface for pricing and analyzing financial +derivatives using QuantLib's advanced numerical methods. ## Installation To install exotx, simply use pip: + ```sh pip install exotx ``` @@ -21,6 +25,7 @@ pip install exotx ## Usage ### Define the product + ```python import exotx @@ -35,14 +40,17 @@ my_autocallable = exotx.Autocallable(notional, strike, autocall_barrier_level, a ``` ### Define the static data + The object that represents static data such as the calendar, the day counter or the business day convention used. #### From the constructor + ```python my_static_data = exotx.StaticData(day_counter='Actual360', business_day_convention='ModifiedFollowing') ``` #### From JSON + ```python my_json = { 'day_counter': 'Actual360', @@ -52,7 +60,9 @@ my_static_data = exotx.StaticData.from_json(my_json) ``` ### Define the market data + #### From the constructor + ```python reference_date = '2015-11-06' spot = 100.0 @@ -62,7 +72,9 @@ black_scholes_volatility = 0.2 my_market_data = exotx.MarketData(reference_date, spot, risk_free_rate, dividend_rate, black_scholes_volatility=black_scholes_volatility) ``` + #### From JSON + ```python my_json = { 'reference_date': '2015-11-06', @@ -75,16 +87,19 @@ my_market_data = exotx.MarketData.from_json(my_json) ``` ### Price the product + ```python exotx.price(my_autocallable, my_market_data, my_static_data, model='black-scholes') ``` + ```plaintext 96.08517973497098 ``` ## Contributing -We welcome contributions to exotx! If you find a bug or would like to request a new feature, please open an issue on the [Github repository](https://github.com/sebastieneveno/exotx). +We welcome contributions to exotx! If you find a bug or would like to request a new feature, please open an issue on +the [Github repository](https://github.com/sebastieneveno/exotx). If you would like to contribute code, please submit a pull request. ## License diff --git a/setup.py b/setup.py index 45ed6ff..77c44fb 100644 --- a/setup.py +++ b/setup.py @@ -29,15 +29,15 @@ ], python_requires='>=3.10.2, <4', classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'Intended Audience :: Financial and Insurance Industry', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.10', - 'Operating System :: OS Independent', - 'Intended Audience :: Science/Research', - 'Topic :: Software Development', - 'Topic :: Office/Business :: Financial', - 'Topic :: Scientific/Engineering :: Information Analysis' - ] + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: Financial and Insurance Industry', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.10', + 'Operating System :: OS Independent', + 'Intended Audience :: Science/Research', + 'Topic :: Software Development', + 'Topic :: Office/Business :: Financial', + 'Topic :: Scientific/Engineering :: Information Analysis' + ] ) From a7103a36f86771810d8150ddc0fa48f192f41396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eveno?= Date: Mon, 10 Apr 2023 18:20:31 +0200 Subject: [PATCH 3/5] Fixes + add test on basket option --- exotx/data/marketdata.py | 1 + exotx/instruments/basket_option.py | 8 +- exotx/tests/instruments/test_basket_option.py | 78 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 exotx/tests/instruments/test_basket_option.py diff --git a/exotx/data/marketdata.py b/exotx/data/marketdata.py index d0f963e..241b48f 100644 --- a/exotx/data/marketdata.py +++ b/exotx/data/marketdata.py @@ -138,6 +138,7 @@ class MarketDataSchema(Schema): strikes = fields.List(fields.Float(), allow_none=True) data = fields.List(fields.List(fields.Float), allow_none=True) underlying_black_scholes_volatilities = fields.List(fields.Float(allow_none=True)) + correlation_matrix = fields.List(fields.List(fields.Float())) @post_load def make_market_data(self, data, **kwargs) -> MarketData: diff --git a/exotx/instruments/basket_option.py b/exotx/instruments/basket_option.py index b83b023..099b51e 100644 --- a/exotx/instruments/basket_option.py +++ b/exotx/instruments/basket_option.py @@ -108,16 +108,16 @@ def _get_ql_pricing_engine(market_data, static_data, pricing_config: PricingConf day_counter = static_data.get_ql_day_counter() processes = [ql.BlackScholesMertonProcess(ql.QuoteHandle(ql.SimpleQuote(x)), - market_data.get_dividend_curve(), - market_data.get_yield_curve(), + market_data.get_dividend_curve(day_counter), + market_data.get_yield_curve(day_counter), ql.BlackVolTermStructureHandle( ql.BlackConstantVol(reference_date, calendar, y, day_counter))) for x, y in zip(market_data.underlying_spots, market_data.underlying_black_scholes_volatilities)] multi_processes = ql.StochasticProcessArray(processes, market_data.get_correlation_matrix()) # TODO: Consider different pricing engines based on self.basket_type and/or pricing_config - return ql.MCEuropeanBasketEngine(multi_processes, RandomNumberGenerator.PSEUDORANDOM, timeStepsPerYear=1, - requiredSamples=10000, seed=seed) + return ql.MCEuropeanBasketEngine(multi_processes, RandomNumberGenerator.PSEUDORANDOM.value, timeStepsPerYear=1, + requiredSamples=100000, seed=seed) # region Schema diff --git a/exotx/tests/instruments/test_basket_option.py b/exotx/tests/instruments/test_basket_option.py new file mode 100644 index 0000000..7bc0663 --- /dev/null +++ b/exotx/tests/instruments/test_basket_option.py @@ -0,0 +1,78 @@ +import pytest + +from exotx import price +from exotx.data.marketdata import MarketData +from exotx.data.staticdata import StaticData +from exotx.enums.enums import PricingModel, NumericalMethod +from exotx.instruments.basket_option import BasketOption +from exotx.instruments.basket_type import BasketType +from exotx.instruments.option_type import OptionType +from exotx.utils.pricing_configuration import PricingConfiguration + + +# Arrange +@pytest.fixture() +def my_static_data() -> StaticData: + my_json = { + 'day_counter': 'Actual360', + 'business_day_convention': 'ModifiedFollowing' + } + return StaticData.from_json(my_json) + + +@pytest.fixture() +def my_market_data() -> MarketData: + my_json = { + 'reference_date': '2015-11-06', + 'underlying_spots': [80, 90, 100], + 'risk_free_rate': 0.05, + 'dividend_rate': -0.03, + 'underlying_black_scholes_volatilities': [0.20, 0.25, 0.3], + 'correlation_matrix': [ + [1.0, 0.5, 0.6], + [0.5, 1.0, 0.7], + [0.6, 0.7, 1.0] + ] + } + + return MarketData.from_json(my_json) + + +@pytest.fixture +def my_basket_option() -> BasketOption: + strike = 85 + maturity = '2016-02-04' # reference_date + 90 days + exercise = 'european' + option_type = OptionType.PUT + # option_type = 'call' + basket_type = BasketType.MINBASKET + + return BasketOption(strike, maturity, option_type, basket_type) + + +@pytest.fixture +def my_pricing_config() -> PricingConfiguration: + model = PricingModel.BLACK_SCHOLES + numerical_method = NumericalMethod.ANALYTIC + + return PricingConfiguration(model, numerical_method) + + +@pytest.mark.parametrize( + 'expected_price', + [ + 5.7207 + ]) +def test_price(my_basket_option: BasketOption, + my_market_data: MarketData, + my_static_data: StaticData, + my_pricing_config: PricingConfiguration, + expected_price: float) -> None: + # Arrange + seed = 42 + + # Act + result = price(my_basket_option, my_market_data, my_static_data, my_pricing_config, seed) + + # Assert + assert result['price'] == pytest.approx(expected_price, abs=1e-4) From 32c5751064837d966d80d23e04d985b3b99edcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eveno?= Date: Mon, 10 Apr 2023 18:39:51 +0200 Subject: [PATCH 4/5] Bump version --- exotx/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exotx/_version.py b/exotx/_version.py index 43c4ab0..22049ab 100644 --- a/exotx/_version.py +++ b/exotx/_version.py @@ -1 +1 @@ -__version__ = "0.6.1" +__version__ = "0.6.2" From a28cb76b4c8f1c94ea9829b60dc20f7119873d7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eveno?= Date: Mon, 10 Apr 2023 18:49:25 +0200 Subject: [PATCH 5/5] Bump version to 0.6.3 --- exotx/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exotx/_version.py b/exotx/_version.py index 22049ab..63af887 100644 --- a/exotx/_version.py +++ b/exotx/_version.py @@ -1 +1 @@ -__version__ = "0.6.2" +__version__ = "0.6.3"