2

I have a class that has two mutually exclusive arguments (prices and returns). That is, they must not be both provided in order to instantiate an object.

However, the class needs both for internal computations. So I want to compute the missing pd.Series from the one provided by the user.

I created two alternative class constructors (from_prices and from_returns). Using these constructors, the class will be correctly instantiated.

Here's the code. It makes use of the attrs library (www.attrs.org).

import pandas as pd

import attr


@attr.s
class MutuallyExclusive:
    prices: pd.Series = attr.ib()
    returns: pd.Series = attr.ib()
    trading_days_per_year: int = attr.ib(default=252)

    @classmethod
    def from_prices(cls, price_series: pd.Series, trading_days: int = 252):
        return cls(
            price_series,
            price_series.pct_change(),
            trading_days,
        )

    @classmethod
    def from_returns(cls, return_series: pd.Series):
        return cls(
            pd.Series(data=100 + 100 * (returns.add(1).cumprod() - 1)),
            return_series,
        )


if __name__ == "__main__":
    prices = pd.Series(data=[100, 101, 98, 104, 102, 108])
    returns = pd.Series(data=[0.01, 0.03, -0.02, 0.01, -0.03, 0.04])

    obj_returns = MutuallyExclusive.from_returns(returns)
    obj_prices = MutuallyExclusive.from_prices(prices, trading_days=100)

However, the user could still call obj = MutuallyExclusive(prices, returns), eventhough these two series are not compatible to each other. What's the best way to catch that situation and throw an error?

EDIT:
Would it be possible to "disable" the regular constructor alltogether? If it would be possible to instantiate the object via alternative constructors only, this would solve the problem, wouldn't it?

Andi
  • 3,196
  • 2
  • 24
  • 44

3 Answers3

1

Is the attrs library the correct tool for this ? Why not use a regular python class and define the __init__() yourself ?

import pandas as pd


class MutuallyExclusive:
    def __init__(self, prices: pd.Series = None, returns: pd.Series = None):
       if prices is not None and returns is not None:
          raise ValueError("prices and returns are mutually exclusive")
       self.prices = prices if prices is not None else pd.Series(data=100 * (1 + returns))
       self.returns = returns if returns is not None else prices.pct_change()

if __name__ == "__main__":
    prices = pd.Series(data=[100, 101, 98, 104, 102, 108])
    returns = pd.Series(data=[0.01, 0.03, -0.02, 0.01, -0.03, 0.04])

    obj_returns = MutuallyExclusive(returns=returns)
    obj_prices = MutuallyExclusive(prices=prices)

Edit: you updated your example, so my answer is missing the trading_days_per_year but the concept is the same.

If you want to use the attrs library, others have pointed out that you can put your logic in the __attrs_post_init__ function, see example below removing the need for the class methods Note you need to default both prices and returns to None

def __attrs_post_init__(self):
    if self.prices is not None and self.returns is not None:
         raise ValueError("prices and returns are mutually exclusive")
    if self.returns is None:
       self.returns = self.price_series.pct_change()
    if self.prices is None:
       self.prices = pd.Series(data=100 + 100 * (self.returns.add(1).cumprod() - 1))
lafferc
  • 2,741
  • 3
  • 24
  • 37
  • When using the regular Python class, your answer is quite clear. What I like about ``attrs`` is the fact I can set ``attr.ib(init=False)``. In my real world case, I do have a lot of class attributes that I usually would initialize with ``None``. However, I am always struggling a bit with that. Compare https://stackoverflow.com/questions/55800218/setting-default-empty-attributes-for-user-classes-in-init. – Andi Sep 29 '21 at 12:50
0

You check that in __attrs_post_init__: https://www.attrs.org/en/stable/init.html#post-init

hynek
  • 3,647
  • 1
  • 18
  • 26
  • 2
    Can you please elaborate a bit? Do you mean to check for compatibility between ``prices`` and ``returns``? – Andi Sep 29 '21 at 11:32
  • 1
    As someone else wrote above (I was answering from a phone): you could use None for default values and then verify the invariance of exactly one of them being None and one not. – hynek Sep 29 '21 at 18:45
0

I don't know if there is a more idiomatic pattern, but you could just guard the constructor with a boolean lock, checking the lock in __attrs_post_init__ to prevent the constructor from being called directly:

import pandas as pd

import attr

@attr.s
class MutuallyExclusive:
    prices: pd.Series = attr.ib()
    returns: pd.Series = attr.ib()

    def __attrs_post_init__(self):
        if not MutuallyExclusive.constructor_unlocked:
            raise TypeError('Please use the `from_prices` or `from_returns` constructor methods')

    @classmethod
    def from_prices(cls, price_series: pd.Series):
        cls.constructor_unlocked = True
        value = cls(price_series, price_series.pct_change())
        cls.constructor_unlocked = False
        return value

    @classmethod
    def from_returns(cls, return_series: pd.Series):
        cls.constructor_unlocked = True
        value = cls(pd.Series(data=100 * (1 + return_series)), return_series)
        cls.constructor_unlocked = False
        return value


MutuallyExclusive.constructor_unlocked = False

if __name__ == "__main__":
    prices = pd.Series(data=[100, 101, 98, 104, 102, 108])
    returns = pd.Series(data=[0.01, 0.03, -0.02, 0.01, -0.03, 0.04])

    obj_returns = MutuallyExclusive.from_returns(returns)
    obj_prices = MutuallyExclusive.from_prices(prices)

    bad = MutuallyExclusive(obj_prices.prices, obj_prices.returns)

Or if you prefer, a threading.Lock:

import pandas as pd

import attr
from threading import Lock

mutually_exclusive_constructor_lock = Lock()

@attr.s
class MutuallyExclusive:
    prices: pd.Series = attr.ib()
    returns: pd.Series = attr.ib()

    def __attrs_post_init__(self):
        if not mutually_exclusive_constructor_lock.locked():
            raise TypeError('Please use the `from_prices` or `from_returns` constructor methods')

    @classmethod
    def from_prices(cls, price_series: pd.Series):
        with mutually_exclusive_constructor_lock:
            return cls(price_series, price_series.pct_change())

    @classmethod
    def from_returns(cls, return_series: pd.Series):
        with mutually_exclusive_constructor_lock:
            return cls(pd.Series(data=100 * (1 + return_series)), return_series)

if __name__ == "__main__":
    prices = pd.Series(data=[100, 101, 98, 104, 102, 108])
    returns = pd.Series(data=[0.01, 0.03, -0.02, 0.01, -0.03, 0.04])

    obj_returns = MutuallyExclusive.from_returns(returns)
    obj_prices = MutuallyExclusive.from_prices(prices)

    bad = MutuallyExclusive(obj_prices.prices, obj_prices.returns)
Oli
  • 2,507
  • 1
  • 11
  • 23