tubedude/xirr

View on GitHub
lib/xirr/cashflow.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

module Xirr
  # Expands [Array] to store a set of transactions which will be used to calculate the XIRR
  # @note A Cashflow should consist of at least two transactions, one positive and one negative.
  class Cashflow < Array
    attr_reader :raise_exception, :fallback, :iteration_limit, :options

    # @param args [Transaction]
    # @example Creating a Cashflow
    #   cf = Cashflow.new
    #   cf << Transaction.new( 1000, date: '2013-01-01'.to_date)
    #   cf << Transaction.new(-1234, date: '2013-03-31'.to_date)
    #   Or
    #   cf = Cashflow.new Transaction.new( 1000, date: '2013-01-01'.to_date), Transaction.new(-1234, date: '2013-03-31'.to_date)
    def initialize(flow: [], period: Xirr.config.period, ** options)
      @period   = period
      @fallback = options[:fallback] || Xirr.config.fallback
      @options  = options
      self << flow
      flatten!
    end

    # Check if Cashflow is invalid
    # @return [Boolean]
    def invalid?
      inflow.empty? || outflows.empty?
    end

    # Inverse of #invalid?
    # @return [Boolean]
    def valid?
      !invalid?
    end

    # @return [Float]
    # Sums all amounts in a cashflow
    def sum
      map(&:amount).sum
    end

    # Last investment date
    # @return [Time]
    def max_date
      @max_date ||= map(&:date).max
    end

    # Calculates a simple IRR guess based on period of investment and multiples.
    # @return [Float]
    def irr_guess
      return @irr_guess = 0.0 if periods_of_investment.zero?
      @irr_guess = valid? ? ((multiple**(1 / periods_of_investment)) - 1).round(3) : 0.0
      @irr_guess == 1.0 / 0 ? 0.0 : @irr_guess
    end

    # @param guess [Float]
    # @param method [Symbol]
    # @return [Float]
    def xirr(guess: nil, method: nil, ** options)
      method, options = process_options(method, options)
      if invalid?
        raise ArgumentError, invalid_message if options[:raise_exception]
        BigDecimal(0, Xirr.config.precision)
      else
        xirr = choose_(method).send :xirr, guess, options
        xirr = choose_(other_calculation_method(method)).send(:xirr, guess, options) if (xirr.nil? || xirr.nan?) && fallback
        xirr || Xirr.config.replace_for_nil
      end
    end

    def process_options(method, options)
      @temporary_period         = options[:period]
      options[:raise_exception] ||= @options[:raise_exception] || Xirr.config.raise_exception
      options[:iteration_limit] ||= @options[:iteration_limit] || Xirr.config.iteration_limit
      return switch_fallback(method), options
    end

    # If method is defined it will turn off fallback
    # it return either the provided method or the system default
    # @param method [Symbol]
    # @return [Symbol]
    def switch_fallback(method)
      if method
        @fallback = false
        method
      else
        @fallback = Xirr.config.fallback
        Xirr.config.default_method
      end
    end

    def other_calculation_method(method)
      method == :newton_method ? :bisection : :newton_method
    end

    def compact_cf
      # self
      compact = Hash.new 0
      each { |flow| compact[flow.date] += flow.amount }
      Cashflow.new flow: compact.map { |key, value| Transaction.new(value, date: key) }, options: options, period: period
    end

    # First investment date
    # @return [Time]
    def min_date
      @min_date ||= map(&:date).min
    end

    # @return [String]
    # Error message depending on the missing transaction
    def invalid_message
      return 'No positive transaction' if inflow.empty?
      return 'No negative transaction' if outflows.empty?
    end

    def period
      @temporary_period || @period
    end

    def <<(arg)
      super arg
      sort! { |x, y| x.date <=> y.date }
      self
    end

    private

    # @param method [Symbol]
    # Choose a Method to call.
    # @return [Class]
    def choose_(method)
      case method
      when :bisection
        Bisection.new compact_cf
      when :newton_method
        NewtonMethod.new compact_cf
      else
        raise ArgumentError, "There is no method called #{method} "
      end
    end

    # @api private
    # Sorts the {Cashflow} by date ascending
    #   and finds the signal of the first transaction.
    # This implies the first transaction is a disbursement
    # @return [Integer]
    def first_transaction_direction
      # self.sort! { |x, y| x.date <=> y.date }
      @first_transaction_direction ||= first.amount / first.amount.abs
    end

    # Based on the direction of the first investment finds the multiple cash-on-cash
    # @example
    #   [100,100,-300] and [-100,-100,300] returns 1.5
    # @api private
    # @return [Float]
    def multiple
      inflow.sum(&:amount).abs / outflows.sum(&:amount).abs
    end

    def first_transaction_positive?
      first_transaction_direction > 0
    end

    # @api private
    # Counts how many years from first to last transaction in the cashflow
    # @return
    def periods_of_investment
      (max_date - min_date) / period
    end

    # @api private
    # @return [Array]
    # @see #outflows
    # Selects all positives transactions from Cashflow
    def inflow
      select { |x| x.amount * first_transaction_direction < 0 }
    end

    # @api private
    # @return [Array]
    # @see #inflow
    # Selects all negatives transactions from Cashflow
    def outflows
      select { |x| x.amount * first_transaction_direction > 0 }
    end
  end
end