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 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 test_init_when_freq(self): """ Init `Timing` with a single parameter, `when`. """ timing = Timing(0.5) self.assertEqual(set(timing.keys()), {0.5})