fat_core 2.0.1 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,533 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- class Period
4
- include Enumerable
5
- include Comparable
6
-
7
- attr_reader :first, :last
8
-
9
- def initialize(first, last)
10
- case first
11
- when String
12
- begin
13
- first = Date.parse(first)
14
- rescue ArgumentError => ex
15
- if ex.message =~ /invalid date/
16
- raise ArgumentError, "you gave an invalid date '#{first}'"
17
- else
18
- raise
19
- end
20
- end
21
- when Date
22
- first = first
23
- else
24
- raise ArgumentError, 'use Date or String to initialize Period'
25
- end
26
-
27
- case last
28
- when String
29
- begin
30
- last = Date.parse(last)
31
- rescue ArgumentError => ex
32
- if ex.message =~ /invalid date/
33
- raise ArgumentError, "you gave an invalid date '#{last}'"
34
- else
35
- raise
36
- end
37
- end
38
- when Date
39
- last = last
40
- else
41
- raise ArgumentError, 'use Date or String to initialize Period'
42
- end
43
-
44
- @first = first
45
- @last = last
46
- if @first > @last
47
- raise ArgumentError, "Period's first date is later than its last date"
48
- end
49
- end
50
-
51
- # These need to come after initialize is defined
52
- TO_DATE = Period.new(Date::BOT, Date.current)
53
- FOREVER = Period.new(Date::BOT, Date::EOT)
54
-
55
- # Need custom setters to ensure first <= last
56
- def first=(new_first)
57
- unless new_first.is_a?(Date)
58
- raise ArgumentError, "can't set Period#first to non-date"
59
- end
60
- unless new_first <= last
61
- raise ArgumentError, 'cannot make Period#first > Period#last'
62
- end
63
- @first = new_first
64
- end
65
-
66
- def last=(new_last)
67
- unless new_last.is_a?(Date)
68
- raise ArgumentError, 'cannot set Period#last to non-date'
69
- end
70
- unless new_last >= first
71
- raise ArgumentError, 'cannot make Period#last < Period#first'
72
- end
73
- @last = new_last
74
- end
75
-
76
- # Comparable base: periods are equal only if their first and last dates are
77
- # equal. Sorting will be by first date, then last, so periods starting on
78
- # the same date will sort by last date, thus, from smallest to largest in
79
- # size.
80
- def <=>(other)
81
- [first, size] <=> [other.first, other.size]
82
- end
83
-
84
- # Comparable does not include this.
85
- def !=(other)
86
- !(self == other)
87
- end
88
-
89
- # Enumerable base. Yield each day in the period.
90
- def each
91
- d = first
92
- while d <= last
93
- yield d
94
- d += 1.day
95
- end
96
- end
97
-
98
- # Case equality checks for inclusion of date in period.
99
- def ===(other)
100
- contains?(other)
101
- end
102
-
103
- # Return the number of days in the period
104
- def days
105
- last - first + 1
106
- end
107
-
108
- # Return the fractional number of months in the period. By default, use the
109
- # average number of days in a month, but allow the user to override the
110
- # assumption with a parameter.
111
- def months(days_in_month = 30.436875)
112
- (days / days_in_month).to_f
113
- end
114
-
115
- # Return the fractional number of years in the period. By default, use the
116
- # average number of days in a year, but allow the user to override the
117
- # assumption with a parameter.
118
- def years(days_in_year = 365.2425)
119
- (days / days_in_year).to_f
120
- end
121
-
122
- def trading_days
123
- select(&:nyse_workday?)
124
- end
125
-
126
- # Return a period based on two date specs passed as strings (see
127
- # Date.parse_spec), a '''from' and a 'to' spec. If the to-spec is not given
128
- # or is nil, the from-spec is used for both the from- and to-spec.
129
- #
130
- # Period.parse('2014-11') => Period.new('2014-11-01', 2014-11-30')
131
- # Period.parse('2014-11', '2015-3Q')
132
- # => Period.new('2014-11-01', 2015-09-30')
133
- def self.parse(from, to = nil)
134
- raise ArgumentError, 'Period.parse missing argument' unless from
135
- to ||= from
136
- first = Date.parse_spec(from, :from)
137
- second = Date.parse_spec(to, :to)
138
- Period.new(first, second) if first && second
139
- end
140
-
141
- # Return a period from a phrase in which the from date is introduced with
142
- # 'from' and, optionally, the to-date is introduced with 'to'.
143
- #
144
- # Period.parse_phrase('from 2014-11 to 2015-3Q')
145
- # => Period('2014-11-01', '2015-09-30')
146
- def self.parse_phrase(phrase)
147
- phrase = phrase.clean
148
- if phrase =~ /\Afrom (.*) to (.*)\z/
149
- from_phrase = $1
150
- to_phrase = $2
151
- elsif phrase =~ /\Afrom (.*)\z/
152
- from_phrase = $1
153
- to_phrase = nil
154
- elsif phrase =~ /\Ato (.*)\z/
155
- from_phrase = $1
156
- else
157
- from_phrase = phrase
158
- end
159
- parse(from_phrase, to_phrase)
160
- end
161
-
162
- # Possibly useful class method to take an array of periods and join all the
163
- # contiguous ones, then return an array of the disjoint periods not
164
- # contiguous to one another. An array of periods with no gaps should return
165
- # an array of only one period spanning all the given periods.
166
-
167
- # Return an array of periods that represent the concatenation of all
168
- # adjacent periods in the given periods.
169
- # def self.meld_periods(*periods)
170
- # melded_periods = []
171
- # while (this_period = periods.pop)
172
- # melded_periods.each do |mp|
173
- # if mp.overlaps?(this_period)
174
- # melded_periods.delete(mp)
175
- # melded_periods << mp.union(this_period)
176
- # break
177
- # elsif mp.contiguous?(this_period)
178
- # melded_periods.delete(mp)
179
- # melded_periods << mp.join(this_period)
180
- # break
181
- # end
182
- # end
183
- # end
184
- # melded_periods
185
- # end
186
-
187
- def self.chunk_syms
188
- [:day, :week, :biweek, :semimonth, :month, :bimonth,
189
- :quarter, :half, :year, :irregular]
190
- end
191
-
192
- def self.chunk_sym_to_days(sym)
193
- case sym
194
- when :day
195
- 1
196
- when :week
197
- 7
198
- when :biweek
199
- 14
200
- when :semimonth
201
- 15
202
- when :month
203
- 30
204
- when :bimonth
205
- 60
206
- when :quarter
207
- 90
208
- when :half
209
- 180
210
- when :year
211
- 365
212
- when :irregular
213
- 30
214
- else
215
- raise ArgumentError, "unknown chunk sym '#{sym}'"
216
- end
217
- end
218
-
219
- # The smallest number of days possible in each chunk
220
- def self.chunk_sym_to_min_days(sym)
221
- case sym
222
- when :semimonth
223
- 15
224
- when :month
225
- 28
226
- when :bimonth
227
- 59
228
- when :quarter
229
- 86
230
- when :half
231
- 180
232
- when :year
233
- 365
234
- when :irregular
235
- raise ArgumentError, 'no minimum period for :irregular chunk'
236
- else
237
- chunk_sym_to_days(sym)
238
- end
239
- end
240
-
241
- # The largest number of days possible in each chunk
242
- def self.chunk_sym_to_max_days(sym)
243
- case sym
244
- when :semimonth
245
- 16
246
- when :month
247
- 31
248
- when :bimonth
249
- 62
250
- when :quarter
251
- 92
252
- when :half
253
- 183
254
- when :year
255
- 366
256
- when :irregular
257
- raise ArgumentError, 'no maximum period for :irregular chunk'
258
- else
259
- chunk_sym_to_days(sym)
260
- end
261
- end
262
-
263
- # Distinguishing between :semimonth and :biweek is impossible in
264
- # some cases since a :semimonth can be 14 days just like a :biweek.
265
- # This ignores that possiblity and requires a :semimonth to be at
266
- # least 15 days.
267
- def self.days_to_chunk_sym(days)
268
- case days
269
- when 356..376
270
- :year
271
- when 180..183
272
- :half
273
- when 86..96
274
- :quarter
275
- when 59..62
276
- :bimonth
277
- when 26..33
278
- :month
279
- when 15..16
280
- :semimonth
281
- when 14
282
- :biweek
283
- when 7
284
- :week
285
- when 1
286
- :day
287
- else
288
- :irregular
289
- end
290
- end
291
-
292
- def to_range
293
- (first..last)
294
- end
295
-
296
- def to_s
297
- if first.beginning_of_year? && last.end_of_year? && first.year == last.year
298
- first.year.to_s
299
- elsif first.beginning_of_quarter? &&
300
- last.end_of_quarter? &&
301
- first.year == last.year &&
302
- first.quarter == last.quarter
303
- "#{first.year}-#{first.quarter}Q"
304
- elsif first.beginning_of_month? &&
305
- last.end_of_month? &&
306
- first.year == last.year &&
307
- first.month == last.month
308
- "#{first.year}-%02d" % first.month
309
- else
310
- "#{first.iso} to #{last.iso}"
311
- end
312
- end
313
-
314
- # Allow erb documents can directly interpolate ranges
315
- def tex_quote
316
- "#{first.iso}--#{last.iso}"
317
- end
318
-
319
- # Days in period
320
- def size
321
- (last - first + 1).to_i
322
- end
323
-
324
- def length
325
- size
326
- end
327
-
328
- def subset_of?(other)
329
- to_range.subset_of?(other.to_range)
330
- end
331
-
332
- def proper_subset_of?(other)
333
- to_range.proper_subset_of?(other.to_range)
334
- end
335
-
336
- def superset_of?(other)
337
- to_range.superset_of?(other.to_range)
338
- end
339
-
340
- def proper_superset_of?(other)
341
- to_range.proper_superset_of?(other.to_range)
342
- end
343
-
344
- def intersection(other)
345
- result = to_range.intersection(other.to_range)
346
- if result.nil?
347
- nil
348
- else
349
- Period.new(result.first, result.last)
350
- end
351
- end
352
- alias & intersection
353
- alias narrow_to intersection
354
-
355
- def union(other)
356
- result = to_range.union(other.to_range)
357
- Period.new(result.first, result.last)
358
- end
359
- alias + union
360
-
361
- def difference(other)
362
- ranges = to_range.difference(other.to_range)
363
- ranges.each.map { |r| Period.new(r.first, r.last) }
364
- end
365
- alias - difference
366
-
367
- # returns the chunk sym represented by the period
368
- def chunk_sym
369
- if first.beginning_of_year? && last.end_of_year? &&
370
- (365..366) === last - first + 1
371
- :year
372
- elsif first.beginning_of_half? && last.end_of_half? &&
373
- (180..183) === last - first + 1
374
- :half
375
- elsif first.beginning_of_quarter? && last.end_of_quarter? &&
376
- (90..92) === last - first + 1
377
- :quarter
378
- elsif first.beginning_of_bimonth? && last.end_of_bimonth? &&
379
- (58..62) === last - first + 1
380
- :bimonth
381
- elsif first.beginning_of_month? && last.end_of_month? &&
382
- (28..31) === last - first + 1
383
- :month
384
- elsif first.beginning_of_semimonth? && last.end_of_semimonth &&
385
- (13..16) === last - first + 1
386
- :semimonth
387
- elsif first.beginning_of_biweek? && last.end_of_biweek? &&
388
- last - first + 1 == 14
389
- :biweek
390
- elsif first.beginning_of_week? && last.end_of_week? &&
391
- last - first + 1 == 7
392
- :week
393
- elsif first == last
394
- :day
395
- else
396
- :irregular
397
- end
398
- end
399
-
400
- # Name for a period not necessarily ending on calendar boundaries. For
401
- # example, in reporting reconciliation, we want the period from Feb 11,
402
- # 2014, to March 10, 2014, be called the 'Month ending March 10, 2014,'
403
- # event though the period is not a calendar month. Using the stricter
404
- # Period#chunk_sym, would not allow such looseness.
405
- def chunk_name
406
- case Period.days_to_chunk_sym(length)
407
- when :year
408
- 'Year'
409
- when :half
410
- 'Half'
411
- when :quarter
412
- 'Quarter'
413
- when :bimonth
414
- 'Bi-month'
415
- when :month
416
- 'Month'
417
- when :semimonth
418
- 'Semi-month'
419
- when :biweek
420
- 'Bi-week'
421
- when :week
422
- 'Week'
423
- when :day
424
- 'Day'
425
- else
426
- 'Period'
427
- end
428
- end
429
-
430
- def contains?(date)
431
- date = date.to_date if date.respond_to?(:to_date)
432
- raise ArgumentError, 'argument must be a Date' unless date.is_a?(Date)
433
- to_range.cover?(date)
434
- end
435
-
436
- def overlaps?(other)
437
- to_range.overlaps?(other.to_range)
438
- end
439
-
440
- # Return whether any of the Periods that are within self overlap one
441
- # another
442
- def has_overlaps_within?(periods)
443
- to_range.has_overlaps_within?(periods.map(&:to_range))
444
- end
445
-
446
- def spanned_by?(periods)
447
- to_range.spanned_by?(periods.map(&:to_range))
448
- end
449
-
450
- def gaps(periods)
451
- to_range.gaps(periods.map(&:to_range))
452
- .map { |r| Period.new(r.first, r.last) }
453
- end
454
-
455
- # Return an array of Periods wholly-contained within self in chunks of size,
456
- # defaulting to monthly chunks. Partial chunks at the beginning and end of
457
- # self are not included unless partial_first or partial_last, respectively,
458
- # are set true. The last chunk can be made to extend beyond the end of self to
459
- # make it a whole chunk if round_up_last is set true, in which case,
460
- # partial_last is ignored.
461
- def chunks(size: :month, partial_first: false, partial_last: false,
462
- round_up_last: false)
463
- size = size.to_sym
464
- if Period.chunk_sym_to_min_days(size) > length
465
- if partial_first || partial_last
466
- return [self]
467
- else
468
- raise ArgumentError, "any #{size} is longer than this period's #{length} days"
469
- end
470
- end
471
- result = []
472
- chunk_start = first.dup
473
- while chunk_start <= last
474
- case size
475
- when :year
476
- unless partial_first
477
- chunk_start += 1.day until chunk_start.beginning_of_year?
478
- end
479
- chunk_end = chunk_start.end_of_year
480
- when :half
481
- unless partial_first
482
- chunk_start += 1.day until chunk_start.beginning_of_half?
483
- end
484
- chunk_end = chunk_start.end_of_half
485
- when :quarter
486
- unless partial_first
487
- chunk_start += 1.day until chunk_start.beginning_of_quarter?
488
- end
489
- chunk_end = chunk_start.end_of_quarter
490
- when :bimonth
491
- unless partial_first
492
- chunk_start += 1.day until chunk_start.beginning_of_bimonth?
493
- end
494
- chunk_end = (chunk_start.end_of_month + 1.day).end_of_month
495
- when :month
496
- unless partial_first
497
- chunk_start += 1.day until chunk_start.beginning_of_month?
498
- end
499
- chunk_end = chunk_start.end_of_month
500
- when :semimonth
501
- unless partial_first
502
- chunk_start += 1.day until chunk_start.beginning_of_semimonth?
503
- end
504
- chunk_end = chunk_start.end_of_semimonth
505
- when :biweek
506
- unless partial_first
507
- chunk_start += 1.day until chunk_start.beginning_of_biweek?
508
- end
509
- chunk_end = chunk_start.end_of_biweek
510
- when :week
511
- unless partial_first
512
- chunk_start += 1.day until chunk_start.beginning_of_week?
513
- end
514
- chunk_end = chunk_start.end_of_week
515
- when :day
516
- chunk_end = chunk_start
517
- else
518
- raise ArgumentError, "invalid chunk size '#{size}'"
519
- end
520
- if chunk_end <= last
521
- result << Period.new(chunk_start, chunk_end)
522
- elsif round_up_last
523
- result << Period.new(chunk_start, chunk_end)
524
- elsif partial_last
525
- result << Period.new(chunk_start, last)
526
- else
527
- break
528
- end
529
- chunk_start = result.last.last + 1.day
530
- end
531
- result
532
- end
533
- end