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 +4 -4
- data/CHANGELOG.md +31 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +79 -8
- data/lib/finrb/amortization.rb +161 -25
- data/lib/finrb/calendars/base.rb +161 -0
- data/lib/finrb/calendars/hebrew_calendar.rb +77 -0
- data/lib/finrb/calendars/israel_tase.rb +96 -0
- data/lib/finrb/calendars/us_federal_reserve.rb +82 -0
- data/lib/finrb/calendars.rb +6 -0
- data/lib/finrb/day_count.rb +62 -0
- data/lib/finrb/fixed_rate_bond.rb +140 -0
- data/lib/finrb/schedule.rb +126 -0
- data/lib/finrb/version.rb +1 -1
- data/lib/finrb.rb +4 -0
- data/sig/finrb.rbs +112 -3
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a5f20ce5e17a03b0043df557ea5ea2207eb15e286e8f80664ef436dadc6cbce
|
|
4
|
+
data.tar.gz: de4773bdabd2131fcfcf4be29cc9befecf47fd92353e308d7cdae5569d7c914e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
20
|
-
examples, and validates the packaged RBS declarations.
|
|
21
|
-
|
|
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,
|
|
270
|
-
IRR/XIRR properties, committed SciPy/QuantLib reference fixtures,
|
|
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
|
|
325
|
-
|
|
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
|
|
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
|
|
data/lib/finrb/amortization.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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.
|
|
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]
|
|
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 =
|
|
284
|
-
|
|
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
|