lib/fat_period/period.rb in fat_period-1.5.0 vs lib/fat_period/period.rb in fat_period-2.0.0

- old
+ new

@@ -1,8 +1,7 @@ +require 'active_support' require 'fat_core/date' -require 'fat_core/range' -require 'fat_core/string' # The Period class represents a range of Dates and supports a variety of # operations on those ranges. class Period # Return the first Date of the Period @@ -25,22 +24,21 @@ # @param last [Date, String] last date of Period # @raise [ArgumentError] if string is not parseable as a Date or # @raise [ArgumentError] if first date is later than last date # @return [Period] def initialize(first, last) - @first = Date.ensure(first).freeze - @last = Date.ensure(last).freeze + @first = Date.ensure_date(first).freeze + @last = Date.ensure_date(last).freeze freeze - raise ArgumentError, "Period's first date is later than its last date" if @first > @last + return unless @first > @last + + raise ArgumentError, "Period's first date is later than its last date" end # These need to come after initialize is defined - # Period from commercial beginning of time to today - TO_DATE = Period.new(Date::BOT, Date.current) - # Period from commercial beginning of time to commercial end of time. FOREVER = Period.new(Date::BOT, Date::EOT) # @group Parsing # @@ -66,64 +64,39 @@ first = Date.parse_spec(from, :from) second = Date.parse_spec(to, :to) Period.new(first, second) if first && second end - PHRASE_RE = %r{\A - # One or both of from and to parts - (((from)?\s*(?<from_part>[-_a-z0-9]+)\s*)? - (to\s+(?<to_part>[-_a-z0-9]+))?) - # Wholly optional chunk part - (\s+per\s+(?<chunk_part>\w+))?\z}xi - - private_constant :PHRASE_RE - - # Return an array of periods, either a single period as in `Period.parse` - # from a String phrase in which a `from spec` is introduced with 'from' and, - # optionally, a `to spec` is introduced with 'to', or a number of periods if - # there is a 'per <chunk>' modifier. A phrase with only a `to spec` is - # treated the same as one with only a from spec. If neither 'from' nor 'to' - # appear in phrase, treat the whole string as a from spec. + # Return a period as in `Period.parse` from a String phrase in which the from + # spec is introduced with 'from' and, optionally, the to spec is introduced + # with 'to'. A phrase with only a to spec is treated the same as one with + # only a from spec. If neither 'from' nor 'to' appear in phrase, treat the + # whole string as a from spec. # # @example - # Period.parse_phrase('from 2014-11 to 2015-3Q') #=> [Period('2014-11-01..2015-09-30')] - # Period.parse_phrase('from 2014-11') #=> [Period('2014-11-01..2014-11-30')] - # Period.parse_phrase('from 2015-3Q') #=> [Period('2015-09-01..2015-12-31')] - # Period.parse_phrase('to 2015-3Q') #=> [Period('2015-09-01..2015-12-31')] - # Period.parse_phrase('from 2015-3Q') #=> [Period('2015-09-01..2015-12-31')] - # Period.parse_phrase('from 2015 per month') #=> [ - # Period('2015-01-01..2015-01-31'), - # Period('2015-02-01..2015-02-28'), - # ... - # Period('2015-12-01..2015-12-31') - # ] + # Period.parse_phrase('from 2014-11 to 2015-3Q') #=> Period('2014-11-01..2015-09-30') + # Period.parse_phrase('from 2014-11') #=> Period('2014-11-01..2014-11-30') + # Period.parse_phrase('from 2015-3Q') #=> Period('2015-09-01..2015-12-31') + # Period.parse_phrase('to 2015-3Q') #=> Period('2015-09-01..2015-12-31') + # Period.parse_phrase('2015-3Q') #=> Period('2015-09-01..2015-12-31') # - # @param phrase [String] with 'from <spec> to <spec> [per <chunk>]' - # @return [Array<Period>] translated from phrase - def self.parse_phrase(phrase, partial_first: false, partial_last: false, round_up_last: false) - phrase = phrase.downcase.clean - mm = phrase.match(PHRASE_RE) - raise ArgumentError, "invalid period phrase: `#{phrase}`" unless mm - - if mm[:from_part] && mm[:to_part].nil? - from_part = mm[:from_part] - to_part = nil - elsif mm[:from_part].nil? && mm[:to_part] - from_part = mm[:to_part] - to_part = nil + # @param phrase [String] with 'from <spec> to <spec>' + # @return [Period] translated from phrase + def self.parse_phrase(phrase) + phrase = phrase.clean + case phrase + when /\Afrom (.*) to (.*)\z/ + from_phrase = $1 + to_phrase = $2 + when /\Afrom (.*)\z/, /\Ato (.*)\z/ + from_phrase = $1 + to_phrase = nil else - from_part = mm[:from_part] - to_part = mm[:to_part] + from_phrase = phrase + to_phrase = nil end - - whole_period = parse(from_part, to_part) - if mm[:chunk_part].nil? - [whole_period] - else - whole_period.chunks(size: mm[:chunk_part], partial_first: partial_first, - partial_last: partial_last, round_up_last: round_up_last) - end + parse(from_phrase, to_phrase) end # @group Conversion # Convert this Period to a Range. @@ -164,35 +137,10 @@ else "#{first.iso} to #{last.iso}" end end - # Convert a string or Period into a Period object, if the string is parsable - # as a period. - # - # @example - # Period.ensure('2023-1q') #=> Period 2023-01-01..2023-03-31 - # pd = Period.new('2016-01-01', '2016-12-31') - # Period.ensure(pd) #=> pd # No effect - # - # @return [Period] either parsed String or same Period - def self.ensure(pd) - case pd - when Period - pd - when String - if pd.match?(/\s*from/i) - # Ignore any chunk modifier, 'per' - Period.parse_phrase(pd).first - else - Period.parse(pd) - end - else - raise UserError, "can't ensure `#{pd}` of #{pd.class} is a Period" - end - end - # A concise way to print out Periods for inspection as # 'Period(YYYY-MM-DD..YYYY-MM-DD)'. # # @return [String] def inspect @@ -214,11 +162,11 @@ # largest. # # @param other [Period] @return [Integer] -1 if self < other; 0 if self == # other; 1 if self > other def <=>(other) - return nil unless other.is_a?(Period) + return unless other.is_a?(Period) [first, last] <=> [other.first, other.last] end # Comparable does not include this. @@ -233,11 +181,11 @@ def hash (first.hash | last.hash) end def eql?(other) - return nil unless other.is_a?(Period) + return unless other.is_a?(Period) hash == other.hash end # Return whether this Period contains the given date. @@ -248,27 +196,22 @@ date = date.to_date if date.respond_to?(:to_date) raise ArgumentError, 'argument must be a Date' unless date.is_a?(Date) to_range.cover?(date) end - alias === contains? + alias_method :===, :contains? include Enumerable # @group Enumeration # Yield each day in this Period. def each - if block_given? - d = first - while d <= last - yield d - d += 1.day - end - self - else - to_enum(:each) + d = first + while d <= last + yield d + d += 1.day end end # Return an Array of the days in the Period that are trading days on the NYSE. # See FatCore::Date for how trading days are determined. @@ -282,12 +225,12 @@ # Return the number of days in the period def size (last - first + 1).to_i end - alias length size - alias days size + alias_method :length, :size + alias_method :days, :size # Return the fractional number of months in the period. By default, use the # average number of days in a month, but allow the user to override the # assumption with a parameter. def months(days_in_month = 30.436875) @@ -329,28 +272,42 @@ # @group Chunking # # An Array of the valid Symbols for calendar chunks plus the Symbol :irregular # for other periods. - CHUNKS = %i[day week biweek semimonth month bimonth quarter - half year irregular].freeze + CHUNKS = %i[ + day + week + biweek + semimonth + month + bimonth + quarter + half + year + irregular + ].freeze CHUNK_ORDER = {} CHUNKS.each_with_index do |c, i| CHUNK_ORDER[c] = i end CHUNK_ORDER.freeze # An Array of Ranges for the number of days that can be covered by each chunk. CHUNK_RANGE = { - day: (1..1), week: (7..7), biweek: (14..14), semimonth: (15..16), - month: (28..31), bimonth: (59..62), quarter: (90..92), - half: (180..183), year: (365..366) + day: (1..1), + week: (7..7), + biweek: (14..14), + semimonth: (15..16), + month: (28..31), + bimonth: (59..62), + quarter: (90..92), + half: (180..183), + year: (365..366) }.freeze - private_constant :CHUNK_ORDER, :CHUNK_RANGE - def self.chunk_cmp(chunk1, chunk2) CHUNK_ORDER[chunk1] <=> CHUNK_ORDER[chunk2] end # Return a period representing a chunk containing a given Date. @@ -394,11 +351,11 @@ raise ArgumentError, 'chunk is nil' unless chunk chunk = chunk.to_sym raise ArgumentError, "unknown chunk name: #{chunk}" unless CHUNKS.include?(chunk) - date = Date.ensure(date) + date = Date.ensure_date(date) method = "#{chunk}_containing".to_sym send(method, date) end # Return a Period representing a chunk containing today. @@ -634,12 +591,12 @@ # @param partial_last [Boolean] allow a period less than a full :size period # as the last period in the returned array. # @param round_up_last [Boolean] allow the last period in the returned array # to extend beyond the end of self. # @return [Array<Period>] periods that subdivide self into chunks of size, `size` - def chunks(size: :month, partial_first: false, partial_last: false, - round_up_last: false) + def chunks(size: :month, partial_first: true, partial_last: true, + round_up_last: false) chunk_size = size.to_sym raise ArgumentError, "unknown chunk size '#{chunk_size}'" unless CHUNKS.include?(chunk_size) containing_period = Period.chunk_containing(first, chunk_size) return [dup] if self == containing_period @@ -659,29 +616,26 @@ [] end return result end - # The first chunk chunk_start = first.dup chunk_end = chunk_start.end_of_chunk(chunk_size) if chunk_start.beginning_of_chunk?(chunk_size) || partial_first # Keep the first chunk if it's whole or partials allowed result << Period.new(chunk_start, chunk_end) end chunk_start = chunk_end + 1.day chunk_end = chunk_start.end_of_chunk(chunk_size) - # Add Whole chunks while chunk_end <= last result << Period.new(chunk_start, chunk_end) chunk_start = chunk_end + 1.day chunk_end = chunk_start.end_of_chunk(chunk_size) end - # Possibly append the final chunk to result - if chunk_start <= last + if chunk_start < last if round_up_last result << Period.new(chunk_start, chunk_end) elsif partial_last result << Period.new(chunk_start, last) else @@ -770,12 +724,12 @@ nil else Period.new(result.first, result.last) end end - alias & intersection - alias narrow_to intersection + alias_method :&, :intersection + alias_method :narrow_to, :intersection # Return the Period that is the union of self with `other` or nil if # they neither overlap nor are contiguous # # @example @@ -788,15 +742,15 @@ # # @param other [Period] other Period # @return [Period, nil] self union `other`? def union(other) result = to_range.union(other.to_range) - return nil if result.nil? + return if result.nil? Period.new(result.first, result.last) end - alias + union + alias_method :+, :union # Return an array of periods that are this period excluding any overlap with # other. If there is no overlap, return an array with a period equal to self # as the sole member. # @@ -810,10 +764,10 @@ # @return [Array<Period>] self less the part of other that overlaps def difference(other) ranges = to_range.difference(other.to_range) ranges.each.map { |r| Period.new(r.first, r.last) } end - alias - difference + alias_method :-, :difference # Return whether this period overlaps the `other` period. To overlap, the # periods must have at least one day in common. # # @example