fat_period 2.1.0 → 3.1.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.
- checksums.yaml +4 -4
- data/.envrc +0 -1
- data/.github/workflows/ruby.yml +57 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +3 -0
- data/CHANGELOG.org +43 -0
- data/Gemfile +13 -11
- data/README.md +1291 -0
- data/README.org +1229 -19
- data/Rakefile +25 -5
- data/bin/console +1 -0
- data/fat_period.gemspec +4 -3
- data/lib/fat_period/date.rb +4 -6
- data/lib/fat_period/period.rb +248 -33
- data/lib/fat_period/version.rb +3 -1
- data/lib/fat_period.rb +25 -5
- metadata +23 -11
- data/.github/workflows/gem-push.yml +0 -48
- data/.travis.yml +0 -6
data/README.md
ADDED
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
- [Version](#orgc5d4d8e)
|
|
2
|
+
- [Introduction](#org1ba80db)
|
|
3
|
+
- [Installation](#org0dff64a)
|
|
4
|
+
- [Installing the gem](#org124d8ed)
|
|
5
|
+
- [Usage](#orge5c3efb)
|
|
6
|
+
- [Constant `Period::FOREVER`](#org72bd7c0)
|
|
7
|
+
- [Construction of Periods](#orgbb2b7e4)
|
|
8
|
+
- [`Period.new`](#orgd8f5b9d)
|
|
9
|
+
- [`Period.parse`](#orga32a08b)
|
|
10
|
+
- [`Period.parse_phrase`](#orgaf07a44)
|
|
11
|
+
- [`Period.ensure`](#org71af6fd)
|
|
12
|
+
- [Conversion](#org033812c)
|
|
13
|
+
- [`#to_range`](#org789b885)
|
|
14
|
+
- [`#to_s`](#orgd4e7bf1)
|
|
15
|
+
- [`#tex_quote`](#org4695aee)
|
|
16
|
+
- [Comparison](#org62e3d88)
|
|
17
|
+
- [Enumeration](#org0a448ca)
|
|
18
|
+
- [`#each`](#org13b1aed)
|
|
19
|
+
- [`#size`](#org73ba385)
|
|
20
|
+
- [Chunking](#org44148fd)
|
|
21
|
+
- [`Period.<chunk>_containing(date)`](#org3a1451a)
|
|
22
|
+
- [`#chunk_sym` and `#chunk_name`](#orgabdc39c)
|
|
23
|
+
- [`#chunks(size: :month, partial_first: true, partial_last: true, round_up_last: false)`](#org1ac4069)
|
|
24
|
+
- [Set Operations](#orgce868a8)
|
|
25
|
+
- [`#subset_of?(other)`](#orgc543c76)
|
|
26
|
+
- [`#proper_subset_of?(other)`](#org25e4557)
|
|
27
|
+
- [`#superset_of(other)`](#org346c561)
|
|
28
|
+
- [`#proper_superset_of(other)`](#orgd6469df)
|
|
29
|
+
- [`#intersection(other)`](#org0657929)
|
|
30
|
+
- [`#union(other)`](#org2b4afad)
|
|
31
|
+
- [`#difference(other)`](#orgabc529b)
|
|
32
|
+
- [Coverage](#org8b77cea)
|
|
33
|
+
- [`#contains?(date)`](#orga8a0a29)
|
|
34
|
+
- [`Period.overlaps_among?(periods)`](#orgff72753)
|
|
35
|
+
- [`#overlaps_among?`](#org7598c73)
|
|
36
|
+
- [`#spanned_by(periods)`](#org2808f48)
|
|
37
|
+
- [`#gaps`](#orgb8b77a6)
|
|
38
|
+
- [Interest Day-count fraction (`#year_fraction`)](#org98c19ce)
|
|
39
|
+
- [Choosing a convention](#org9274bdf)
|
|
40
|
+
- [Actual-day conventions and boundaries](#org4613bae)
|
|
41
|
+
- [Sources](#orgb839523)
|
|
42
|
+
- [Development](#orga87916a)
|
|
43
|
+
- [Contributing](#orgcb9b134)
|
|
44
|
+
|
|
45
|
+
[](https://github.com/ddoherty03/fat_period/actions/workflows/ruby.yml)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
<a id="orgc5d4d8e"></a>
|
|
49
|
+
|
|
50
|
+
# Version
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
"Current version is: #{FatPeriod::VERSION}"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Current version is: 3.1.0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
<a id="org1ba80db"></a>
|
|
62
|
+
|
|
63
|
+
# Introduction
|
|
64
|
+
|
|
65
|
+
`FatPeriod` provides a Ruby `Period` class for dealing with time periods of days, that is ranges whose endpoints are of class `Date`. It's target is financial applications, but it serves well for any application where periods of time are useful. It builds on the [fat\_date](https://github.com/ddoherty03/fat_date) gem, which provides enhancements to the `Date` class, especially its class method `Date.spec` for interpreting a rich set of "specs" as the beginning or end of a variety of calendar-related periods.
|
|
66
|
+
|
|
67
|
+
In addition, set operations are provided for Period, as well as methods for breaking a larger periods into an array of smaller periods of various 'chunk' sizes that correspond to calendar-related periods such as days, weeks, months, and so forth.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
<a id="org0dff64a"></a>
|
|
71
|
+
|
|
72
|
+
# Installation
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
<a id="org124d8ed"></a>
|
|
76
|
+
|
|
77
|
+
## Installing the gem
|
|
78
|
+
|
|
79
|
+
Add this line to your application's Gemfile:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
gem 'fat_period'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
true
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
And then execute:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
$ bundle
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or install it yourself as:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
$ gem install fat_period
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
<a id="orge5c3efb"></a>
|
|
103
|
+
|
|
104
|
+
# Usage
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
<a id="org72bd7c0"></a>
|
|
108
|
+
|
|
109
|
+
## Constant `Period::FOREVER`
|
|
110
|
+
|
|
111
|
+
The `Period` class depends on the extensions to `Date` made by the `fat_core` gem, which you can read about [here](https://github.com/ddoherty03/fat_core). It defines a constant, `Period::FOREVER`, which is defined as extending from `Date::BOT` to `Date::EOT`, which are defined in `fat_date` as 1900-01-01 and 3000-12-31, respectively and define the beginning of time and end of time for practical commercial purposes. The constant is not frozen, so you can re-define it to your liking.
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
<a id="orgbb2b7e4"></a>
|
|
115
|
+
|
|
116
|
+
## Construction of Periods
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
<a id="orgd8f5b9d"></a>
|
|
120
|
+
|
|
121
|
+
### `Period.new`
|
|
122
|
+
|
|
123
|
+
A Period is constructed with two arguments for the begin and end date. The begin date must be on or before the end date. Each argument can be (1) a Date, (2) a string parseable as a Date by the Date.parse method, or (3) an object that responds to `#to_s` and can be parsed as a Date by Date.parse:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
p1 = Period.new(Date.today, Date.today + 30)
|
|
127
|
+
p2 = Period.new('Nov 22, 1963', Date.today)
|
|
128
|
+
p3 = Period.new('1961-01-21', '1963-11-22')
|
|
129
|
+
[[p1.to_s], [p2.to_s], [p3.to_s]]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
| 2026-11-05 to 2026-12-05 |
|
|
134
|
+
| 1963-11-22 to 2026-11-05 |
|
|
135
|
+
| 1961-01-21 to 1963-11-22 |
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
["Camelot lasted #{p3.length} days"]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
| Camelot lasted 1036 days |
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
<a id="orga32a08b"></a>
|
|
148
|
+
|
|
149
|
+
### `Period.parse`
|
|
150
|
+
|
|
151
|
+
A more convenient way to construct a period is provided by `Period.parse`. It takes two strings as its arguments, a mandatory "from-spec" and an optional "to-spec":
|
|
152
|
+
|
|
153
|
+
A "spec" is a string designating some period of time. There are many ways of specifying a period, which are detailed below.
|
|
154
|
+
|
|
155
|
+
1. With Only a From-Spec
|
|
156
|
+
|
|
157
|
+
If only a from-spec is given, it defines both the beginning and end of the overall period:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
tab = []
|
|
161
|
+
tab << ['From Spec', 'Result']
|
|
162
|
+
tab << nil
|
|
163
|
+
froms = ['2020', '2020-2Q', '2020-W15', '2020-09', '2020-09-A', '2020-09-iii']
|
|
164
|
+
froms.each do |f|
|
|
165
|
+
tab << [f, Period.parse(f).inspect]
|
|
166
|
+
end
|
|
167
|
+
tab
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
| From Spec | Result |
|
|
172
|
+
|-------------+--------------------------------|
|
|
173
|
+
| 2020 | Period(2020-01-01..2020-12-31) |
|
|
174
|
+
| 2020-2Q | Period(2020-04-01..2020-06-30) |
|
|
175
|
+
| 2020-W15 | Period(2020-04-06..2020-04-12) |
|
|
176
|
+
| 2020-09 | Period(2020-09-01..2020-09-30) |
|
|
177
|
+
| 2020-09-A | Period(2020-09-01..2020-09-15) |
|
|
178
|
+
| 2020-09-iii | Period(2020-09-14..2020-09-20) |
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
| From Spec | Result |
|
|
183
|
+
|-------------+--------------------------------|
|
|
184
|
+
| 2020 | Period(2020-01-01..2020-12-31) |
|
|
185
|
+
| 2020-2Q | Period(2020-04-01..2020-06-30) |
|
|
186
|
+
| 2020-W15 | Period(2020-04-06..2020-04-12) |
|
|
187
|
+
| 2020-09 | Period(2020-09-01..2020-09-30) |
|
|
188
|
+
| 2020-09-A | Period(2020-09-01..2020-09-15) |
|
|
189
|
+
| 2020-09-iii | Period(2020-09-14..2020-09-20) |
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
2. With Both a From-Spec and To-Spec
|
|
193
|
+
|
|
194
|
+
But, if a to-spec is also given, the from-spec defines the beginning of the period and the to-spec defines the end of the period. In particular, the beginning of the period is the first day of the from-spec and the end of the period is the last day of the to-spec:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
tab = []
|
|
198
|
+
tab << ['From Spec', 'To Spec', 'Result']
|
|
199
|
+
tab << nil
|
|
200
|
+
from_tos = [['2020', '2020-2Q'], ['2020-2Q', '2020-W15'], ['2020-W15', '2020-09'], ['2020-09', '2020-09-A'], ['2020-09-A', '2020-09-iii']]
|
|
201
|
+
from_tos.each do |f, t|
|
|
202
|
+
tab << [f, t, Period.parse(f, t).inspect]
|
|
203
|
+
end
|
|
204
|
+
tab
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
| From Spec | To Spec | Result |
|
|
209
|
+
|-----------+-------------+--------------------------------|
|
|
210
|
+
| 2020 | 2020-2Q | Period(2020-01-01..2020-06-30) |
|
|
211
|
+
| 2020-2Q | 2020-W15 | Period(2020-04-01..2020-04-12) |
|
|
212
|
+
| 2020-W15 | 2020-09 | Period(2020-04-06..2020-09-30) |
|
|
213
|
+
| 2020-09 | 2020-09-A | Period(2020-09-01..2020-09-15) |
|
|
214
|
+
| 2020-09-A | 2020-09-iii | Period(2020-09-01..2020-09-20) |
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
| From Spec | To Spec | Result |
|
|
219
|
+
|-----------+-------------+--------------------------------|
|
|
220
|
+
| 2020 | 2020-2Q | Period(2020-01-01..2020-06-30) |
|
|
221
|
+
| 2020-2Q | 2020-W15 | Period(2020-04-01..2020-04-12) |
|
|
222
|
+
| 2020-W15 | 2020-09 | Period(2020-04-06..2020-09-30) |
|
|
223
|
+
| 2020-09 | 2020-09-A | Period(2020-09-01..2020-09-15) |
|
|
224
|
+
| 2020-09-A | 2020-09-iii | Period(2020-09-01..2020-09-20) |
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
3. Using Skip Modifiers
|
|
228
|
+
|
|
229
|
+
One new feature of FatDate is the ability to add a "skip modifier" to the end of a date spec to skip forward or backward to the first day-of-week either on or before/after the date given by the spec. For example, the following demonstrates that one can set the 'to' spec to the *last* Wednesday of 2025 or the last Wednesday *before* the end of 2025. Using '>' or '>=' specified skipping forward instead.
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
tab = []
|
|
233
|
+
tab << ['From Spec', 'To Spec', 'Result', 'Description']
|
|
234
|
+
tab << nil
|
|
235
|
+
from_to_descs = [['2025-2Q', '2025<=Wed', 'From 2q to last Wednesday of 2025'],
|
|
236
|
+
['2025-2Q', '2025<Wed', 'From 2q to last Wednesday /before/ the end of 2025'],
|
|
237
|
+
['2012-11', '2012-11<=Thur', 'November 2012 through last Thursday'],
|
|
238
|
+
['2012-11', '2012-11-4Thur', 'And through Thanksgiving (not always the /last/ Thursday!)']
|
|
239
|
+
]
|
|
240
|
+
from_to_descs.each do |f, t, d|
|
|
241
|
+
tab << [f, t, Period.parse(f, t).inspect, d]
|
|
242
|
+
end
|
|
243
|
+
tab
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
| From Spec | To Spec | Result | Description |
|
|
248
|
+
|-----------+---------------+--------------------------------+------------------------------------------------------------|
|
|
249
|
+
| 2025-2Q | 2025<=Wed | Period(2025-04-01..2025-12-31) | From 2q to last Wednesday of 2025 |
|
|
250
|
+
| 2025-2Q | 2025<Wed | Period(2025-04-01..2025-12-31) | From 2q to last Wednesday /before/ the end of 2025 |
|
|
251
|
+
| 2012-11 | 2012-11<=Thur | Period(2012-11-01..2012-11-29) | November 2012 through last Thursday |
|
|
252
|
+
| 2012-11 | 2012-11-4Thur | Period(2012-11-01..2012-11-22) | And through Thanksgiving (not always the /last/ Thursday!) |
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
| From Spec | To Spec | Result | Description |
|
|
257
|
+
|-----------+---------------+--------------------------------+------------------------------------------------------------|
|
|
258
|
+
| 2025-2Q | 2025<=Wed | Period(2025-04-01..2025-12-31) | From 2q to last Wednesday of 2025 |
|
|
259
|
+
| 2025-2Q | 2025<Wed | Period(2025-04-01..2025-12-31) | From 2q to last Wednesday /before/ the end of 2025 |
|
|
260
|
+
| 2012-11 | 2012-11<=Thur | Period(2012-11-01..2012-11-29) | November 2012 through last Thursday |
|
|
261
|
+
| 2012-11 | 2012-11-4Thur | Period(2012-11-01..2012-11-22) | And through Thanksgiving (not always the /last/ Thursday!) |
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
<a id="orgaf07a44"></a>
|
|
266
|
+
|
|
267
|
+
### `Period.parse_phrase`
|
|
268
|
+
|
|
269
|
+
For example:
|
|
270
|
+
|
|
271
|
+
The `Period.parse_phrase` method will take a string having a 'from', 'to', and 'per' clause and return an Array of Periods encompassing the same period as `Period.parse`, but optionally broken into sub-periods each having the length specified by the 'per' clause. `Period.parse_phrase` always returns an Array of Periods even if there is no 'per' clause and the Array has only one member. If there is no 'to' clause, the returned period is from the start of the 'from' period to its end. If there is neither a 'from' or a 'to' clause, it tries to interpret the beginning of the phrase as a valid spec and uses it as a 'from' clause.
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
tab = []
|
|
275
|
+
tab << ['k', 'Sub Period']
|
|
276
|
+
tab << nil
|
|
277
|
+
pds = Period.parse_phrase('from 2025 to 2025-3Q per month')
|
|
278
|
+
pds.each_with_index do |pd, k|
|
|
279
|
+
tab << [k, pd.inspect]
|
|
280
|
+
end
|
|
281
|
+
tab
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
| k | Sub Period |
|
|
286
|
+
|---+--------------------------------|
|
|
287
|
+
| 0 | Period(2025-01-01..2025-01-31) |
|
|
288
|
+
| 1 | Period(2025-02-01..2025-02-28) |
|
|
289
|
+
| 2 | Period(2025-03-01..2025-03-31) |
|
|
290
|
+
| 3 | Period(2025-04-01..2025-04-30) |
|
|
291
|
+
| 4 | Period(2025-05-01..2025-05-31) |
|
|
292
|
+
| 5 | Period(2025-06-01..2025-06-30) |
|
|
293
|
+
| 6 | Period(2025-07-01..2025-07-31) |
|
|
294
|
+
| 7 | Period(2025-08-01..2025-08-31) |
|
|
295
|
+
| 8 | Period(2025-09-01..2025-09-30) |
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The period named in the 'per' clause is called a 'chunk' and there are several valid chunk names in `FatPeriod`:
|
|
299
|
+
|
|
300
|
+
| Chunk Name |
|
|
301
|
+
|---------- |
|
|
302
|
+
| year |
|
|
303
|
+
| half |
|
|
304
|
+
| quarter |
|
|
305
|
+
| bimonth |
|
|
306
|
+
| month |
|
|
307
|
+
| semimonth |
|
|
308
|
+
| biweek |
|
|
309
|
+
| week |
|
|
310
|
+
| day |
|
|
311
|
+
|
|
312
|
+
Here is the same period broken into weeks. Notice that the first and last "weeks" are not whole weeks because parts of them fall outside the boundaries of the overall period.
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
tab = []
|
|
316
|
+
tab << ['k', 'Sub Period']
|
|
317
|
+
tab << nil
|
|
318
|
+
pds = Period.parse_phrase('from 2025 to 2025-3Q per week')
|
|
319
|
+
pds.each_with_index do |pd, k|
|
|
320
|
+
tab << [k, pd.inspect]
|
|
321
|
+
end
|
|
322
|
+
tab
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
| k | Sub Period |
|
|
327
|
+
|----+--------------------------------|
|
|
328
|
+
| 0 | Period(2025-01-01..2025-01-05) |
|
|
329
|
+
| 1 | Period(2025-01-06..2025-01-12) |
|
|
330
|
+
| 2 | Period(2025-01-13..2025-01-19) |
|
|
331
|
+
| 3 | Period(2025-01-20..2025-01-26) |
|
|
332
|
+
| 4 | Period(2025-01-27..2025-02-02) |
|
|
333
|
+
| 5 | Period(2025-02-03..2025-02-09) |
|
|
334
|
+
| 6 | Period(2025-02-10..2025-02-16) |
|
|
335
|
+
| 7 | Period(2025-02-17..2025-02-23) |
|
|
336
|
+
| 8 | Period(2025-02-24..2025-03-02) |
|
|
337
|
+
| 9 | Period(2025-03-03..2025-03-09) |
|
|
338
|
+
| 10 | Period(2025-03-10..2025-03-16) |
|
|
339
|
+
| 11 | Period(2025-03-17..2025-03-23) |
|
|
340
|
+
| 12 | Period(2025-03-24..2025-03-30) |
|
|
341
|
+
| 13 | Period(2025-03-31..2025-04-06) |
|
|
342
|
+
| 14 | Period(2025-04-07..2025-04-13) |
|
|
343
|
+
| 15 | Period(2025-04-14..2025-04-20) |
|
|
344
|
+
| 16 | Period(2025-04-21..2025-04-27) |
|
|
345
|
+
| 17 | Period(2025-04-28..2025-05-04) |
|
|
346
|
+
| 18 | Period(2025-05-05..2025-05-11) |
|
|
347
|
+
| 19 | Period(2025-05-12..2025-05-18) |
|
|
348
|
+
| 20 | Period(2025-05-19..2025-05-25) |
|
|
349
|
+
| 21 | Period(2025-05-26..2025-06-01) |
|
|
350
|
+
| 22 | Period(2025-06-02..2025-06-08) |
|
|
351
|
+
| 23 | Period(2025-06-09..2025-06-15) |
|
|
352
|
+
| 24 | Period(2025-06-16..2025-06-22) |
|
|
353
|
+
| 25 | Period(2025-06-23..2025-06-29) |
|
|
354
|
+
| 26 | Period(2025-06-30..2025-07-06) |
|
|
355
|
+
| 27 | Period(2025-07-07..2025-07-13) |
|
|
356
|
+
| 28 | Period(2025-07-14..2025-07-20) |
|
|
357
|
+
| 29 | Period(2025-07-21..2025-07-27) |
|
|
358
|
+
| 30 | Period(2025-07-28..2025-08-03) |
|
|
359
|
+
| 31 | Period(2025-08-04..2025-08-10) |
|
|
360
|
+
| 32 | Period(2025-08-11..2025-08-17) |
|
|
361
|
+
| 33 | Period(2025-08-18..2025-08-24) |
|
|
362
|
+
| 34 | Period(2025-08-25..2025-08-31) |
|
|
363
|
+
| 35 | Period(2025-09-01..2025-09-07) |
|
|
364
|
+
| 36 | Period(2025-09-08..2025-09-14) |
|
|
365
|
+
| 37 | Period(2025-09-15..2025-09-21) |
|
|
366
|
+
| 38 | Period(2025-09-22..2025-09-28) |
|
|
367
|
+
| 39 | Period(2025-09-29..2025-09-30) |
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
<a id="org71af6fd"></a>
|
|
372
|
+
|
|
373
|
+
### `Period.ensure`
|
|
374
|
+
|
|
375
|
+
`Period.ensure` tries to interpret its argument as a `Period` and returns it; otherwise it throws an `ArgumentError` exception:
|
|
376
|
+
|
|
377
|
+
- if the argument responds to the `to_period` method, it invokes that on the argument and returns it;
|
|
378
|
+
- if the argument is a `String`, it uses `Period.parse_phrase` to try to interepret it as a `Period`;
|
|
379
|
+
- if it is already a `Period`, it just returns the argument;
|
|
380
|
+
- otherwise, it throws an `ArgumentError` exception.
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
class ContainingMonth
|
|
384
|
+
def initialize(dat)
|
|
385
|
+
@dat = Date.ensure(dat)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def to_period
|
|
389
|
+
Period.month_containing(@dat)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
cm = ContainingMonth.new('2025-09-22')
|
|
394
|
+
Period.ensure(cm)
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
Period(2025-09-01..2025-09-30)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
Period.ensure('from 2016 to 2018-3Q')
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
Period(2016-01-01..2018-09-30)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
Period.ensure(Period.new('2016-01-02', '2017-09-29'))
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
Period(2016-01-02..2017-09-29)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
<a id="org033812c"></a>
|
|
419
|
+
|
|
420
|
+
## Conversion
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
<a id="org789b885"></a>
|
|
424
|
+
|
|
425
|
+
### `#to_range`
|
|
426
|
+
|
|
427
|
+
`Period#to_range` converts the `Period` to a `Range` with limits `first..last`.
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
[Period.this_year.to_range.class, Period.this_year.to_range.to_s]
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
```
|
|
434
|
+
| Range | 2026-01-01..2026-12-31 |
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
<a id="orgd4e7bf1"></a>
|
|
439
|
+
|
|
440
|
+
### `#to_s`
|
|
441
|
+
|
|
442
|
+
`Period#to_s` does `String` conversion but using compact notation for years, halves, quarters, and months if possible; otherwise, it gives `first` and `last` explicitly:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
specs = [
|
|
446
|
+
'from this_year',
|
|
447
|
+
'from 2026-2H',
|
|
448
|
+
'from 2026-3Q',
|
|
449
|
+
'from 2026-10',
|
|
450
|
+
'from 2026-10 to 2026-10-30'
|
|
451
|
+
]
|
|
452
|
+
tab = []
|
|
453
|
+
tab << ['Spec', '#to_s']
|
|
454
|
+
tab << nil
|
|
455
|
+
specs.each do |s|
|
|
456
|
+
pd = Period.parse_phrase(s).first
|
|
457
|
+
tab << [s, pd.to_s]
|
|
458
|
+
end
|
|
459
|
+
tab
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```
|
|
463
|
+
| Spec | #to_s |
|
|
464
|
+
|----------------------------+--------------------------|
|
|
465
|
+
| from this_year | 2026 |
|
|
466
|
+
| from 2026-2H | 2026-2H |
|
|
467
|
+
| from 2026-3Q | 2026-3Q |
|
|
468
|
+
| from 2026-10 | 2026-10 |
|
|
469
|
+
| from 2026-10 to 2026-10-30 | 2026-10-01 to 2026-10-30 |
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
<a id="org4695aee"></a>
|
|
474
|
+
|
|
475
|
+
### `#tex_quote`
|
|
476
|
+
|
|
477
|
+
`Period#tex_quote` formats a period in a way suitable for TeX or LaTeX. Mainly, this causes it to use an "en-dash" to separate the end points and hyphens to separate date components.
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
specs = [
|
|
481
|
+
'from this_year',
|
|
482
|
+
'from 2026-2H',
|
|
483
|
+
'from 2026-3Q',
|
|
484
|
+
'from 2026-10',
|
|
485
|
+
'from 2026-10 to 2026-10-30'
|
|
486
|
+
]
|
|
487
|
+
tab = []
|
|
488
|
+
tab << ['Spec', '#to_s']
|
|
489
|
+
tab << nil
|
|
490
|
+
specs.each do |s|
|
|
491
|
+
pd = Period.parse_phrase(s).first
|
|
492
|
+
tab << [s, pd.tex_quote]
|
|
493
|
+
end
|
|
494
|
+
tab
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
```
|
|
498
|
+
| Spec | #to_s |
|
|
499
|
+
|----------------------------+------------------------|
|
|
500
|
+
| from this_year | 2026-01-01--2026-12-31 |
|
|
501
|
+
| from 2026-2H | 2026-07-01--2026-12-31 |
|
|
502
|
+
| from 2026-3Q | 2026-07-01--2026-09-30 |
|
|
503
|
+
| from 2026-10 | 2026-10-01--2026-10-31 |
|
|
504
|
+
| from 2026-10 to 2026-10-30 | 2026-10-01--2026-10-30 |
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
<a id="org62e3d88"></a>
|
|
509
|
+
|
|
510
|
+
## Comparison
|
|
511
|
+
|
|
512
|
+
The `Period` class includes `Comparable` so periods can be compared directly. Period are compared by `first`, and if those are the same, then by `last`, and those using normal `Date` comparison.
|
|
513
|
+
|
|
514
|
+
```ruby
|
|
515
|
+
spec_pairs = [
|
|
516
|
+
['from 2026', 'from 2026-1H'],
|
|
517
|
+
['from 2026', 'from 2026-2H'],
|
|
518
|
+
['from 2016-05', 'from 2016-05 to 2016-06'],
|
|
519
|
+
['from 2026', 'from 2026-2H'],
|
|
520
|
+
['from 2026', 'from 2025'],
|
|
521
|
+
['from 2010-09', 'from 2010-09'],
|
|
522
|
+
]
|
|
523
|
+
tab = []
|
|
524
|
+
tab << ['Period1', 'Period2', 'Period1 <=> Period2']
|
|
525
|
+
tab << nil
|
|
526
|
+
spec_pairs.each do |s1, s2|
|
|
527
|
+
p1 = Period.parse_phrase(s1).first
|
|
528
|
+
p2 = Period.parse_phrase(s2).first
|
|
529
|
+
tab << [p1.to_s, p2.to_s, p1 <=> p2]
|
|
530
|
+
end
|
|
531
|
+
tab
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
```
|
|
535
|
+
| Period1 | Period2 | Period1 <=> Period2 |
|
|
536
|
+
|---------+--------------------------+---------------------|
|
|
537
|
+
| 2026 | 2026-1H | 1 |
|
|
538
|
+
| 2026 | 2026-2H | -1 |
|
|
539
|
+
| 2016-05 | 2016-05-01 to 2016-06-30 | -1 |
|
|
540
|
+
| 2026 | 2026-2H | -1 |
|
|
541
|
+
| 2026 | 2025 | 1 |
|
|
542
|
+
| 2010-09 | 2010-09 | 0 |
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
<a id="org0a448ca"></a>
|
|
547
|
+
|
|
548
|
+
## Enumeration
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
<a id="org13b1aed"></a>
|
|
552
|
+
|
|
553
|
+
### `#each`
|
|
554
|
+
|
|
555
|
+
`Period` includes `Enumerable` and defines an `#each` method that iterates through each day in the period:
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
pd = Period.parse('2024-02')
|
|
559
|
+
n = 0
|
|
560
|
+
tab = []
|
|
561
|
+
tab << ['N', 'Day']
|
|
562
|
+
tab << nil
|
|
563
|
+
pd.each do |day|
|
|
564
|
+
tab << [n += 1, day.iso]
|
|
565
|
+
end
|
|
566
|
+
tab
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
```
|
|
570
|
+
| N | Day |
|
|
571
|
+
|----+------------|
|
|
572
|
+
| 1 | 2024-02-01 |
|
|
573
|
+
| 2 | 2024-02-02 |
|
|
574
|
+
| 3 | 2024-02-03 |
|
|
575
|
+
| 4 | 2024-02-04 |
|
|
576
|
+
| 5 | 2024-02-05 |
|
|
577
|
+
| 6 | 2024-02-06 |
|
|
578
|
+
| 7 | 2024-02-07 |
|
|
579
|
+
| 8 | 2024-02-08 |
|
|
580
|
+
| 9 | 2024-02-09 |
|
|
581
|
+
| 10 | 2024-02-10 |
|
|
582
|
+
| 11 | 2024-02-11 |
|
|
583
|
+
| 12 | 2024-02-12 |
|
|
584
|
+
| 13 | 2024-02-13 |
|
|
585
|
+
| 14 | 2024-02-14 |
|
|
586
|
+
| 15 | 2024-02-15 |
|
|
587
|
+
| 16 | 2024-02-16 |
|
|
588
|
+
| 17 | 2024-02-17 |
|
|
589
|
+
| 18 | 2024-02-18 |
|
|
590
|
+
| 19 | 2024-02-19 |
|
|
591
|
+
| 20 | 2024-02-20 |
|
|
592
|
+
| 21 | 2024-02-21 |
|
|
593
|
+
| 22 | 2024-02-22 |
|
|
594
|
+
| 23 | 2024-02-23 |
|
|
595
|
+
| 24 | 2024-02-24 |
|
|
596
|
+
| 25 | 2024-02-25 |
|
|
597
|
+
| 26 | 2024-02-26 |
|
|
598
|
+
| 27 | 2024-02-27 |
|
|
599
|
+
| 28 | 2024-02-28 |
|
|
600
|
+
| 29 | 2024-02-29 |
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
<a id="org73ba385"></a>
|
|
605
|
+
|
|
606
|
+
### `#size`
|
|
607
|
+
|
|
608
|
+
`Period#size` returns the number of days in the period.
|
|
609
|
+
|
|
610
|
+
```ruby
|
|
611
|
+
Period.parse('2024-02').size
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
```
|
|
615
|
+
29
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
<a id="org44148fd"></a>
|
|
620
|
+
|
|
621
|
+
## Chunking
|
|
622
|
+
|
|
623
|
+
`FatPeriod` recognizes several calendar sub-period types than the standard libraries do:
|
|
624
|
+
|
|
625
|
+
- day
|
|
626
|
+
- week
|
|
627
|
+
- biweek
|
|
628
|
+
- semimonth
|
|
629
|
+
- month
|
|
630
|
+
- bimonth
|
|
631
|
+
- quarter
|
|
632
|
+
- half
|
|
633
|
+
- year
|
|
634
|
+
|
|
635
|
+
`FatPeriod` refers to these special kinds of periods as "chunks", and they are often useful in financial applications.
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
<a id="org3a1451a"></a>
|
|
639
|
+
|
|
640
|
+
### `Period.<chunk>_containing(date)`
|
|
641
|
+
|
|
642
|
+
For each chunk, `FatPeriod` provides a method for returning the `Period` of the size of the given chunk containing a given date. For example, to find the calendar quarter containing '2016-08-04', you can call `Period.quarter_containing('2016-08-04')` and get the period `2016-3Q`.
|
|
643
|
+
|
|
644
|
+
```ruby
|
|
645
|
+
chunks = %w(
|
|
646
|
+
day
|
|
647
|
+
week
|
|
648
|
+
biweek
|
|
649
|
+
semimonth
|
|
650
|
+
month
|
|
651
|
+
bimonth
|
|
652
|
+
quarter
|
|
653
|
+
half
|
|
654
|
+
year
|
|
655
|
+
)
|
|
656
|
+
tab = []
|
|
657
|
+
tab << ['Chunk Size', 'Containing Period']
|
|
658
|
+
tab << nil
|
|
659
|
+
chunks.each do |ck|
|
|
660
|
+
meth = "#{ck}_containing".to_sym
|
|
661
|
+
pd = Period.send(meth, '2016-08-04').to_s
|
|
662
|
+
tab << [ck, pd.to_s]
|
|
663
|
+
end
|
|
664
|
+
tab
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
```
|
|
668
|
+
| Chunk Size | Containing Period |
|
|
669
|
+
|------------+--------------------------|
|
|
670
|
+
| day | 2016-08-04 to 2016-08-04 |
|
|
671
|
+
| week | 2016-08-01 to 2016-08-07 |
|
|
672
|
+
| biweek | 2016-08-01 to 2016-08-14 |
|
|
673
|
+
| semimonth | 2016-08-01 to 2016-08-15 |
|
|
674
|
+
| month | 2016-08 |
|
|
675
|
+
| bimonth | 2016-07-01 to 2016-08-31 |
|
|
676
|
+
| quarter | 2016-3Q |
|
|
677
|
+
| half | 2016-2H |
|
|
678
|
+
| year | 2016 |
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
`Period` also provides several related methods that return the chunk-sized period containing today's date:
|
|
682
|
+
|
|
683
|
+
```ruby
|
|
684
|
+
chunks = %w(
|
|
685
|
+
day
|
|
686
|
+
week
|
|
687
|
+
biweek
|
|
688
|
+
semimonth
|
|
689
|
+
month
|
|
690
|
+
bimonth
|
|
691
|
+
quarter
|
|
692
|
+
half
|
|
693
|
+
year
|
|
694
|
+
)
|
|
695
|
+
tab = []
|
|
696
|
+
tab << ['Chunk Size', 'Current Period']
|
|
697
|
+
tab << nil
|
|
698
|
+
chunks.each do |ck|
|
|
699
|
+
meth = "this_#{ck}".to_sym
|
|
700
|
+
pd = Period.send(meth).to_s
|
|
701
|
+
tab << [ck, pd.to_s]
|
|
702
|
+
end
|
|
703
|
+
tab
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
```
|
|
707
|
+
| Chunk Size | Current Period |
|
|
708
|
+
|------------+--------------------------|
|
|
709
|
+
| day | 2026-11-05 to 2026-11-05 |
|
|
710
|
+
| week | 2026-11-02 to 2026-11-08 |
|
|
711
|
+
| biweek | 2026-10-26 to 2026-11-08 |
|
|
712
|
+
| semimonth | 2026-11-01 to 2026-11-15 |
|
|
713
|
+
| month | 2026-11 |
|
|
714
|
+
| bimonth | 2026-11-01 to 2026-12-31 |
|
|
715
|
+
| quarter | 2026-4Q |
|
|
716
|
+
| half | 2026-2H |
|
|
717
|
+
| year | 2026 |
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
<a id="orgabdc39c"></a>
|
|
722
|
+
|
|
723
|
+
### `#chunk_sym` and `#chunk_name`
|
|
724
|
+
|
|
725
|
+
A `Period` can determine if it is a chunk with the `#chunk_sym` method. It's `first` and `last` dates correspond to a chunk boundary, it returns a `Symbol` for its chunks size, or the symbol :irregular otherwise. A similar method, `#chunk_name`, returns a `String` name of the chunk:
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
Date.beginning_of_week = :sunday
|
|
729
|
+
pds = [
|
|
730
|
+
Period.new('2016-02-01', '2016-02-29'),
|
|
731
|
+
Period.new('2016-02-01', '2016-02-28'),
|
|
732
|
+
Period.new('2017-02-01', '2017-02-28'),
|
|
733
|
+
Period.new('2017-01-01', '2017-02-28'),
|
|
734
|
+
Period.new('2016-01-01', '2016-03-31'),
|
|
735
|
+
Period.new('2016-03-20', '2016-03-26'),
|
|
736
|
+
Period.new('2016-01-02', '2016-04-01'),
|
|
737
|
+
]
|
|
738
|
+
tab = []
|
|
739
|
+
tab << ['First', 'Last', 'Chunk']
|
|
740
|
+
tab << nil
|
|
741
|
+
pds.each do |pd|
|
|
742
|
+
tab << [pd.first.iso, pd.last.iso, pd.chunk_sym]
|
|
743
|
+
end
|
|
744
|
+
tab
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
```
|
|
748
|
+
| First | Last | Chunk |
|
|
749
|
+
|------------+------------+------------|
|
|
750
|
+
| 2016-02-01 | 2016-02-29 | :month |
|
|
751
|
+
| 2016-02-01 | 2016-02-28 | :irregular |
|
|
752
|
+
| 2017-02-01 | 2017-02-28 | :month |
|
|
753
|
+
| 2017-01-01 | 2017-02-28 | :bimonth |
|
|
754
|
+
| 2016-01-01 | 2016-03-31 | :quarter |
|
|
755
|
+
| 2016-03-20 | 2016-03-26 | :week |
|
|
756
|
+
| 2016-01-02 | 2016-04-01 | :irregular |
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
<a id="org1ac4069"></a>
|
|
761
|
+
|
|
762
|
+
### `#chunks(size: :month, partial_first: true, partial_last: true, round_up_last: false)`
|
|
763
|
+
|
|
764
|
+
You may want to divide a year into months, or a quarter into semi-months. For this `FatTable` provides `Period#chunks`. By default, the first and last chunks will be partial, the first starting at `Period#first` regardless of whether it is a full chunk and the last ending at `Period#last` regardless of whether it is a full chunk. But this can be altered at the cost of not fully spanning the subject `Period`.
|
|
765
|
+
|
|
766
|
+
- **`partial_first`:** if set `false`, the first chunk will be a full-sized chunk at the possible cost of starting later than `Period#first`; it is `true` by default.
|
|
767
|
+
- **`partial_last`:** if set `false`, the last chunk will be a full-sized chunk at the possible cost of ending earlier than `Period#last`; it is `true` by default.
|
|
768
|
+
- **`round_up_last`:** if set `true`, the last chunk returned may extend beyond `Period#last` if it needs to in order to make it whole; it is `false` by default.
|
|
769
|
+
|
|
770
|
+
Since weeks do not necessarily align with calendar quarters, we get a partial first and last week by default:
|
|
771
|
+
|
|
772
|
+
```ruby
|
|
773
|
+
Period.parse('2026-3Q').chunks(size: :week).map {|m| [m]}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
```
|
|
777
|
+
| Period | (2026-07-01..2026-07-04) |
|
|
778
|
+
| Period | (2026-07-05..2026-07-11) |
|
|
779
|
+
| Period | (2026-07-12..2026-07-18) |
|
|
780
|
+
| Period | (2026-07-19..2026-07-25) |
|
|
781
|
+
| Period | (2026-07-26..2026-08-01) |
|
|
782
|
+
| Period | (2026-08-02..2026-08-08) |
|
|
783
|
+
| Period | (2026-08-09..2026-08-15) |
|
|
784
|
+
| Period | (2026-08-16..2026-08-22) |
|
|
785
|
+
| Period | (2026-08-23..2026-08-29) |
|
|
786
|
+
| Period | (2026-08-30..2026-09-05) |
|
|
787
|
+
| Period | (2026-09-06..2026-09-12) |
|
|
788
|
+
| Period | (2026-09-13..2026-09-19) |
|
|
789
|
+
| Period | (2026-09-20..2026-09-26) |
|
|
790
|
+
| Period | (2026-09-27..2026-09-30) |
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
But we can suppress the partials at the cost of not spanning the whole quarter:
|
|
794
|
+
|
|
795
|
+
```ruby
|
|
796
|
+
Period.parse('2026-3Q').chunks(size: :week, partial_first: false, partial_last: false).map {|m| [m]}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
```
|
|
800
|
+
| Period | (2026-07-05..2026-07-11) |
|
|
801
|
+
| Period | (2026-07-12..2026-07-18) |
|
|
802
|
+
| Period | (2026-07-19..2026-07-25) |
|
|
803
|
+
| Period | (2026-07-26..2026-08-01) |
|
|
804
|
+
| Period | (2026-08-02..2026-08-08) |
|
|
805
|
+
| Period | (2026-08-09..2026-08-15) |
|
|
806
|
+
| Period | (2026-08-16..2026-08-22) |
|
|
807
|
+
| Period | (2026-08-23..2026-08-29) |
|
|
808
|
+
| Period | (2026-08-30..2026-09-05) |
|
|
809
|
+
| Period | (2026-09-06..2026-09-12) |
|
|
810
|
+
| Period | (2026-09-13..2026-09-19) |
|
|
811
|
+
| Period | (2026-09-20..2026-09-26) |
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
With `round_up_last` set `true`, the last chunk overflows the subject Period's bounds if needed to make it a whole week:
|
|
815
|
+
|
|
816
|
+
```ruby
|
|
817
|
+
Period.parse('2026-3Q').chunks(size: :week, round_up_last: true).map {|m| [m]}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
```
|
|
821
|
+
| Period | (2026-07-01..2026-07-04) |
|
|
822
|
+
| Period | (2026-07-05..2026-07-11) |
|
|
823
|
+
| Period | (2026-07-12..2026-07-18) |
|
|
824
|
+
| Period | (2026-07-19..2026-07-25) |
|
|
825
|
+
| Period | (2026-07-26..2026-08-01) |
|
|
826
|
+
| Period | (2026-08-02..2026-08-08) |
|
|
827
|
+
| Period | (2026-08-09..2026-08-15) |
|
|
828
|
+
| Period | (2026-08-16..2026-08-22) |
|
|
829
|
+
| Period | (2026-08-23..2026-08-29) |
|
|
830
|
+
| Period | (2026-08-30..2026-09-05) |
|
|
831
|
+
| Period | (2026-09-06..2026-09-12) |
|
|
832
|
+
| Period | (2026-09-13..2026-09-19) |
|
|
833
|
+
| Period | (2026-09-20..2026-09-26) |
|
|
834
|
+
| Period | (2026-09-27..2026-10-03) |
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
<a id="orgce868a8"></a>
|
|
839
|
+
|
|
840
|
+
## Set Operations
|
|
841
|
+
|
|
842
|
+
Another question one might want to ask of a `Period` is how it relates to another `Period`. Does it fully contain another? Is it a sub-period of another, and so-forth. Since these resemble the same sorts of questions one might ask of sets of numbers, these are called "set operations" on ~Period~s.
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
<a id="orgc543c76"></a>
|
|
846
|
+
|
|
847
|
+
### `#subset_of?(other)`
|
|
848
|
+
|
|
849
|
+
Is this `Period` wholly contained within the `other` `Period`?
|
|
850
|
+
|
|
851
|
+
```ruby
|
|
852
|
+
Period.parse('2026-1Q').subset_of?(Period.parse('2026'))
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
```
|
|
856
|
+
true
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
Note that every `Period` is it's own subset:
|
|
860
|
+
|
|
861
|
+
```ruby
|
|
862
|
+
Period.parse('2026').subset_of?(Period.parse('2026'))
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
```
|
|
866
|
+
true
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
```ruby
|
|
870
|
+
Period.parse('2026').subset_of?(Period.parse('2026-2Q'))
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
```
|
|
874
|
+
false
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
<a id="org25e4557"></a>
|
|
879
|
+
|
|
880
|
+
### `#proper_subset_of?(other)`
|
|
881
|
+
|
|
882
|
+
Is this `Period` wholly contained within the `other` `Period` but not identical to it?
|
|
883
|
+
|
|
884
|
+
```ruby
|
|
885
|
+
Period.parse('2026-1Q').proper_subset_of?(Period.parse('2026'))
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
```
|
|
889
|
+
true
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
Note that every `Period` is *not* it's own proper subset:
|
|
893
|
+
|
|
894
|
+
```ruby
|
|
895
|
+
Period.parse('2026').proper_subset_of?(Period.parse('2026'))
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
```
|
|
899
|
+
false
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
<a id="org346c561"></a>
|
|
904
|
+
|
|
905
|
+
### `#superset_of(other)`
|
|
906
|
+
|
|
907
|
+
Does this `Period` wholly contain `other` `Period`?
|
|
908
|
+
|
|
909
|
+
```ruby
|
|
910
|
+
Period.parse('2026-1Q').superset_of?(Period.parse('2026'))
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
```
|
|
914
|
+
false
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
```ruby
|
|
918
|
+
Period.parse('2026').superset_of?(Period.parse('2026-2H'))
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
```
|
|
922
|
+
true
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
Note that every `Period` is it's own superset:
|
|
926
|
+
|
|
927
|
+
```ruby
|
|
928
|
+
Period.parse('2026').superset_of?(Period.parse('2026'))
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
```
|
|
932
|
+
true
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
<a id="orgd6469df"></a>
|
|
937
|
+
|
|
938
|
+
### `#proper_superset_of(other)`
|
|
939
|
+
|
|
940
|
+
Does this `Period` wholly contain `other` `Period` without being identical to it?
|
|
941
|
+
|
|
942
|
+
```ruby
|
|
943
|
+
Period.parse('2026').proper_superset_of?(Period.parse('2026-2Q'))
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
```
|
|
947
|
+
true
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
Note that every `Period` is *not* it's own proper superset:
|
|
951
|
+
|
|
952
|
+
```ruby
|
|
953
|
+
Period.parse('2026').proper_superset_of?(Period.parse('2026'))
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
```
|
|
957
|
+
false
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
<a id="org0657929"></a>
|
|
962
|
+
|
|
963
|
+
### `#intersection(other)`
|
|
964
|
+
|
|
965
|
+
Return a new `Period` that is contained in both the subject `Period` and `other`.
|
|
966
|
+
|
|
967
|
+
```ruby
|
|
968
|
+
Period.parse('2026').intersection(Period.parse_phrase('from 2025-33W to 2026-23W').first)
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
```
|
|
972
|
+
Period(2026-01-01..2026-06-07)
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
The `&` operator is equivalent.
|
|
976
|
+
|
|
977
|
+
```ruby
|
|
978
|
+
Period.parse('2026') & Period.parse_phrase('from 2025-33W to 2026-23W').first
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
```
|
|
982
|
+
Period(2026-01-01..2026-06-07)
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
As is the method `narrow_to`.
|
|
986
|
+
|
|
987
|
+
```ruby
|
|
988
|
+
Period.parse('2026').narrow_to(Period.parse_phrase('from 2025-33W to 2026-23W').first)
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
```
|
|
992
|
+
Period(2026-01-01..2026-06-07)
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
Return `nil` if there is no intersection:
|
|
996
|
+
|
|
997
|
+
```ruby
|
|
998
|
+
Period.parse('2026').intersection(Period.parse('2024-3Q'))
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
```
|
|
1002
|
+
nil
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
<a id="org2b4afad"></a>
|
|
1007
|
+
|
|
1008
|
+
### `#union(other)`
|
|
1009
|
+
|
|
1010
|
+
If two `Periods` either overlap or are contiguous, you can return a `Period` that spans both:
|
|
1011
|
+
|
|
1012
|
+
```ruby
|
|
1013
|
+
Period.parse('2026').union(Period.parse_phrase('from 2025-33W to 2026-23W').first)
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
```
|
|
1017
|
+
Period(2025-08-11..2026-12-31)
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
The `+` operator is equivalent.
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
Period.parse('2026') + Period.parse_phrase('from 2025-33W to 2026-23W').first
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
```
|
|
1027
|
+
Period(2025-08-11..2026-12-31)
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Return `nil` if there is no overlap or contiguity:
|
|
1031
|
+
|
|
1032
|
+
```ruby
|
|
1033
|
+
Period.parse('2026').union(Period.parse('2024-3Q'))
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
```
|
|
1037
|
+
nil
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
<a id="orgabc529b"></a>
|
|
1042
|
+
|
|
1043
|
+
### `#difference(other)`
|
|
1044
|
+
|
|
1045
|
+
Return an `Array` of `Periods` representing the subject `Period` with any overlap with `other` excluded. If there is no overlap, return the subject `Period`.
|
|
1046
|
+
|
|
1047
|
+
```ruby
|
|
1048
|
+
Period.parse('2015-1Q').difference(Period.parse('2015-02')).map {|p| [p.to_s]}
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
```
|
|
1052
|
+
| 2015-01 |
|
|
1053
|
+
| 2015-03 |
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
```ruby
|
|
1057
|
+
Period.parse('2015-1Q').difference(Period.parse('2015-08')).map {|p| [p.to_s]}
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
```
|
|
1061
|
+
| 2015-1Q |
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
The operator `-` can be used as well:
|
|
1065
|
+
|
|
1066
|
+
```ruby
|
|
1067
|
+
(Period.parse('2015-1Q') - Period.parse('2015-02')).map {|p| [p.to_s]}
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
```
|
|
1071
|
+
| 2015-01 |
|
|
1072
|
+
| 2015-03 |
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
<a id="org8b77cea"></a>
|
|
1077
|
+
|
|
1078
|
+
## Coverage
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
<a id="orga8a0a29"></a>
|
|
1082
|
+
|
|
1083
|
+
### `#contains?(date)`
|
|
1084
|
+
|
|
1085
|
+
Return whether the subject `Period` contains the given `Date`.
|
|
1086
|
+
|
|
1087
|
+
```ruby
|
|
1088
|
+
Period.parse('2026-2Q').contains?('2026-04-15')
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
```
|
|
1092
|
+
true
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
```ruby
|
|
1096
|
+
Period.parse('2026-2Q').contains?('2026-08-15')
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
```
|
|
1100
|
+
false
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
<a id="orgff72753"></a>
|
|
1105
|
+
|
|
1106
|
+
### `Period.overlaps_among?(periods)`
|
|
1107
|
+
|
|
1108
|
+
This class method takes an `Array` of `Periods` and returns whether the given periods have any overlaps with one another.
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
<a id="org7598c73"></a>
|
|
1112
|
+
|
|
1113
|
+
### `#overlaps_among?`
|
|
1114
|
+
|
|
1115
|
+
Similar to `Period.overlaps_among` except that it returns true only if any of the overlaps occur within the subject `Period`.
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
<a id="org2808f48"></a>
|
|
1119
|
+
|
|
1120
|
+
### `#spanned_by(periods)`
|
|
1121
|
+
|
|
1122
|
+
Returns whether the given `Array` of `Periods` "span" the subject `Period`, that is, whether they collectively cover the subject `Period` with no overlaps and no gaps.
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
<a id="orgb8b77a6"></a>
|
|
1126
|
+
|
|
1127
|
+
### `#gaps`
|
|
1128
|
+
|
|
1129
|
+
Returns an `Array` of `Period~s representing the gaps within the subject ~Period` not covered by the given `Array` of `Periods`. Overlaps among the periods do not affect the result nor do gaps outside the range of the subject `Period`. Ordering among the given `periods` does not matter.
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
<a id="org98c19ce"></a>
|
|
1133
|
+
|
|
1134
|
+
## Interest Day-count fraction (`#year_fraction`)
|
|
1135
|
+
|
|
1136
|
+
For interest-related calculations, the methods by which accrued interest is allocated to a partial `Period` are numerous and subtle. Rather than solve this problem over-and-over, `FatPeriod` aims to implement a single method that solves this problem once-and-for-all.
|
|
1137
|
+
|
|
1138
|
+
The dates that correspond to the notation in Wikipedia's [Day count convention](https://en.wikipedia.org/wiki/Day_count_convention) article, which was used as a reference for `FatPeriod`'s implementation, are as follows:
|
|
1139
|
+
|
|
1140
|
+
- `first` is Date1, the start of accrual.
|
|
1141
|
+
- `date2` is Date2, the payment or settlement date, supplied as a `Date` or a date string. It must fall within the Period, including its endpoints.
|
|
1142
|
+
- `last` is Date3, the next coupon payment date. For ICMA, construct the Period with consecutive coupon boundaries.
|
|
1143
|
+
|
|
1144
|
+
`Period#year_fraction` returns a `BigDecimal` factor for calculating interest from `first` to a given payment or settlement date. Usually this is a fraction of a year: multiply the principal by the annual rate and this factor to calculate accrued interest. The default convention is **30/360 US** (`convention: 1`).
|
|
1145
|
+
|
|
1146
|
+
```
|
|
1147
|
+
period.year_fraction(date2, convention: 1, eom: true, maturity_date: nil)
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
Day counts use elapsed days, without adding one as `Period#size` does. For example, January 1 to July 1 is the reference period for a coupon paid on July 1; use July 1 as `last`, rather than June 30. Passing `last` as the settlement date calculates the full coupon's factor.
|
|
1151
|
+
|
|
1152
|
+
```ruby
|
|
1153
|
+
period = Period.new('2024-01-01', '2024-07-01')
|
|
1154
|
+
pay_date = '2024-04-01'
|
|
1155
|
+
principal = BigDecimal('1000')
|
|
1156
|
+
rate = BigDecimal('0.05')
|
|
1157
|
+
|
|
1158
|
+
tab =
|
|
1159
|
+
[['Principal', principal.to_s('F')],
|
|
1160
|
+
['Rate', rate.to_s('F')],
|
|
1161
|
+
['Period', period.to_s],
|
|
1162
|
+
['Payment Date', pay_date]]
|
|
1163
|
+
tab
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
```
|
|
1167
|
+
| Principal | 1000.0 |
|
|
1168
|
+
| Rate | 0.05 |
|
|
1169
|
+
| Period | 2024-01-01 to 2024-07-01 |
|
|
1170
|
+
| Payment Date | 2024-04-01 |
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
```ruby
|
|
1174
|
+
tab2 = []
|
|
1175
|
+
tab2 << ['Convention', 'Factor', 'Interest']
|
|
1176
|
+
tab2 << nil
|
|
1177
|
+
0.upto(11) do |conv|
|
|
1178
|
+
factor = period.year_fraction(pay_date, convention: conv)
|
|
1179
|
+
interest = principal * rate * factor
|
|
1180
|
+
tab2 << [conv, factor.round(4).to_s('F'), interest.round(2).to_s('F')]
|
|
1181
|
+
end
|
|
1182
|
+
tab2
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
```
|
|
1186
|
+
| Convention | Factor | Interest |
|
|
1187
|
+
|------------+--------+----------|
|
|
1188
|
+
| 0 | 0.25 | 12.5 |
|
|
1189
|
+
| 1 | 0.25 | 12.5 |
|
|
1190
|
+
| 2 | 0.25 | 12.5 |
|
|
1191
|
+
| 3 | 0.25 | 12.5 |
|
|
1192
|
+
| 4 | 0.25 | 12.5 |
|
|
1193
|
+
| 5 | 0.2486 | 12.43 |
|
|
1194
|
+
| 6 | 0.2493 | 12.47 |
|
|
1195
|
+
| 7 | 0.2528 | 12.64 |
|
|
1196
|
+
| 8 | 0.25 | 12.5 |
|
|
1197
|
+
| 9 | 0.2486 | 12.43 |
|
|
1198
|
+
| 10 | 0.2486 | 12.43 |
|
|
1199
|
+
| 11 | 1.0 | 50.0 |
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
<a id="org9274bdf"></a>
|
|
1204
|
+
|
|
1205
|
+
### Choosing a convention
|
|
1206
|
+
|
|
1207
|
+
The convention numbers are specific to `FatPeriod`; they are not Excel's `YEARFRAC` basis numbers. Supply `convention: 0` explicitly for Bond Basis. The ISDA references below are to Section 4.16 of the *2006 ISDA Definitions*.
|
|
1208
|
+
|
|
1209
|
+
| Convention | Name | Reference |
|
|
1210
|
+
|---------- |------------------- |------------------------------- |
|
|
1211
|
+
| 0 | 30/360 Bond Basis | ISDA 4.16(f) |
|
|
1212
|
+
| 1 | 30/360 US (default) | 4.16(f), plus US February rules |
|
|
1213
|
+
| 2 | 30E/360 | ISDA 4.16(g) |
|
|
1214
|
+
| 3 | 30E/360 ISDA | ISDA 4.16(h) |
|
|
1215
|
+
| 4 | Actual/Actual ICMA | ISDA 4.16(c); ICMA Rule 251 |
|
|
1216
|
+
| 5 | Actual/Actual ISDA | ISDA 4.16(b) |
|
|
1217
|
+
| 6 | Actual/365 Fixed | ISDA 4.16(d) |
|
|
1218
|
+
| 7 | Actual/360 | ISDA 4.16(e) |
|
|
1219
|
+
| 8 | Actual/364 | Wikipedia's Actual/364 section |
|
|
1220
|
+
| 9 | Actual/365L | Wikipedia's Actual/365L section |
|
|
1221
|
+
| 10 | Actual/Actual AFB | Wikipedia's AFB discussion |
|
|
1222
|
+
| 11 | 1/1 | ISDA 4.16(a) |
|
|
1223
|
+
|
|
1224
|
+
For convention 1, `eom: true` (the default) applies the February end-of-month rules for investments that pay at month end. Set `eom: false` to omit those February adjustments. The remaining 30/360 adjustments still apply.
|
|
1225
|
+
|
|
1226
|
+
For convention 3, `maturity_date:` accepts a `Date` or date string and identifies the investment's final maturity. It is irrelevant for the other conventions and is `nil` by default. Under convention 3, when settlement (i.e., `date2`) is that date and is the last day of February, its day number is left unchanged. Otherwise the usual month-end adjustment applies.
|
|
1227
|
+
|
|
1228
|
+
```ruby
|
|
1229
|
+
final_coupon = Period.new('2023-08-31', '2024-02-29')
|
|
1230
|
+
ordinary = final_coupon.year_fraction('2024-02-29', convention: 3)
|
|
1231
|
+
at_maturity = final_coupon.year_fraction(
|
|
1232
|
+
'2024-02-29', convention: 3, maturity_date: '2024-02-29'
|
|
1233
|
+
)
|
|
1234
|
+
[ordinary.to_s('F'), at_maturity.round(10).to_s('F')]
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
```
|
|
1238
|
+
| 0.5 | 0.4972222222 |
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
<a id="org4613bae"></a>
|
|
1243
|
+
|
|
1244
|
+
### Actual-day conventions and boundaries
|
|
1245
|
+
|
|
1246
|
+
ICMA (4) divides elapsed days by the product of payment frequency and elapsed days in the full coupon period. Frequency is inferred from the full period's length. For example, both a full 181-day half-year coupon and a full 184-day half-year coupon have a factor of 0.5, but their daily accruals differ.
|
|
1247
|
+
|
|
1248
|
+
```ruby
|
|
1249
|
+
coupon = Period.new('2024-01-01', '2024-07-01')
|
|
1250
|
+
partial = coupon.year_fraction('2024-04-01', convention: 4)
|
|
1251
|
+
full = coupon.year_fraction(coupon.last, convention: 4)
|
|
1252
|
+
[partial.to_s('F'), full.to_s('F')]
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
```
|
|
1256
|
+
| 0.25 | 0.5 |
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
ICMA supports regular coupon periods only; it does not construct notional coupon schedules for irregular first or final coupons. Frequency inference allows the same 10% length tolerance as `chunk_name` for monthly and longer periods. An unrecognizable length raises `ArgumentError` for conventions that require frequency (4 and 9).
|
|
1260
|
+
|
|
1261
|
+
ISDA Actual/Actual (5) splits the elapsed interval at calendar-year boundaries, using 365 or 366 for each portion. Conventions 6, 7, and 8 use fixed denominators of 365, 360, and 364 respectively.
|
|
1262
|
+
|
|
1263
|
+
Actual/365L (9) follows the Wikipedia article's settlement-date rule: for annual payments, it uses 366 if February 29 falls after `first` and on or before settlement, otherwise 365. For other frequencies, it uses the number of days in the settlement year. The full period determines frequency, not the leap-day test interval.
|
|
1264
|
+
|
|
1265
|
+
AFB (10) counts complete years backwards from settlement, then calculates the remaining initial fraction. It uses the ISDA backward-counting February adjustment described in the article: a February 28 anniversary moves to February 29 when the preceding year is a leap year. The remaining fraction uses 366 only when it includes February 29, counting its start but excluding its end.
|
|
1266
|
+
|
|
1267
|
+
Equal start and settlement dates return zero for conventions 0 through 10. Convention 11 (1/1) always returns one, even for equal dates: it applies the full contractual rate without prorating for time. Invalid convention numbers or settlement dates outside the Period raise `ArgumentError`.
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
<a id="orgb839523"></a>
|
|
1271
|
+
|
|
1272
|
+
### Sources
|
|
1273
|
+
|
|
1274
|
+
- [Wikipedia: Day count convention](https://en.wikipedia.org/wiki/Day_count_convention) gives the formulas, naming variants, and discussion of the conventions implemented here.
|
|
1275
|
+
- [2006 ISDA Definitions](https://www.isda.org/book/2006-isda-definitions/), Section 4.16, *Day Count Fraction*, is the primary source for most of these rules. A [PDF copy hosted by Standard Chartered](https://www.sc.com/en/uploads/sites/66/content/docs/2006-ISDA-Definitions.pdf) contains the section on printed pages 11-13. Section 4.16(f) defines Bond Basis; the additional US February rules are described in the Wikipedia article rather than that clause.
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
<a id="orga87916a"></a>
|
|
1279
|
+
|
|
1280
|
+
# Development
|
|
1281
|
+
|
|
1282
|
+
After checking out the repo, run \`bin/setup\` to install dependencies. Then, run \`rake spec\` to run the tests. You can also run \`bin/console\` for an interactive prompt that will allow you to experiment.
|
|
1283
|
+
|
|
1284
|
+
To install this gem onto your local machine, run \`bundle exec rake install\`. To release a new version, update the version number in \`version.rb\`, and then run \`bundle exec rake release\`, which will create a git tag for the version, push git commits and tags, and push the \`.gem\` file to [rubygems.org](<https://rubygems.org>).
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
<a id="orgcb9b134"></a>
|
|
1288
|
+
|
|
1289
|
+
# Contributing
|
|
1290
|
+
|
|
1291
|
+
Bug reports and pull requests are welcome on GitHub at <https://github.com/ddoherty03/fat_table>.
|