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