fat_period 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.org CHANGED
@@ -1,54 +1,28 @@
1
1
  #+TITLE: FatPeriod User Guide
2
2
  #+OPTIONS: toc:5
3
- #+PROPERTY: header-args:ruby :colnames no :session fat_period :hlines yes :exports both :wrap example :ruby "bundle exec irb --prompt=simple"
4
- #+PROPERTY: header-args:sh :exports code
5
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
6
9
 
7
- [[https://github.com/ddoherty03/fat_period/actions/workflows/ruby.yml][https://github.com/ddoherty03/fat_period/actions/workflows/ruby.yml/badge.svg?branch=master]]
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
8
13
 
9
- * Setup for Code Blocks :noexport:
10
- Run this block before all others to ensure that we are reading the libraries
11
- from the source directory.
12
-
13
- #+begin_src ruby :results output :export no
14
- Dir.chdir("/home/ded/src/fat_period")
15
- puts "Current directory: #{Dir.pwd}"
16
- puts "Ruby LOADPATH:"
17
- $LOAD_PATH.unshift(File.expand_path("lib", Dir.pwd)) unless $:[0].match?(%r{src/fat_period/lib})
18
- $:[0..10].each { |d| puts d }
19
- puts "..."
20
- require 'fat_period'
21
- #+end_src
22
-
23
- #+RESULTS:
24
- #+begin_example
25
- Current directory: /home/ded/src/fat_period
26
- Ruby LOADPATH:
27
- /home/ded/src/fat_period/lib
28
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/bundler-2.6.7/lib
29
- /home/ded/.rbenv/rbenv.d/exec/gem-rehash
30
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/bundler/gems/rubocop-ddoherty-6b28e9614f18/lib
31
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rubocop-shopify-2.17.1/lib
32
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rubocop-rspec-3.7.0/lib
33
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rubocop-rake-0.7.1/lib
34
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rubocop-performance-1.26.1/lib
35
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rubocop-1.81.6/lib
36
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/unicode-display_width-3.2.0/lib
37
- /home/ded/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/unicode-emoji-4.1.0/lib
38
- ...
39
- #+end_example
40
14
 
41
15
  * Version
42
16
  #+begin_src ruby :wrap EXAMPLE
43
17
  "Current version is: #{FatPeriod::VERSION}"
44
18
  #+end_src
45
19
 
20
+ #+RESULTS:
46
21
  #+begin_EXAMPLE
47
- Current version is: 3.0.0
22
+ Current version is: 3.1.0
48
23
  #+end_EXAMPLE
49
24
 
50
25
  * Introduction
51
-
52
26
  ~FatPeriod~ provides a Ruby ~Period~ class for dealing with time periods of
53
27
  days, that is ranges whose endpoints are of class ~Date~. It's target is
54
28
  financial applications, but it serves well for any application where periods
@@ -62,43 +36,6 @@ breaking a larger periods into an array of smaller periods of various 'chunk'
62
36
  sizes that correspond to calendar-related periods such as days, weeks, months,
63
37
  and so forth.
64
38
 
65
- * Table of Contents :toc:noexport:
66
- - [[#version][Version]]
67
- - [[#introduction][Introduction]]
68
- - [[#installation][Installation]]
69
- - [[#installing-the-gem][Installing the gem]]
70
- - [[#usage][Usage]]
71
- - [[#constant-periodforever][Constant Period::FOREVER]]
72
- - [[#construction-of-periods][Construction of Periods]]
73
- - [[#periodnew][Period.new]]
74
- - [[#periodparse][Period.parse]]
75
- - [[#with-only-a-from-spec][With Only a From-Spec]]
76
- - [[#with-both-a-from-spec-and-to-spec][With Both a From-Spec and To-Spec]]
77
- - [[#using-skip-modifiers][Using Skip Modifiers]]
78
- - [[#periodparse_phrase][Period.parse_phrase]]
79
- - [[#periodensure][Period.ensure]]
80
- - [[#conversion][Conversion]]
81
- - [[#to-range][To Range]]
82
- - [[#to-string][To String]]
83
- - [[#tex-form][TeX Form]]
84
- - [[#comparison][Comparison]]
85
- - [[#enumeration][Enumeration]]
86
- - [[#size][Size]]
87
- - [[#chunking][Chunking]]
88
- - [[#set-operations][Set Operations]]
89
- - [[#subset-determination][Subset Determination]]
90
- - [[#superset-determination][Superset Determination]]
91
- - [[#intersection][Intersection]]
92
- - [[#difference][Difference]]
93
- - [[#union][Union]]
94
- - [[#coverage][Coverage]]
95
- - [[#contains][Contains?]]
96
- - [[#overlapping][Overlapping]]
97
- - [[#spanning][Spanning]]
98
- - [[#gaps][Gaps]]
99
- - [[#development][Development]]
100
- - [[#contributing][Contributing]]
101
-
102
39
  * Installation
103
40
 
104
41
  ** Installing the gem
@@ -109,20 +46,29 @@ Add this line to your application's Gemfile:
109
46
  gem 'fat_period'
110
47
  #+END_SRC
111
48
 
49
+ #+RESULTS:
50
+ #+begin_example
51
+ true
52
+ #+end_example
53
+
112
54
  And then execute:
113
55
 
114
56
  #+BEGIN_SRC sh
115
57
  $ bundle
116
58
  #+END_SRC
117
59
 
60
+ #+RESULTS:
61
+
118
62
  Or install it yourself as:
119
63
 
120
64
  #+BEGIN_SRC sh
121
65
  $ gem install fat_period
122
66
  #+END_SRC
123
67
 
68
+ #+RESULTS:
69
+
124
70
  * Usage
125
- ** Constant Period::FOREVER
71
+ ** Constant ~Period::FOREVER~
126
72
 
127
73
  The ~Period~ class depends on the extensions to ~Date~ made by the ~fat_core~
128
74
  gem, which you can read about [[https://github.com/ddoherty03/fat_core][here]]. It defines a constant, ~Period::FOREVER~,
@@ -132,7 +78,7 @@ the beginning of time and end of time for practical commercial purposes. The
132
78
  constant is not frozen, so you can re-define it to your liking.
133
79
 
134
80
  ** Construction of Periods
135
- *** Period.new
81
+ *** ~Period.new~
136
82
  A Period is constructed with two arguments for the begin and end date. The
137
83
  begin date must be on or before the end date. Each argument can be (1) a
138
84
  Date, (2) a string parseable as a Date by the Date.parse method, or (3) an
@@ -146,18 +92,22 @@ object that responds to ~#to_s~ and can be parsed as a Date by Date.parse:
146
92
  #+end_SRC
147
93
 
148
94
  #+RESULTS:
149
- | 2025-03-20 to 2025-04-19 |
150
- | 1963-11-22 to 2025-03-20 |
95
+ #+begin_example
96
+ | 2026-11-05 to 2026-12-05 |
97
+ | 1963-11-22 to 2026-11-05 |
151
98
  | 1961-01-21 to 1963-11-22 |
99
+ #+end_example
152
100
 
153
101
  #+begin_src ruby
154
102
  ["Camelot lasted #{p3.length} days"]
155
103
  #+end_src
156
104
 
157
105
  #+RESULTS:
106
+ #+begin_example
158
107
  | Camelot lasted 1036 days |
108
+ #+end_example
159
109
 
160
- *** Period.parse
110
+ *** ~Period.parse~
161
111
  A more convenient way to construct a period is provided by ~Period.parse~. It
162
112
  takes two strings as its arguments, a mandatory "from-spec" and an optional
163
113
  "to-spec":
@@ -181,6 +131,18 @@ overall period:
181
131
  tab
182
132
  #+end_src
183
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
+
184
146
  #+begin_example
185
147
  | From Spec | Result |
186
148
  |-------------+--------------------------------|
@@ -209,6 +171,17 @@ period is the last day of the to-spec:
209
171
  tab
210
172
  #+end_src
211
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
+
212
185
  #+begin_example
213
186
  | From Spec | To Spec | Result |
214
187
  |-----------+-------------+--------------------------------|
@@ -242,6 +215,7 @@ skipping forward instead.
242
215
  tab
243
216
  #+end_src
244
217
 
218
+ #+RESULTS:
245
219
  #+begin_example
246
220
  | From Spec | To Spec | Result | Description |
247
221
  |-----------+---------------+--------------------------------+------------------------------------------------------------|
@@ -251,7 +225,16 @@ skipping forward instead.
251
225
  | 2012-11 | 2012-11-4Thur | Period(2012-11-01..2012-11-22) | And through Thanksgiving (not always the /last/ Thursday!) |
252
226
  #+end_example
253
227
 
254
- *** Period.parse_phrase
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~
255
238
  For example:
256
239
 
257
240
  The ~Period.parse_phrase~ method will take a string having a 'from', 'to', and
@@ -275,6 +258,7 @@ as a 'from' clause.
275
258
  tab
276
259
  #+end_src
277
260
 
261
+ #+RESULTS:
278
262
  #+begin_example
279
263
  | k | Sub Period |
280
264
  |---+--------------------------------|
@@ -319,6 +303,7 @@ of the overall period.
319
303
  tab
320
304
  #+end_src
321
305
 
306
+ #+RESULTS:
322
307
  #+begin_example
323
308
  | k | Sub Period |
324
309
  |----+--------------------------------|
@@ -364,8 +349,7 @@ of the overall period.
364
349
  | 39 | Period(2025-09-29..2025-09-30) |
365
350
  #+end_example
366
351
 
367
-
368
- *** Period.ensure
352
+ *** ~Period.ensure~
369
353
  ~Period.ensure~ tries to interpret its argument as a ~Period~ and returns it;
370
354
  otherwise it throws an ~ArgumentError~ exception:
371
355
 
@@ -392,14 +376,16 @@ otherwise it throws an ~ArgumentError~ exception:
392
376
  Period.ensure(cm)
393
377
  #+end_src
394
378
 
395
- #+begin_example
396
- Period(2025-09-01..2025-09-30)
379
+ #+RESULTS:
380
+ #+begin_example
381
+ Period(2025-09-01..2025-09-30)
397
382
  #+end_example
398
383
 
399
384
  #+begin_src ruby
400
385
  Period.ensure('from 2016 to 2018-3Q')
401
386
  #+end_src
402
387
 
388
+ #+RESULTS:
403
389
  #+begin_example
404
390
  Period(2016-01-01..2018-09-30)
405
391
  #+end_example
@@ -408,29 +394,884 @@ Period(2016-01-01..2018-09-30)
408
394
  Period.ensure(Period.new('2016-01-02', '2017-09-29'))
409
395
  #+end_src
410
396
 
397
+ #+RESULTS:
411
398
  #+begin_example
412
399
  Period(2016-01-02..2017-09-29)
413
400
  #+end_example
414
401
 
415
402
  ** Conversion
416
- *** To Range
417
- *** To String
418
- *** TeX Form
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
+
419
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
+
420
521
  ** Enumeration
421
- ** Size
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
+
422
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
+
423
818
  ** Set Operations
424
- *** Subset Determination
425
- *** Superset Determination
426
- *** Intersection
427
- *** Difference
428
- *** Union
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
+
429
1048
  ** Coverage
430
- *** Contains?
431
- *** Overlapping
432
- *** Spanning
433
- *** Gaps
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.
1274
+
434
1275
 
435
1276
  * Development
436
1277