fat_period 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile CHANGED
@@ -5,6 +5,9 @@ require 'rspec/core/rake_task'
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
+ require 'gem_docs'
9
+ GemDocs.install
10
+
8
11
  ########################################################################
9
12
  # Rubocop tasks
10
13
  ########################################################################
data/fat_period.gemspec CHANGED
@@ -21,6 +21,6 @@ Gem::Specification.new do |spec|
21
21
  # spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
22
  spec.require_paths = ['lib']
23
23
 
24
- spec.add_dependency 'fat_core', '>= 6.0'
25
- spec.add_dependency 'fat_date'
24
+ spec.add_dependency 'fat_core', '>= 7.0'
25
+ spec.add_dependency 'fat_date', '>= 0.3.0'
26
26
  end
@@ -23,9 +23,5 @@ module FatPeriod
23
23
  end
24
24
  end
25
25
 
26
- # An extension Date for methods useful with respect to FatPeriod::Periods.
27
- class Date
28
- include FatPeriod::Date
29
- # @!parse include FatPeriod::Date
30
- # @!parse extend FatPeriod::Date::ClassMethods
31
- end
26
+ # Add the period helpers to Ruby's Date class.
27
+ Date.include(FatPeriod::Date)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fat_date'
4
+ require 'bigdecimal'
4
5
 
5
6
  # The Period class represents a range of Dates and supports a variety of
6
7
  # operations on those ranges.
@@ -185,7 +186,7 @@ class Period
185
186
  "Period(#{first.iso}..#{last.iso})"
186
187
  end
187
188
 
188
- # Allow erb documents can directly interpolate ranges
189
+ # Allow erb documents to directly interpolate ranges
189
190
  def tex_quote
190
191
  "#{first.iso}--#{last.iso}"
191
192
  end
@@ -207,11 +208,6 @@ class Period
207
208
  [first, last] <=> [other.first, other.last]
208
209
  end
209
210
 
210
- # Comparable does not include this.
211
- def !=(other)
212
- !(self == other)
213
- end
214
-
215
211
  # Return the hash value for this Period. Make Period's with identical
216
212
  # values test eql? so that they may be used as hash keys.
217
213
  #
@@ -337,38 +333,47 @@ class Period
337
333
 
338
334
  # Return a period representing a chunk containing a given Date.
339
335
  def self.day_containing(date)
336
+ date = Date.ensure_date(date)
340
337
  Period.new(date, date)
341
338
  end
342
339
 
343
340
  def self.week_containing(date)
341
+ date = Date.ensure_date(date)
344
342
  Period.new(date.beginning_of_week, date.end_of_week)
345
343
  end
346
344
 
347
345
  def self.biweek_containing(date)
346
+ date = Date.ensure_date(date)
348
347
  Period.new(date.beginning_of_biweek, date.end_of_biweek)
349
348
  end
350
349
 
351
350
  def self.semimonth_containing(date)
351
+ date = Date.ensure_date(date)
352
352
  Period.new(date.beginning_of_semimonth, date.end_of_semimonth)
353
353
  end
354
354
 
355
355
  def self.month_containing(date)
356
+ date = Date.ensure_date(date)
356
357
  Period.new(date.beginning_of_month, date.end_of_month)
357
358
  end
358
359
 
359
360
  def self.bimonth_containing(date)
361
+ date = Date.ensure_date(date)
360
362
  Period.new(date.beginning_of_bimonth, date.end_of_bimonth)
361
363
  end
362
364
 
363
365
  def self.quarter_containing(date)
366
+ date = Date.ensure_date(date)
364
367
  Period.new(date.beginning_of_quarter, date.end_of_quarter)
365
368
  end
366
369
 
367
370
  def self.half_containing(date)
371
+ date = Date.ensure_date(date)
368
372
  Period.new(date.beginning_of_half, date.end_of_half)
369
373
  end
370
374
 
371
375
  def self.year_containing(date)
376
+ date = Date.ensure_date(date)
372
377
  Period.new(date.beginning_of_year, date.end_of_year)
373
378
  end
374
379
 
@@ -618,8 +623,7 @@ class Period
618
623
  # @param round_up_last [Boolean] allow the last period in the returned array
619
624
  # to extend beyond the end of self.
620
625
  # @return [Array<Period>] periods that subdivide self into chunks of size, `size`
621
- def chunks(size: :month, partial_first: true, partial_last: true,
622
- round_up_last: false)
626
+ def chunks(size: :month, partial_first: true, partial_last: true, round_up_last: false)
623
627
  chunk_size = size.to_sym
624
628
  raise ArgumentError, "unknown chunk size '#{chunk_size}'" unless CHUNKS.include?(chunk_size)
625
629
 
@@ -809,13 +813,14 @@ class Period
809
813
  to_range.overlaps?(other.to_range)
810
814
  end
811
815
 
816
+ # @group Coverage
817
+
812
818
  # Return whether this Period contains the given date.
813
819
  #
814
820
  # @param date [Date] date to test
815
821
  # @return [Boolean] is the given date within this Period?
816
822
  def contains?(date)
817
- date = date.to_date if date.respond_to?(:to_date)
818
- raise ArgumentError, 'argument must be a Date' unless date.is_a?(Date)
823
+ date = Date.ensure(date)
819
824
 
820
825
  to_range.cover?(date)
821
826
  end
@@ -896,4 +901,212 @@ class Period
896
901
  to_range.gaps(periods.map(&:to_range))
897
902
  .map { |r| Period.new(r.first, r.last) }
898
903
  end
904
+
905
+ # Return the fraction of a whole year represented by the part of this Period
906
+ # from its beginning to the given date2, following the day-count convention,
907
+ # convention.
908
+ #
909
+ # This Period is assumed to represent an entire accrual period, for example,
910
+ # a whole month for a loan, or a whole quarter or semi-year for a bond. We
911
+ # infer the annual frequency of payments from the length of this Period.
912
+ #
913
+ # Return the fraction of the year that has elapsed on date2, typically the
914
+ # settlement date.
915
+ #
916
+ # - ~first~ :: is the date on which interest accrual begins
917
+ # - ~date2~ :: is the date when payment of interest is made,
918
+ # - ~last~ :: is the date on which the interest accrual period ends,
919
+ # usually irrelevant unless actual days in period are considered
920
+ #
921
+ # The ~convention~ is the day-count convention to be used.
922
+ #
923
+ # That is, the convention for how interest is calculated between
924
+ # interest payment dates. This has to do with the fraction by which the
925
+ # annual interest or coupon rate is multiplied to pay for a partial
926
+ # period. The numerator is the number of days that have elapsed; The
927
+ # denominator is the number of days in the entire year. These involve
928
+ # assumptions about the number of days in the months that fall within
929
+ # the partial period. They will assume either that every month consists
930
+ # of 30 days, even if the particular period in which the payment occurs
931
+ # contains February, July, and August, or they will take into account
932
+ # the actual number of days in the particular period.
933
+ #
934
+ # | ~convention~ | Description | Source |
935
+ # |--------------+--------------------+------------------------------------|
936
+ # | 0 | 30/360 Bond | ISDA 2006 Section 4.16(f) |
937
+ # | 1 | 30/360 US | ISDA 2006 Section 4.16(f) (Note A) |
938
+ # | 2 | 30E/360 | ISDA 2006 Section 4.16(g) |
939
+ # | 3 | 30E/360 ISDA | ISDA 2006 Section 4.16(h) |
940
+ # | 4 | Actual/actual ICMA | ISDA 2006 Section 4.16(c) |
941
+ # | 5 | Actual/actual ISDA | ISDA 2006 Section 4.16(b) |
942
+ # | 6 | Actual/365 Fixed | ISDA 2006 Section 4.16(d) |
943
+ # | 7 | Actual/360 | ISDA 2006 Section 4.16(e) |
944
+ # | 8 | Actual/364 | |
945
+ # | 9 | Actual/365L | ICMA Rule 251.1(i) |
946
+ # | 10 | Actual/Actual AFB | |
947
+ # | 11 | 1/1 | ISDA 2006 Section 4.16(a) |
948
+ #
949
+ # Source: https://en.wikipedia.org/wiki/Day_count_convention
950
+ #
951
+ # Unlike Period#size, day counts use elapsed days, without adding one.
952
+ # For ICMA, first and last are consecutive coupon boundaries; only regular
953
+ # coupon periods are supported. Settlement must lie within this Period.
954
+ # Actual/365L uses the settlement date to determine its denominator, following
955
+ # the reference above. Convention 11 (1/1) always returns one, even for equal dates.
956
+ # AFB counts whole years backwards, with the February month-end adjustment
957
+ # used by QuantLib, then counts the remaining days (start included, end excluded).
958
+ #
959
+ # @param date2 [Date, String] payment or settlement date within this Period
960
+ # @param convention [Integer] day-count convention, 0 through 11; defaults to 1 (30/360 US)
961
+ # @param eom [Boolean] apply February end-of-month rules for convention 1
962
+ # @param maturity_date [Date, String, nil] final maturity for convention 3
963
+ # @return [BigDecimal] accrued fraction of a year
964
+ def year_fraction(date2, convention: 1, eom: true, maturity_date: nil)
965
+ unless convention.is_a?(Integer) && (0..11).cover?(convention)
966
+ raise ArgumentError, 'convention must be an integer 0..11'
967
+ end
968
+
969
+ date1 = first
970
+ date2 = Date.ensure(date2)
971
+ unless (first..last).cover?(date2)
972
+ raise ArgumentError, 'settlement date must lie within the period'
973
+ end
974
+
975
+ maturity_date = Date.ensure(maturity_date) if maturity_date
976
+ return BigDecimal(1) if convention == 11
977
+ return BigDecimal(0) if date1 == date2
978
+
979
+ if (0..3).cover?(convention)
980
+ # 360-day methods
981
+ y1 = date1.year
982
+ m1 = date1.month
983
+ d1 = date1.day
984
+ y2 = date2.year
985
+ m2 = date2.month
986
+ d2 = date2.day
987
+ case convention
988
+ when 0
989
+ # 30/360 Bond
990
+ d1 = [d1, 30].min
991
+ d2 = d1 > 29 ? [d2, 30].min : d2
992
+ when 1
993
+ # 30/360 US
994
+ if eom && date1.end_of_month? && m1 == 2 && date2.end_of_month? && m2 == 2
995
+ d2 = 30
996
+ end
997
+ if eom && date1.end_of_month? && m1 == 2
998
+ d1 = 30
999
+ end
1000
+ # If D2 is 31 and D1 is 30 or 31, then change D2 to 30.
1001
+ if d2 == 31 && (d1 == 30 || d1 == 31)
1002
+ d2 = 30
1003
+ end
1004
+ # If D1 is 31, then change D1 to 30.
1005
+ if d1 == 31
1006
+ d1 = 30
1007
+ end
1008
+ when 2
1009
+ # 30E/360
1010
+ # If D1 is 31, then change D1 to 30.
1011
+ if d1 == 31
1012
+ d1 = 30
1013
+ end
1014
+ # If D2 is 31, then change D2 to 30.
1015
+ if d2 == 31
1016
+ d2 = 30
1017
+ end
1018
+ when 3
1019
+ # 30E/360 ISDA
1020
+ if date1.end_of_month?
1021
+ d1 = 30
1022
+ end
1023
+ if date2.end_of_month? && !(m2 == 2 && maturity_date && date2 == maturity_date)
1024
+ # If D2 is the last day of the month (unless Date2 is the maturity
1025
+ # date and M2 is February), then change D2 to 30.
1026
+ d2 = 30
1027
+ end
1028
+ end
1029
+ num = 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1)
1030
+ return BigDecimal(num) / 360
1031
+ end
1032
+
1033
+ num = BigDecimal((date2 - date1).to_i)
1034
+ case convention
1035
+ when 4
1036
+ # Actual/actual ICMA: elapsed days in the reference coupon period.
1037
+ num / (freq_per_year(days: (last - first).to_i) * (last - first).to_i)
1038
+ when 5
1039
+ # Actual/actual ISDA: split only the elapsed interval at year boundaries.
1040
+ frac = BigDecimal(0)
1041
+ cursor = date1
1042
+ while cursor < date2
1043
+ boundary = [Date.new(cursor.year + 1, 1, 1), date2].min
1044
+ frac += BigDecimal((boundary - cursor).to_i) / (cursor.leap? ? 366 : 365)
1045
+ cursor = boundary
1046
+ end
1047
+ frac
1048
+ when 6
1049
+ num / 365
1050
+ when 7
1051
+ num / 360
1052
+ when 8
1053
+ num / 364
1054
+ when 9
1055
+ # Actual/365L: annual coupons exclude the start and include the end
1056
+ # when looking for February 29. Other frequencies use the settlement year.
1057
+ leap =
1058
+ if freq_per_year(days: (last - first).to_i) == 1
1059
+ (first.year..date2.year).any? do |year|
1060
+ Date.leap?(year) && first < Date.new(year, 2, 29) && Date.new(year, 2, 29) <= date2
1061
+ end
1062
+ else
1063
+ date2.leap?
1064
+ end
1065
+ num / (leap ? 366 : 365)
1066
+ when 10
1067
+ # Actual/actual AFB: whole years plus the remaining fraction.
1068
+ years = 0
1069
+ remainder_end = date2
1070
+ loop do
1071
+ previous = remainder_end << 12
1072
+ previous += 1 if previous.month == 2 && previous.day == 28 && previous.leap?
1073
+ break if previous < date1
1074
+
1075
+ years += 1
1076
+ remainder_end = previous
1077
+ end
1078
+ leap = (date1.year..remainder_end.year).any? do |year|
1079
+ Date.leap?(year) && date1 <= Date.new(year, 2, 29) && Date.new(year, 2, 29) < remainder_end
1080
+ end
1081
+ years + BigDecimal((remainder_end - date1).to_i) / (leap ? 366 : 365)
1082
+ end
1083
+ end
1084
+
1085
+ # Infer payment frequency from the period length, allowing the same tolerance
1086
+ # as chunk_name. Daily periods use the number of days in the starting year.
1087
+ # @param days [Integer] period length; use elapsed days for coupon boundaries
1088
+ def freq_per_year(days: length)
1089
+ case Period.days_to_chunk(days)
1090
+ when :year
1091
+ 1
1092
+ when :half
1093
+ 2
1094
+ when :quarter
1095
+ 4
1096
+ when :bimonth
1097
+ 6
1098
+ when :month
1099
+ 12
1100
+ when :semimonth
1101
+ 24
1102
+ when :biweek
1103
+ 26
1104
+ when :week
1105
+ 52
1106
+ when :day
1107
+ first.leap? ? 366 : 365
1108
+ else
1109
+ raise ArgumentError, "indeterminate size of period (#{self})"
1110
+ end
1111
+ end
899
1112
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FatPeriod
4
- VERSION = '3.0.0'
4
+ VERSION = '3.2.0'
5
5
  end
data/lib/fat_period.rb CHANGED
@@ -1,14 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'date'
4
-
5
4
  require 'active_support'
6
5
 
7
- require 'fat_period/version'
8
- require 'fat_period/date'
9
- require 'fat_period/period'
10
-
11
6
  require 'fat_core/range'
12
7
  require 'fat_core/string'
13
-
14
8
  require 'fat_date'
9
+
10
+ # Gem Overview (extracted from README.org by gem_docs)
11
+ #
12
+ # * Introduction
13
+ # ~FatPeriod~ provides a Ruby ~Period~ class for dealing with time periods of
14
+ # days, that is ranges whose endpoints are of class ~Date~. It's target is
15
+ # financial applications, but it serves well for any application where periods
16
+ # of time are useful. It builds on the [[https://github.com/ddoherty03/fat_date][fat_date]] gem, which provides
17
+ # enhancements to the ~Date~ class, especially its class method ~Date.spec~ for
18
+ # interpreting a rich set of "specs" as the beginning or end of a variety of
19
+ # calendar-related periods.
20
+ #
21
+ # In addition, set operations are provided for Period, as well as methods for
22
+ # breaking a larger periods into an array of smaller periods of various 'chunk'
23
+ # sizes that correspond to calendar-related periods such as days, weeks, months,
24
+ # and so forth.
25
+ module FatPeriod
26
+ require 'fat_period/version'
27
+ require 'fat_period/date'
28
+ require 'fat_period/period'
29
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fat_period
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel E. Doherty
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-28 00:00:00.000000000 Z
10
+ date: 2026-11-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: fat_core
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '7.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '6.0'
25
+ version: '7.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: fat_date
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 0.3.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 0.3.0
40
40
  email:
41
41
  - ded@ddoherty.net
42
42
  executables: []
@@ -51,6 +51,7 @@ files:
51
51
  - CHANGELOG.org
52
52
  - Gemfile
53
53
  - LICENSE.txt
54
+ - README.md
54
55
  - README.org
55
56
  - Rakefile
56
57
  - bin/console
@@ -77,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
78
  - !ruby/object:Gem::Version
78
79
  version: '0'
79
80
  requirements: []
80
- rubygems_version: 3.6.3
81
+ rubygems_version: 3.6.2
81
82
  specification_version: 4
82
83
  summary: Implements a Period class as a Range of Dates.
83
84
  test_files: []