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