def test_time_series_basic(self): """ Tests `time_series` with simple input. """ # start and end are equally weighted: timing = Timing({0: 1, 1: 1}) result = timing.time_series(2) # Spreading a value of 2 equally across start and end means # allocating 1 at start and 1 at end: self.assertEqual(result, {0: 1, 1: 1})
def test_normalized_subset(self): """ Tests `normalized` for a subset of keys. """ # start and end are equally weighted: timing = Timing({0: 1, 1: 1}) result = timing.normalized(keys={1}) # Normalizing just end should yield a value of 1 at that time: target = Timing({1: 1}) self.assertEqual(result, target)
def test_time_series_subset(self): """ Tests `time_series` for a subset of keys. """ # start and end are equally weighted: timing = Timing({0: 1, 1: 1}) result = timing.time_series(2, keys={1}) # Spreading a value of 2 equally across _just end_ means # allocating 2 at end: self.assertEqual(result, {1: 2})
def test_normalized_basic(self): """ Tests `normalized` with simple input. """ # start and end are equally weighted: timing = Timing({0: 1, 1: 1}) result = timing.normalized() # start and end are equally weighted, so each should get 0.5 # when normalized: target = Timing({0: 0.5, 1: 0.5}) self.assertEqual(result, target)
def __init__(self, initial_year, name, birth_date, retirement_date=None, gross_income=None, raise_rate=None, spouse=None, tax_treatment=None, payment_timing=None, inputs=None, **kwargs): """ Initializes a `Person` object. """ super().__init__(initial_year=initial_year, inputs=inputs, **kwargs) # For numerical optional inputs, ensure an appropriately-typed # default value is used: if gross_income is None: gross_income = self.precision_convert(0) if raise_rate is None: raise_rate = self.precision_convert(0) # For simple, non-property-wrapped attributes, assign directly: self.name = name if payment_timing is None: # Timing is technically mutable, so init it here rather than # using "Timing()" as a default value. self.payment_timing = Timing(high_precision=self.high_precision) else: self.payment_timing = Timing(payment_timing, high_precision=self.high_precision) # For attributes wrapped by ordinary properties, create hidden # attributes and assign to them using the properties: self._birth_date = None self._retirement_date = None self._raise_rate_callable = None self._spouse = None self._tax_treatment = None self._contribution_room = {} self._contribution_groups = {} self.birth_date = birth_date self.retirement_date = retirement_date self.raise_rate_callable = raise_rate self.spouse = spouse self.tax_treatment = tax_treatment # Now provide initial-year values for recorded properties: self.gross_income = gross_income # Money value # NOTE: Be sure to set up tax_treatment before calling tax_withheld self.net_income = self.gross_income - self.tax_withheld # Finally, build an empty set for accounts to add themselves to # and a `data` dict for accounts to write unstructed data to. self.accounts = set() self.data = {}
def test_init_dict_money(self): """ Init `Timing` with a dict of `Money` values. """ # It doesn't matter what this dict is; if we wrap its values as # `Money` objects, it should parse to the same result: timing_dict = {0: 0, 0.5: -2, 1: 1} money_dict = {key: Money(value) for key, value in timing_dict.items()} # Build timings based on the (otherwise-identical) Money and # non-Money inputs and confirm that the results are the same: timing1 = Timing(timing_dict) timing2 = Timing(money_dict) self.assertEqual(timing1, timing2)
def test_init_dict_mixed_neg_2(self): """ Init `Timing` with a net-negative dict of mixed values. This tests basically the same behaviour as `test_init_dict_mixed_neg`, but was written much earlier (as `test_init_dict_accum`) and helped to track down some erroneous behaviour. It's retained for historical reasons. """ available = {0: 1000, 0.25: -11000, 0.5: 1000, 0.75: -11000} timing = Timing(available) target = Timing({0.25: 10000, 0.75: 10000}) self.assertEqual(timing, target)
def test_init_dict_mixed_zero(self): """ Init `Timing` with a net-zero dict of mixed values. "Mixed" in this context means it has both positive and negative values. The sum of those values is zero in this test. """ # Large outflow followed by equally large inflow: timing_dict = {0: 0, 0.5: -2, 1: 2} timing = Timing(timing_dict) # This should result in either an empty time-series or one with # all-zero values. self.assertTrue(all(value == 0 for value in timing.values()))
def test_init_dict_mixed_pos(self): """ Init `Timing` with a net-positive dict of mixed values. "Mixed" in this context means it has both positive and negative values. The sum of those values is positive in this test. """ # Large inflow followed by small outflow (for small net inflow): timing_dict = {0: 0, 0.5: 2, 1: -1} timing = Timing(timing_dict) # There should be only one key with non-zero weight: self.assertNotEqual(timing[0.5], 0) # All other keys must have zero weight, if they exist: self.assertTrue( all(value == 0 for key, value in timing.items() if key != 0.5))
def test_init_dict_pos(self): """ Init `Timing` with a dict of all-positive values. """ # Technically we use non-negative values: timing_dict = {0: 0, 0.5: 1, 1: 1} # Should convert directly to `Timing` (the 0 value is optional): timing = Timing(timing_dict) total_weight = sum(timing.values()) for key, value in timing_dict.items(): if value != 0: # Each non-0 value should be present... self.assertIn(key, timing) # ... and both non-0 values should be equally weighted. self.assertEqual(timing[key], total_weight / 2) elif key in timing: # The zero value, if it exists, should remain 0: self.assertEqual(timing[key], 0)
def __init__(self, initial_year, default_timing=None, *, high_precision=None): """ Initializes an instance of SubForecast. """ # Invoke Ledger's __init__ or pay the price! # NOTE Issue #53 removes this requirement super().__init__(initial_year, high_precision=high_precision) # Use default Timing (i.e. lump sum contributions at the # midpoint of the year) if none is explicitly provided: if default_timing is None: self.default_timing = Timing(high_precision=high_precision) else: self.default_timing = default_timing # We store transactions to/from each account so that we can # unwind or inspect transactions caused by this subforecast # later. So we store it as `{account: {when: value}}`. # Since `account` can be a dict (which is non-hashable), # we use a custom subclass of defaultdict that allows # non-hashable keys. self._transactions = TransactionDict( lambda: defaultdict(lambda: 0)) # Money value # If the subforecast is called more than once, we # may want to do some unwinding. Use this to track # whether this subforecast has been called before: self._call_invoked = False self.total_available = 0 # Money value
def __init__(self, owner=None, balance=0, rate=0, nper=1, default_timing=None, inputs=None, initial_year=None): """ Constructor for `Account`. This constructor receives only values for the first year. Args: owner (Person): The owner of the account. Optional. balance (Money): The balance for the first year rate (Decimal, callable): An object that gives the rate for each year, either as a constant value (e.g. a Decimal) or as a callable object with a signature of the form `rate(year) -> Decimal`. If this callable object relies on `Scenario` or other objects defined in the `forecaster` package, recommend passing an object that stores these objects explicitly as attributes (as opposed to a method/function where these objects are stored in the context), otherwise `Forecaster`'s object-substitution logic will not work. nper (int): The number of compounding periods per year. default_timing (Timing): The usual schedule for transactions to/from this account, used by various methods when no `timing` arg is expressly provided. Optional. initial_year (int): The first year (e.g. 2000) """ # Use the explicitly-provided initial year if available, # otherwise default to the owner's initial year: if initial_year is None: if not hasattr(owner, 'initial_year'): raise TypeError( 'Account: owner must have initial_year attribute.') else: initial_year = owner.initial_year super().__init__(initial_year=initial_year, inputs=inputs) # Set hidden attributes to support properties that need them to # be set in advance: self._owner = None self._transactions = defaultdict(lambda: Money(0)) self._rate_callable = None self._default_timing = None # Set the various property values based on inputs: self.owner = owner self.balance = Money(balance) self.rate_callable = rate self.nper = frequency_conv(nper) if default_timing is None: self.default_timing = Timing() else: self.default_timing = default_timing
def test_init_dict_neg(self): """ Init `Timing` with a dict of all-negative values. """ # This returns the same result as `test_init_dict_pos`; # the inputs have their sign flipped, but output is the same. # Technically we use non-positive values: timing_dict = {0: 0, 0.5: -1, 1: -1} # Should convert directly to `Timing`, except that all values # have their sign flipped (the 0 value is optional): timing = Timing(timing_dict) # Ensure that `total_weight` is positive; the later assertEqual # tests will thus also check that all values are non-negative: total_weight = abs(sum(timing.values())) for key, value in timing_dict.items(): if value != 0: # Each non-0 value should be present... self.assertIn(key, timing) # ... and both non-0 values should be equally weighted. self.assertEqual(timing[key], total_weight / 2) elif key in timing: # The zero value, if it exists, should remain 0: self.assertEqual(timing[key], 0)
def test_init_dict_mixed_neg(self): """ Init `Timing` with a net-negative dict of mixed values. "Mixed" in this context means it has both positive and negative values. The sum of those values is negative in this test. """ # Large outflow followed by small inflow and then another large # outflow: timing_dict = {0: 0, 0.5: -2, 0.75: 1, 1: -2} timing = Timing(timing_dict) # This should result in a time-series of {0.5: 2, 1: 1} to # balance the net flows at each outflow. # NOTE: The behaviour is different than is provided for inflows! self.assertNotEqual(timing[0.5], 0) self.assertNotEqual(timing[1], 0) # Key 0.5 should have 2x the weight of key 1: self.assertEqual(timing[0.5], timing[1] * 2) # All other keys must have zero weight, if they exist: self.assertTrue( all( value == 0 for key, value in timing.items() if key not in (0.5, 1)))
def _carryover_tax(self, tax_adjustment_previous): """ Add tax refunds/payments arising from last year's taxes. """ # Nothing to carry over is there's no known tax adjustment: if tax_adjustment_previous is None: return # Determine the time series of transactions as appropriate: if tax_adjustment_previous > 0: # refund if hasattr(self.tax_forecast, "tax_refund_timing"): timing = self.tax_forecast.tax_refund_timing else: timing = Timing(0, high_precision=self.high_precision) elif tax_adjustment_previous < 0: # payment owing if hasattr(self.tax_forecast, "tax_payment_timing"): timing = self.tax_forecast.tax_payment_timing else: timing = Timing(0, high_precision=self.high_precision) else: # no adjustment return # No need to proceed on to add_transactions # Add the time-series of transactions to `available`: transactions = timing.time_series(tax_adjustment_previous) add_transactions(self.available, transactions)
def transactions_to_balance(self, balance, timing=None, max_total=None, min_total=None, transactions=None): """ The amounts to add/withdraw at `timing` to get `balance`. The return value satisfies two criteria: * If each `{when: value}` pair is added as a transaction then `self.balance_at_time('end')` will return `balance`, subject to precision-based error. * Each `value` is proportionate to the corresponding input `weight` for the given timing. Note that this method does not guarantee that the Account will not go into negative balance mid-year if the output is used to apply transactions to the Account. This method is transaction-aware in the sense that the resulting transactions are additional to any transactions already added to the account. Arguments: timing (Timing): A mapping of `{when: weight}` pairs. balance (Money): The balance of the Account would have after applying the outflows. max_total (Money): If provided, the resulting transactions will not exceed this total value (even if this value is not sufficient to achieve `balance`) min_total (Money): If provided, the resulting transactions will be at least this total value (even if this value is larger than necessary to achieve `balance`) transactions (dict[Decimal, Money]): If provided, the result of this method will be determined as if the account also had these transactions recorded against it. Returns: dict[float, Money]: A mapping of `{when: value}` pairs where value indicates the amount that can be withdrawn at that time such that, by the end of the year, the Account's balance is `balance`. """ # Determine how much the end-of-year balance would change under # these transactions. This accounts for any transactions already # applied to the account (via balance_at_time): ref_balance = self.balance_at_time('end', transactions=transactions) change = balance - ref_balance # Clean inputs: if timing is None or not timing: # Use default timing if none was explicitly provided. # NOTE: Consider whether we should instead use whatever # timing is naturally suggested by the timings of the # current account transactions. # For example, if an account receives $50 at when=0.5 and a # $25 withdrawal at when=1, then the maximum outflow would # occur at when=0.5 (and would have a value of $25, assuming # no growth). # The benefit here is that we could provide a schedule of # transactions which *does* guarantee that the account will # not go into negative balance at any time! # A side-effect is that we might move `default_timing` logic # back to the classes that need it (i.e. Debt, Person). timing = self.default_timing elif not isinstance(timing, Timing): # Convert timing from str or dict if appropriate: timing = Timing(timing) # Weights aren't guaranteed to sum to 1, so normalize them so # they do. This will help later. # (Also convert to Decimal to avoid Decimal/float mismatch.) total_weight = Decimal(sum(timing.values())) # If calling code has passed in a timing object with 0 net # weights (i.e. values) but non-empty timings (i.e. keys), # assume uniform weights at those timings: if total_weight == 0: timing = Timing({when: 1 for when in timing.keys()}) total_weight = len(timing) # Normalize so that we can conveniently multiply weights by # `total` to get that amount spread across all timings: # (Exclude zero-valued weights for two reasons: we don't need # them and they lead to errors when multipling by infinity) normalized_timing = { when: Decimal(weight) / total_weight for when, weight in timing.items() if weight != 0 } # We want the value at each timing to be proportional to its # weight. This calls for math. # Consider this derivation, where: # * A is the accumulation function using the current rate/nper # * Each {timing: value} pair in output is abbreviated t_i: v_i # * The sum total of all transaction values is denoted s # change = A(1-t_1)*v_1 + A(1-t_2)*v_2 + ... + A(1-t_n)*v_n # (This is just the sum of future values of the transactions) # v_j = sum(v_1 ... v_n) * w_j = s * w_j for all j in [1 .. n] # (This is the constraint that each v_j is proportional to its # weight w_j. Note that it assumes w_j is normalized!) # change = A(1-t_1)*s*w_1 + ... + A(1-t_n)*s*w_n # (Obtain this by simple substitution of v_j = s * w_j) # s = change / (A(1-t_1)*w_1 + ... + A(1-t_n)*w_n) # (We can use this to determine v_j) # Since s (the total) is the same for all values, find it first: weighted_accum = Decimal(0) for timing, weight in normalized_timing.items(): weighted_accum += weight * accumulation_function( t=1 - timing, rate=self.rate, nper=self.nper) total = change / weighted_accum # Limit total transaction value based on args: if max_total is not None: total = min(total, max_total) if min_total is not None: total = max(total, min_total) # Now find value (v_j) for each timing (t_j) by applying # normalized weights to the total: result_transactions = { timing: total * weight for timing, weight in normalized_timing.items() } return result_transactions
def default_timing(self): """ Deletes default_timing. """ # Return default_timing to its default value: self._default_timing = Timing()
def default_timing(self, val): """ Sets default_timing. """ # Cast to `Timing` type: if not isinstance(val, Timing): val = Timing(val) self._default_timing = val
def test_init_when_freq(self): """ Init `Timing` with a single parameter, `when`. """ timing = Timing(0.5) self.assertEqual(set(timing.keys()), {0.5})
def default_timing(self): """ Deletes default_timing. """ # Return default_timing to its default value: self._default_timing = Timing(high_precision=self.high_precision)
def __init__(self, owner=None, balance=None, rate=None, nper=None, default_timing=None, inputs=None, initial_year=None, *, high_precision=None, **kwargs): """ Constructor for `Account`. This constructor receives only values for the first year. Args: owner (Person): The owner of the account. Optional. balance (float): The balance for the first year. Optional, defaults to 0. rate (float, callable): An object that gives the rate for each year, either as a constant value (e.g. 1.0 implies a 100% interest rate) or as a callable object with a signature of the form `rate(year) -> Decimal`. If this callable object relies on `Scenario` or other objects defined in the `forecaster` package, recommend passing an object that stores these objects explicitly as attributes (as opposed to a method/function where these objects are stored in the context), otherwise `Forecaster`'s object-substitution logic will not work. Optional. Defaults to 0 (i.e. no growth/interest/etc.) nper (int): The number of compounding periods per year. Optional. Defaults to 1 (i.e. compounds once annually). default_timing (Timing): The usual schedule for transactions to/from this account, used by various methods when no `timing` arg is expressly provided. Optional. initial_year (int): The first year (e.g. 2000) high_precision (Callable[[float], T]): Takes a single `float` argument and converts it to high-precision numeric type `T`, such as Decimal. """ # Use the explicitly-provided initial year if available, # otherwise default to the owner's initial year: if initial_year is None: if not hasattr(owner, 'initial_year'): raise TypeError( 'Account: owner must have initial_year attribute.') else: initial_year = owner.initial_year # Defer Ledger's init until we can pass initial_year: super().__init__(initial_year=initial_year, inputs=inputs, high_precision=high_precision, **kwargs) # For numerical optional inputs, ensure an appropriately-typed # default value is used. (Do this after __init__!) if balance is None: balance = self.precision_convert(0) if rate is None: rate = self.precision_convert(0) if nper is None: nper = self.precision_convert(1) # Set hidden attributes to support properties that need them to # be set in advance: self._owner = None self._transactions = defaultdict( lambda: self.precision_convert(0)) # Money value self._rate_callable = None self._default_timing = None self._nper = None # Set the various property values based on inputs: self.owner = owner self.balance = balance # Money value self.rate_callable = rate self.nper = frequency_conv(nper) if default_timing is None: self.default_timing = Timing(high_precision=high_precision) else: self.default_timing = default_timing
def test_init_str_freq(self): """ Init `Timing` with a single str parameter, `frequency`. """ # There are 12 occurances with a monthly frequency: timing = Timing(frequency="M") self.assertEqual(len(timing), 12)
def refund_timing(self, val): """ Sets `refund_timing`. """ self._refund_timing = Timing(val)
def payment_timing(self, val): """ Sets `payment_timing`. """ self._payment_timing = Timing(val)
def test_init_str_when(self): """ Init `Timing` with a single str parameter, `when`. """ # 'start' should convert to 0: timing = Timing(when='start') self.assertEqual(set(timing.keys()), {0})
def add_transaction(self, value, timing=None, from_account=None, to_account=None, strict_timing=False): """ Records a transaction at a time that balances the books. This method will always add the transaction at or after `when` (or at or after the implied timing provided by `frequency`). It tries to find a time where adding the transaction would avoid putting `from_account` into a negative balance (not only at the time of the transaction but at any subsequent time). In particular, it tries to find the _earliest_ workable time at or after `when`. Thus, `when` is used if it meets these constraints. `when` is also used if no such time can be found. The transaction is actually two transactions: an outflow from `from_account` and an inflow to `to_account`. (This is reversed if `value` is negative.) If an account are omitted, the method treats the money as coming from (and/or going to) an infinite pool of money outside of the model. The `*_account` parameters are not necessarily `Account` objects. `dict[float, float]` (a dict of timings mapped to transaction values) or anything with similar semantics will also work. Args: value (float): The value of the transaction. Positive for inflows, negative for outflows. timing (Timing, dict[float, float], float, str): This is either a Timing object or a value that can be converted to a Timing object (e.g. a dict of {timing: weight} pairs, a `when` value as a float or string, etc.). Optional; defaults to `default_timing`. from_account (Account, dict[Decimal, float]): An account (or dict of transactions) from which the transaction originates. Optional. from_account (Account, dict[Decimal, float]): An account (or dict of transactions) to which the transaction is being sent. Optional. strict_timing (bool): If False, transactions may be added later than `when` if this avoids putting accounts in a negative balance. If True, `when` is always used. """ # NOTE: If `value` expects a Money value input (like PyMoney) # that may need to be converted from a scalar, do that here if timing is None: # Rather than build a new Timing object here, we'll use # the default timing for this SubForecast. timing = self.default_timing elif not isinstance(timing, Timing): # This allows users to pass `when` inputs and have them # parse correctly, since `Timing(when)` converts to # {when: 1}, i.e. a lump-sum occuring at `when`. timing = Timing(timing, high_precision=self.high_precision) # For convenience, ensure that we're withdrawing from # from_account and depositing to to_account: if value < 0: from_account, to_account = to_account, from_account value = -value # (Normalize weights just in case client code was naughty and # didn't do that for us...) total_weight = sum(timing.values()) # Add a transaction at each timing, with a transaction value # proportionate to the (normalized) weight for its timing: for when, weight in timing.items(): weighted_value = value * (weight / total_weight) self._add_transaction(value=weighted_value, when=when, from_account=from_account, to_account=to_account, strict_timing=strict_timing)