finrb 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8474be54c480d3150125ba2a00e7b935fbfb10281ce857304889ebca48839bac
4
- data.tar.gz: 571f0f38b0a662c749df453e314cb3f916efe27e2f466896e600719a9048aef1
3
+ metadata.gz: 2a5f20ce5e17a03b0043df557ea5ea2207eb15e286e8f80664ef436dadc6cbce
4
+ data.tar.gz: de4773bdabd2131fcfcf4be29cc9befecf47fd92353e308d7cdae5569d7c914e
5
5
  SHA512:
6
- metadata.gz: 31edbc91ddfe6bfd433daaae2598be24b7fe22d604e40745b238a66c01ad8cd597d9a441b4643f9cd0a1adc5230cd2e3780fc1ba791e5799e921ffb0a6bc6e59
7
- data.tar.gz: 59b760dfb7f1c27686d34362ad9da310b2bb20ccfc440b42f39e6c551fd14c1ac134dd3ed8795e0cf9d4274c264e085a95b59339bd71b5c11e6763f99f9b82a1
6
+ metadata.gz: e0afa3b1d816b589d23ea2a6cd16c3a274d4ce68bb2a2552caa7b5c398d214b66c3f287fc824c7b607c6288df40b2b7fe7541c84f190a8e3f282cd8ed47238a7
7
+ data.tar.gz: 22c1d7ace18c693faa7f7d28551a064a1e9390e61d9bb023724dc98afbc24c13ae8d80044496260e1c846a37ba7d0a3b70da58e318d04a56aae979f5ead7892b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # finrb changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Dated lending and calendars
6
+
7
+ - Add opt-in dated amortization with monthly, quarterly, semiannual, and annual
8
+ payment frequencies, explicit short-final stubs, and Actual/365 Fixed or
9
+ Actual/360 interest accrual. Rate durations remain expressed in months.
10
+ - Add `Amortization#cashflow_yield` to report the dated borrower's effective
11
+ annual cashflow-equivalent cost from net proceeds and scheduled payments.
12
+ This is not a jurisdiction-specific legal APR disclosure.
13
+ - Add dependency-free US Federal Reserve payment-day and Israel TASE trading
14
+ calendars with business-day adjustment conventions. Supported ranges are
15
+ explicit: US 1950–2065 and TASE 2000–2050.
16
+ - Extract immutable `Finrb::Schedule` payment periods for reuse by dated loans
17
+ and fixed-rate bonds.
18
+
19
+ ### Fixed income
20
+
21
+ - Add `Finrb::FixedRateBond` for regular fixed-coupon bullet bonds, with
22
+ Actual/Actual ICMA coupon accrual, accrued interest, clean and dirty prices,
23
+ and yield-to-maturity.
24
+ - Correct accrued-interest selection when settlement falls between an
25
+ unadjusted coupon boundary and its business-day-adjusted payment date.
26
+
27
+ ### Verification and development
28
+
29
+ - Add pinned QuantLib reference fixtures and seeded randomized cross-validation
30
+ for dated amortization, calendar profiles, and fixed-rate bond valuations.
31
+ - Add calendar and randomized bond oracle checks to CI, and organize scripts by
32
+ verification, fixture generation, packaging, documentation, and benchmarks.
33
+
3
34
  ## 1.2.0
4
35
 
5
36
  ### Validation and financial correctness
data/CONTRIBUTING.md CHANGED
@@ -13,12 +13,12 @@ and TruffleRuby are tested as informational compatibility targets.
13
13
  ```shell
14
14
  bundle install
15
15
  bundle exec rake quality
16
- bundle exec rubocop
17
16
  ```
18
17
 
19
- `rake quality` runs the RSpec suite with coverage, verifies maintained API
20
- examples, and validates the packaged RBS declarations. Additional checks are
21
- available for changes that affect their domains:
18
+ `rake quality` runs RuboCop and the RSpec suite with coverage, verifies
19
+ maintained API examples, and validates the packaged RBS declarations. Run
20
+ `bundle exec rake lint` alone for a focused full-repository lint check.
21
+ Additional checks are available for changes that affect their domains:
22
22
 
23
23
  ```shell
24
24
  bundle exec rake security:audit
data/README.md CHANGED
@@ -58,6 +58,9 @@ API explicitly returns another financial object, such as `Finrb::Rate`.
58
58
  | `Finrb::Cashflow` | NPV, XNPV, IRR, and XIRR |
59
59
  | `Finrb::Rate` | Nominal APR, effective APY, and compounding conversions |
60
60
  | `Finrb::Amortization` | Fixed and adjustable-rate loan amortization |
61
+ | `Finrb::Schedule` | Immutable dated payment periods with optional calendar adjustment |
62
+ | `Finrb::FixedRateBond` | Fixed-coupon bullet bond cashflows, accrued interest, price, and yield |
63
+ | `Finrb::Calendars` | US Federal Reserve and Israel TASE business calendars |
61
64
  | `Finrb::TVM` | Present value, future value, payments, periods, and perpetuities |
62
65
  | `Finrb::Returns` | Holding-period, time-weighted, portfolio, and risk-adjusted returns |
63
66
  | `Finrb::Yields` | Money-market, bond-equivalent, effective, and continuous yield conversions |
@@ -131,6 +134,44 @@ first.interest_only?
131
134
  first.closing_balance
132
135
  ```
133
136
 
137
+ Pass a Ruby `Date` as `start_date:` to opt into an actual/365 dated schedule.
138
+ The start date is the accrual boundary; the first payment date is one month
139
+ later. Month-end anchors stay at month end, while other day numbers are clamped
140
+ to shorter months and recovered from the original anchor in the following
141
+ month. Dated periods accrue simple nominal APR for their actual number of days
142
+ (`APR * days / 365`); this is a specific convention, not a universal loan
143
+ standard. Dates stay unadjusted by default. To opt in, supply a finrb market
144
+ calendar and explicit business-day convention; the adjusted payment dates then
145
+ drive the actual/365 accrual. finrb includes US Federal Reserve and Israel
146
+ TASE full-day calendars, with no runtime holiday-data dependency.
147
+ The profiles intentionally support US dates from 1950 through 2065 and TASE
148
+ dates from 2000 through 2050; querying or configuring dates outside those
149
+ windows raises `RangeError` rather than extrapolating silently.
150
+
151
+ ```ruby
152
+ require 'date'
153
+
154
+ dated = Finrb::Amortization.new(
155
+ 100_000,
156
+ Finrb::Rate.new(0.05, :apr, duration: 3),
157
+ start_date: Date.new(2024, 1, 31)
158
+ )
159
+ dated.schedule.first.date # => #<Date: 2024-02-29 ...>
160
+
161
+ bank_calendar = Finrb::Calendars::USFederalReserve.new
162
+ calendar_adjusted = Finrb::Amortization.new(
163
+ 100_000,
164
+ Finrb::Rate.new(0.05, :apr, duration: 3),
165
+ start_date: Date.new(2026, 1, 31),
166
+ calendar: bank_calendar,
167
+ business_day_convention: :modified_following
168
+ )
169
+ calendar_adjusted.schedule.first.date # => #<Date: 2026-02-27 ...>
170
+ ```
171
+
172
+ See the [calendar API guide](docs/api.md#business-calendars) for market scope,
173
+ holiday overrides, and supported date-adjustment conventions.
174
+
134
175
  Pass several duration-bearing rates for an adjustable-rate schedule. A block
135
176
  can modify each scheduled payment, for example to add a $150 principal payment:
136
177
 
@@ -185,6 +226,25 @@ financed_fee.net_proceeds # => Flt::DecNum('250000')
185
226
  financed_fee.amount_financed # => Flt::DecNum('252500')
186
227
  ```
187
228
 
229
+ For a dated schedule, `cashflow_yield` calculates the effective annual
230
+ cashflow-equivalent cost to the borrower using net proceeds on the start date
231
+ and each actual payment on its scheduled date. The final payment already
232
+ includes any balloon settlement, so it is counted only once. The result is a
233
+ `Finrb::Rate`; it is not a jurisdiction-specific legal APR and follows the
234
+ current `Finrb::Cashflow.xirr` configuration.
235
+
236
+ ```ruby
237
+ require 'date'
238
+
239
+ dated_loan = Finrb::Amortization.new(
240
+ 250_000,
241
+ rate,
242
+ start_date: Date.new(2025, 1, 15),
243
+ origination_fee: 2_500
244
+ )
245
+ dated_loan.cashflow_yield.apy # effective annual cost implied by proceeds and payments
246
+ ```
247
+
188
248
  ## Configuration
189
249
 
190
250
  Configure process-wide defaults during application startup:
@@ -261,14 +321,14 @@ Install the bundle and run the self-contained quality checks:
261
321
  ```shell
262
322
  bundle install
263
323
  bundle exec rake quality
264
- bundle exec rubocop
265
324
  bundle exec rake security:audit
266
325
  bundle exec rake package:verify
267
326
  ```
268
327
 
269
- The quality task runs the RSpec suite with line and branch coverage, generated
270
- IRR/XIRR properties, committed SciPy/QuantLib reference fixtures, and RBS
271
- validation.
328
+ The quality task runs RuboCop, the RSpec suite with line and branch coverage,
329
+ generated IRR/XIRR properties, committed SciPy/QuantLib reference fixtures,
330
+ verified API examples, and RBS validation. Run `bundle exec rake lint` alone for
331
+ a focused full-repository lint check.
272
332
 
273
333
  `security:audit` updates ruby-advisory-db and checks the locked dependencies.
274
334
  `package:verify` builds the gem, validates its contents and metadata, installs
@@ -321,17 +381,28 @@ only finrb's runtime dependencies and RSpec; MRI-only development tooling such
321
381
  as RBS, RuboCop, and coverage is deliberately excluded from engine
322
382
  compatibility runs.
323
383
 
324
- Maintainers with the optional Python environment can run the larger seeded
325
- solver verification campaign:
384
+ Maintainers with the optional Python environment can run seeded randomized
385
+ cross-validation campaigns for solvers and fixed-rate bonds:
326
386
 
327
387
  ```shell
328
- python3 -m pip install --requirement script/requirements-solver-verification.txt
388
+ python3 -m pip install --requirement script/verification/requirements-solver.txt
329
389
  bundle exec rake solver:verify
330
390
  ```
331
391
 
392
+ The bond campaign needs only QuantLib-Python 1.43:
393
+
394
+ ```shell
395
+ python3 -m pip install --requirement script/verification/requirements-bonds.txt
396
+ bundle exec rake bond:verify
397
+ ```
398
+
399
+ The same bond campaign can run in the isolated Docker target with
400
+ `bundle exec rake docker:verify_bond`.
401
+
332
402
  The Python packages are verification references, not gem dependencies. See
333
403
  [the fixture documentation](spec/fixtures/README.md) for reproducibility,
334
- Docker, batching, and tolerance details.
404
+ Docker, batching, and tolerance details. Verification and maintainer utilities
405
+ are grouped by purpose under `script/`.
335
406
 
336
407
  ## Project links
337
408
 
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'calendars'
3
4
  require_relative 'cashflows'
5
+ require_relative 'day_count'
4
6
  require_relative 'decimal'
5
7
  require_relative 'precision'
8
+ require_relative 'schedule'
6
9
  require_relative 'transaction'
7
10
  require_relative 'validation'
11
+ require 'date'
8
12
 
9
13
  module Finrb
10
14
  # the Amortization class provides an interface for working with loan amortizations.
@@ -19,22 +23,29 @@ module Finrb
19
23
  # rate = Rate.new(0.0425, :apr, :duration => (5 * 12))
20
24
  # extra_payments = Finrb::Amortization.new(250000, rate){ |period| period.payment - 150 }
21
25
  class Amortization
26
+ FREQUENCY_MONTHS = Schedule::FREQUENCY_MONTHS
27
+ STUB_CONVENTIONS = Schedule::STUB_CONVENTIONS
28
+ public_constant :FREQUENCY_MONTHS, :STUB_CONVENTIONS
29
+
22
30
  # Immutable breakdown of one amortization period. Payments retain finrb's
23
31
  # cashflow sign convention and are negative; the other monetary fields are
24
32
  # non-negative.
25
33
  class Entry
26
34
  ATTRIBUTES = %i[period opening_balance payment interest principal additional_payment balloon_payment interest_only closing_balance].freeze
27
35
  MONETARY_ATTRIBUTES = ATTRIBUTES - %i[period interest_only]
28
- private_constant :ATTRIBUTES, :MONETARY_ATTRIBUTES
36
+ DATE_ATTRIBUTE = :date
37
+ private_constant :ATTRIBUTES, :DATE_ATTRIBUTE, :MONETARY_ATTRIBUTES
29
38
 
30
- attr_reader(*ATTRIBUTES)
39
+ attr_reader(*ATTRIBUTES, DATE_ATTRIBUTE)
31
40
 
32
- def initialize(period:, opening_balance:, payment:, interest:, principal:, additional_payment:, balloon_payment:, interest_only:, closing_balance:)
41
+ def initialize(period:, opening_balance:, payment:, interest:, principal:, additional_payment:, balloon_payment:, interest_only:, closing_balance:, date: nil)
33
42
  raise(ArgumentError, 'period must be a non-negative integer.') unless period.is_a?(Integer) && !period.negative?
34
43
  raise(ArgumentError, 'interest_only must be true or false.') unless [true, false].include?(interest_only)
44
+ raise(ArgumentError, 'date must be a Date or nil.') unless date.nil? || date.instance_of?(Date)
35
45
 
36
46
  @period = period
37
47
  @interest_only = interest_only
48
+ @date = date
38
49
  MONETARY_ATTRIBUTES.each do |name|
39
50
  value = binding.local_variable_get(name)
40
51
  instance_variable_set("@#{name}", Validation.decimal(value, name: name.to_s.tr('_', ' ')))
@@ -43,17 +54,18 @@ module Finrb
43
54
  end
44
55
 
45
56
  def ==(other)
46
- other.instance_of?(self.class) && ATTRIBUTES.all? { |name| public_send(name) == other.public_send(name) }
57
+ other.instance_of?(self.class) && ([DATE_ATTRIBUTE] + ATTRIBUTES).all? { |name| public_send(name) == other.public_send(name) }
47
58
  end
48
59
  alias eql? ==
49
60
 
50
61
  def hash
51
- attributes = ATTRIBUTES.map { |name| public_send(name) }
52
- attributes.hash
62
+ [date, *ATTRIBUTES.map { |name| public_send(name) }].hash
53
63
  end
54
64
 
55
65
  def to_h
56
- ATTRIBUTES.to_h { |name| [name, public_send(name)] }
66
+ attributes = ATTRIBUTES.to_h { |name| [name, public_send(name)] }
67
+ attributes[DATE_ATTRIBUTE] = date unless date.nil?
68
+ attributes
57
69
  end
58
70
 
59
71
  alias interest_only? interest_only
@@ -71,7 +83,7 @@ module Finrb
71
83
  attr_reader :origination_fee
72
84
  # @return [Integer] number of leading periods that pay interest but no scheduled principal
73
85
  attr_reader :interest_only_periods
74
- # @return [Flt::DecNum] the required monthly payment. For loans with more than one rate, returns nil
86
+ # @return [Flt::DecNum] the required regular payment. For loans with more than one rate, returns nil
75
87
  attr_reader :payment
76
88
  # @return [Flt::DecNum] the principal amount of the loan
77
89
  attr_reader :principal
@@ -79,6 +91,18 @@ module Finrb
79
91
  attr_reader :rates
80
92
  # @return [Array<Entry>] immutable period-by-period loan breakdown
81
93
  attr_reader :schedule
94
+ # @return [Date, nil] the date from which payment dates are generated
95
+ attr_reader :start_date
96
+ # @return [Finrb::Calendars::Base, nil] the calendar used to adjust dated payments
97
+ attr_reader :calendar
98
+ # @return [Symbol, nil] business-day convention used when adjusting dated payments
99
+ attr_reader :business_day_convention
100
+ # @return [Symbol] day-count convention used for dated interest accrual
101
+ attr_reader :day_count
102
+ # @return [Symbol] interval between dated payments
103
+ attr_reader :frequency
104
+ # @return [Symbol] final partial-period handling
105
+ attr_reader :stub
82
106
 
83
107
  # @return [Flt::DecNum] the periodic payment due on a loan
84
108
  # @param [Flt::DecNum] principal the initial amount of the loan or investment
@@ -114,8 +138,17 @@ module Finrb
114
138
  # @param [Flt::DecNum] principal the initial amount of the loan or investment
115
139
  # @param [Rate] rates the applicable interest rates
116
140
  # @param [Proc] block
117
- def initialize(principal, *rates, balloon: 0, interest_only_periods: 0, origination_fee: 0, finance_origination_fee: false, &block)
141
+ # @param [Finrb::Calendars::Base, nil] calendar optional market calendar for dated payment adjustment
142
+ # @param [Symbol, nil] business_day_convention required when calendar is supplied
143
+ # @param [Symbol] frequency dated payment interval; rate durations remain in months
144
+ # @param [Symbol] stub explicit handling for a final partial dated period
145
+ def initialize(principal, *rates, balloon: 0, interest_only_periods: 0, origination_fee: 0, finance_origination_fee: false, start_date: nil, calendar: nil, business_day_convention: nil, day_count: DayCount::DEFAULT, frequency: :monthly, stub: :none, &block)
118
146
  @principal = Validation.positive_decimal(principal, name: 'principal', message: 'principal must be positive.')
147
+ raise(ArgumentError, 'start_date must be a Date or nil.') unless start_date.nil? || start_date.instance_of?(Date)
148
+
149
+ validate_day_count!(start_date, day_count)
150
+
151
+ validate_calendar_options!(start_date, calendar, business_day_convention)
119
152
 
120
153
  @origination_fee = Validation.non_negative_decimal(origination_fee, name: 'origination fee')
121
154
  raise(ArgumentError, 'finance_origination_fee must be true or false.') unless [true, false].include?(finance_origination_fee)
@@ -134,12 +167,16 @@ module Finrb
134
167
  @rates = rates
135
168
  @block = block
136
169
 
137
- # compute the total duration from all of the rates.
138
- @periods = rates.sum(&:duration)
170
+ initialize_schedule(start_date, frequency, stub, rates, calendar, business_day_convention)
171
+
139
172
  valid_interest_only = interest_only_periods.is_a?(Integer) && interest_only_periods.between?(0, @periods - 1)
140
173
  raise(ArgumentError, 'interest_only_periods must be a non-negative integer shorter than the loan term.') unless valid_interest_only
141
174
 
142
175
  @interest_only_periods = interest_only_periods
176
+ @start_date = start_date
177
+ @calendar = calendar
178
+ @business_day_convention = business_day_convention
179
+ @day_count = day_count
143
180
  @period = 0
144
181
 
145
182
  compute
@@ -149,7 +186,7 @@ module Finrb
149
186
  # @return [Numeric] -1, 0, or +1
150
187
  # @param [Amortization] other
151
188
  def ==(other)
152
- (principal == other.principal) && (origination_fee == other.origination_fee) && (finance_origination_fee? == other.finance_origination_fee?) && (balloon == other.balloon) && (interest_only_periods == other.interest_only_periods) && (rates == other.rates) && (payments == other.payments)
189
+ (principal == other.principal) && (start_date == other.start_date) && (calendar == other.calendar) && (business_day_convention == other.business_day_convention) && (day_count == other.day_count) && (frequency == other.frequency) && (stub == other.stub) && (origination_fee == other.origination_fee) && (finance_origination_fee? == other.finance_origination_fee?) && (balloon == other.balloon) && (interest_only_periods == other.interest_only_periods) && (rates == other.rates) && (payments == other.payments)
153
190
  end
154
191
 
155
192
  attr_reader :finance_origination_fee
@@ -164,13 +201,31 @@ module Finrb
164
201
  @transactions.filter_map { |trans| trans.difference if trans.payment? }
165
202
  end
166
203
 
204
+ # Calculate the effective annual yield implied by borrower proceeds and
205
+ # scheduled loan payments. This is a cashflow-equivalent borrowing yield,
206
+ # not a jurisdiction-specific legal APR.
207
+ # @param [Numeric, nil] guess initial rate used by XIRR; defaults to Finrb.config.guess
208
+ # @return [Rate] the effective annual cashflow-equivalent yield
209
+ # @raise [ArgumentError] if the amortization has no start date
210
+ # @example
211
+ # rate = Rate.new(0.05, :apr, duration: 12)
212
+ # loan = Amortization.new(10_000, rate, start_date: Date.new(2025, 1, 15), origination_fee: 250)
213
+ # loan.cashflow_yield.apy #=> effective annual borrower cost
214
+ def cashflow_yield(guess = nil)
215
+ raise(ArgumentError, 'cashflow_yield requires a start_date.') unless start_date
216
+
217
+ transactions = [Transaction.new(net_proceeds, date: start_date)]
218
+ transactions.concat(schedule.map { |entry| Transaction.new(entry.payment, date: entry.date) })
219
+ Cashflow.xirr(transactions, guess)
220
+ end
221
+
167
222
  # amortize the balance of loan with the given interest rate
168
223
  # @return none
169
224
  # @param [Rate] rate the interest rate to use in the amortization
170
- def amortize(rate)
225
+ def amortize(rate, periods)
171
226
  regular_payment = nil
172
227
 
173
- rate.duration.to_i.times do
228
+ periods.times do
174
229
  # Do this first in case the balance is zero already.
175
230
  break if @balance.zero?
176
231
 
@@ -178,13 +233,16 @@ module Finrb
178
233
  regular_payment ||= build_regular_payment(rate) unless interest_only
179
234
 
180
235
  # Compute and record interest on the outstanding balance.
181
- int = Precision.money(@balance * rate.monthly)
182
- interest = Interest.new(int, period: @period)
236
+ due_date = @payment_dates&.fetch(@period)
237
+ periodic_rate = due_date ? dated_period_rate(rate, @period) : rate.monthly
238
+ int = Precision.money(@balance * periodic_rate)
239
+ interest = Interest.new(int, period: @period, date: due_date)
183
240
  @balance += interest.amount
184
241
  @transactions << interest.dup
185
242
 
186
- payment = interest_only ? build_interest_only_payment(int) : regular_payment
243
+ payment = interest_only ? build_interest_only_payment(int, due_date) : regular_payment
187
244
  payment.period = @period
245
+ payment.date = due_date if due_date
188
246
  payment.amount = -@balance if payment.amount.abs > @balance
189
247
  @additional_by_period << [-payment.difference, Flt::DecNum(0)].max
190
248
  @interest_only_by_period << interest_only
@@ -203,8 +261,8 @@ module Finrb
203
261
  @additional_by_period = []
204
262
  @interest_only_by_period = []
205
263
 
206
- @rates.each do |rate|
207
- amortize(rate)
264
+ @rates.each_with_index do |rate, index|
265
+ amortize(rate, @rate_period_counts.fetch(index))
208
266
  end
209
267
 
210
268
  # Add the residual balloon and any rounding remainder to the last payment.
@@ -226,7 +284,7 @@ module Finrb
226
284
 
227
285
  private :amortize, :compute
228
286
 
229
- # @return [Integer] the time required to pay off the loan, in months
287
+ # @return [Integer] number of payments in the amortization schedule
230
288
  # @example In most cases, the duration is equal to the total duration of all rates
231
289
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
232
290
  # amt = Finrb::Amortization.new(300000, rate)
@@ -267,12 +325,66 @@ module Finrb
267
325
 
268
326
  private
269
327
 
328
+ def validate_calendar_options!(start_date, calendar, convention)
329
+ if calendar.nil?
330
+ raise(ArgumentError, 'business_day_convention requires a calendar.') unless convention.nil?
331
+
332
+ return
333
+ end
334
+
335
+ raise(ArgumentError, 'calendar must be a Finrb::Calendars::Base instance.') unless calendar.is_a?(Calendars::Base)
336
+ raise(ArgumentError, 'calendar adjustment requires a start_date.') unless start_date
337
+ raise(ArgumentError, "business_day_convention must be one of #{Calendars::Base::CONVENTIONS.join(', ')}.") unless Calendars::Base::CONVENTIONS.include?(convention)
338
+ end
339
+
340
+ def validate_day_count!(start_date, day_count)
341
+ raise(ArgumentError, "day_count must be one of #{DayCount::CONVENTIONS.join(', ')}.") unless DayCount::CONVENTIONS.include?(day_count)
342
+ raise(ArgumentError, 'a non-default day_count requires a start_date.') if start_date.nil? && day_count != DayCount::DEFAULT
343
+ end
344
+
345
+ def validate_schedule_options!(start_date, frequency, stub)
346
+ raise(ArgumentError, "frequency must be one of #{FREQUENCY_MONTHS.keys.join(', ')}.") unless FREQUENCY_MONTHS.key?(frequency)
347
+ raise(ArgumentError, "stub must be one of #{STUB_CONVENTIONS.join(', ')}.") unless STUB_CONVENTIONS.include?(stub)
348
+ raise(ArgumentError, 'a non-monthly frequency requires a start_date.') if start_date.nil? && frequency != :monthly
349
+ raise(ArgumentError, 'a non-default stub requires a start_date.') if start_date.nil? && stub != :none
350
+ end
351
+
352
+ def initialize_schedule(start_date, frequency, stub, rates, calendar, business_day_convention)
353
+ @term_months = rates.sum(&:duration)
354
+ validate_schedule_options!(start_date, frequency, stub)
355
+ @frequency = frequency
356
+ @stub = stub
357
+ @dated_schedule = start_date && Schedule.from_months(start_date:, term_months: @term_months, frequency:, stub:, calendar:, business_day_convention:)
358
+ @payment_dates = @dated_schedule&.payment_dates
359
+ @periods = @payment_dates ? @payment_dates.length : @term_months
360
+ @rate_period_counts = start_date ? periods_per_rate(rates, frequency, @periods) : rates.map(&:duration)
361
+ end
362
+
363
+ def periods_per_rate(rates, frequency, total_periods)
364
+ interval = FREQUENCY_MONTHS.fetch(frequency)
365
+ cumulative_months = 0
366
+ previous_period_count = 0
367
+ rates.each_with_index.map do |rate, index|
368
+ cumulative_months += rate.duration
369
+ if index == rates.length - 1
370
+ total_periods - previous_period_count
371
+ else
372
+ raise(ArgumentError, 'rate changes must align with a payment date for dated non-monthly schedules.') if (cumulative_months % interval).nonzero?
373
+
374
+ period_count = cumulative_months / interval
375
+ periods_in_segment = period_count - previous_period_count
376
+ previous_period_count = period_count
377
+ periods_in_segment
378
+ end
379
+ end
380
+ end
381
+
270
382
  def build_schedule
271
383
  opening_balance = @amount_financed
272
384
  @transactions.each_slice(2).with_index.map do |(interest, payment), index|
273
385
  principal = -(payment.amount + interest.amount)
274
386
  closing_balance = opening_balance - principal
275
- entry = Entry.new(period: payment.period, opening_balance:, payment: payment.amount, interest: interest.amount, principal:, additional_payment: @additional_by_period.fetch(index), balloon_payment: @balloon_by_period.fetch(index), interest_only: @interest_only_by_period.fetch(index), closing_balance:)
387
+ entry = Entry.new(period: payment.period, opening_balance:, payment: payment.amount, interest: interest.amount, principal:, additional_payment: @additional_by_period.fetch(index), balloon_payment: @balloon_by_period.fetch(index), interest_only: @interest_only_by_period.fetch(index), closing_balance:, date: payment.date)
276
388
  opening_balance = closing_balance
277
389
  entry
278
390
  end
@@ -280,15 +392,20 @@ module Finrb
280
392
 
281
393
  def build_regular_payment(rate)
282
394
  periods = @periods - @period
283
- amount = Amortization.payment(@balance, rate.monthly, periods, balloon: @balloon)
284
- Payment.new(amount, period: @period).tap do |payment|
395
+ amount =
396
+ if @start_date
397
+ dated_payment(@balance, rate, @period, @balloon)
398
+ else
399
+ Amortization.payment(@balance, rate.monthly, periods, balloon: @balloon)
400
+ end
401
+ Payment.new(amount, period: @period, date: @payment_dates && @payment_dates.fetch(@period)).tap do |payment|
285
402
  payment.modify(&@block) if @block
286
403
  validate_payment!(payment)
287
404
  end
288
405
  end
289
406
 
290
- def build_interest_only_payment(interest)
291
- Payment.new(-interest, period: @period).tap do |payment|
407
+ def build_interest_only_payment(interest, date)
408
+ Payment.new(-interest, period: @period, date:).tap do |payment|
292
409
  payment.modify(&@block) if @block
293
410
  validate_payment!(payment, allow_zero: true)
294
411
  end
@@ -301,5 +418,24 @@ module Finrb
301
418
  requirement = allow_zero ? 'must not produce a positive amount' : 'must produce a negative amount'
302
419
  raise(ArgumentError, "payment modification #{requirement}.")
303
420
  end
421
+
422
+ def dated_period_rate(rate, period_index)
423
+ period = @dated_schedule.periods.fetch(period_index)
424
+ year_fraction = DayCount.year_fraction(period.accrual_start_date, period.payment_date, convention: @day_count)
425
+ periodic_rate = Precision.rate(rate.apr * year_fraction)
426
+ Validation.decimal_greater_than(periodic_rate, minimum: -1, name: 'dated periodic rate')
427
+ end
428
+
429
+ def dated_payment(balance, rate, period_index, balloon)
430
+ periods = (period_index...@periods).map { |index| dated_period_rate(rate, index) }
431
+ discount_factors = []
432
+ growth = Flt::DecNum('1')
433
+ periods.each do |periodic_rate|
434
+ growth *= periodic_rate + 1
435
+ discount_factors << (Flt::DecNum('1') / growth)
436
+ end
437
+ balloon_discounted = balloon * discount_factors.last
438
+ -Precision.money((balance - balloon_discounted) / discount_factors.sum)
439
+ end
304
440
  end
305
441
  end