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_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 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 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)