fundamentalista 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE +21 -0
  4. data/README.md +297 -0
  5. data/lib/fundamentalista/balance_sheet.rb +44 -0
  6. data/lib/fundamentalista/banking.rb +32 -0
  7. data/lib/fundamentalista/cash_flow_statement.rb +20 -0
  8. data/lib/fundamentalista/company.rb +81 -0
  9. data/lib/fundamentalista/comparison.rb +85 -0
  10. data/lib/fundamentalista/configuration.rb +71 -0
  11. data/lib/fundamentalista/dcf.rb +73 -0
  12. data/lib/fundamentalista/decimal.rb +57 -0
  13. data/lib/fundamentalista/error.rb +23 -0
  14. data/lib/fundamentalista/estimate.rb +38 -0
  15. data/lib/fundamentalista/financials.rb +103 -0
  16. data/lib/fundamentalista/income_statement.rb +40 -0
  17. data/lib/fundamentalista/inspectable.rb +26 -0
  18. data/lib/fundamentalista/period.rb +163 -0
  19. data/lib/fundamentalista/provider.rb +70 -0
  20. data/lib/fundamentalista/providers/edgar/facts.rb +172 -0
  21. data/lib/fundamentalista/providers/edgar/tags.rb +116 -0
  22. data/lib/fundamentalista/providers/edgar.rb +109 -0
  23. data/lib/fundamentalista/providers/fmp.rb +109 -0
  24. data/lib/fundamentalista/quote.rb +32 -0
  25. data/lib/fundamentalista/ratios.rb +231 -0
  26. data/lib/fundamentalista/scores/altman_z.rb +69 -0
  27. data/lib/fundamentalista/scores/beneish.rb +112 -0
  28. data/lib/fundamentalista/scores/piotroski.rb +111 -0
  29. data/lib/fundamentalista/serializable.rb +34 -0
  30. data/lib/fundamentalista/source.rb +27 -0
  31. data/lib/fundamentalista/statement.rb +49 -0
  32. data/lib/fundamentalista/valuation.rb +201 -0
  33. data/lib/fundamentalista/version.rb +6 -0
  34. data/lib/fundamentalista.rb +85 -0
  35. metadata +136 -0
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # The financial ratios of one Period. Margins, returns and yields are
5
+ # rates, so 0.25 means 25%; the working capital cycle is in days. Balance
6
+ # sheet denominators use the average of the period and the prior one
7
+ # when a prior period is known, and the period's ending balance
8
+ # otherwise. Every ratio is +nil+ when a figure it needs is missing.
9
+ #
10
+ # ratios = period.ratios
11
+ # ratios.roe # => 0.15e1
12
+ # ratios.current_ratio # => 0.89e0
13
+ # ratios.to_h # every ratio keyed by name
14
+ #
15
+ class Ratios
16
+ include Inspectable
17
+ include Serializable
18
+
19
+ PROFITABILITY = %i[gross_margin operating_margin net_margin ebitda_margin roe roa roic return_on_capital].freeze
20
+ LIQUIDITY = %i[current_ratio quick_ratio cash_ratio].freeze
21
+ LEVERAGE = %i[debt_to_equity debt_to_assets net_debt_to_ebitda interest_coverage equity_multiplier].freeze
22
+ EFFICIENCY = %i[asset_turnover inventory_turnover receivables_turnover].freeze
23
+ WORKING_CAPITAL = %i[days_sales_outstanding days_inventory_outstanding days_payables_outstanding
24
+ cash_conversion_cycle].freeze
25
+ CASH = %i[fcf_margin cash_conversion capex_to_revenue accrual_ratio].freeze
26
+ BANKING = %i[net_interest_margin efficiency_ratio loan_to_deposit cost_of_risk allowance_to_loans].freeze
27
+ ALL = (PROFITABILITY + LIQUIDITY + LEVERAGE + EFFICIENCY + WORKING_CAPITAL + CASH + BANKING).freeze
28
+
29
+ attr_reader :period
30
+
31
+ def initialize(period)
32
+ @period = period
33
+ end
34
+
35
+ def gross_margin
36
+ Decimal.ratio(income.gross_profit, income.revenue)
37
+ end
38
+
39
+ def operating_margin
40
+ Decimal.ratio(income.operating_income, income.revenue)
41
+ end
42
+
43
+ def net_margin
44
+ Decimal.ratio(income.net_income, income.revenue)
45
+ end
46
+
47
+ def ebitda_margin
48
+ Decimal.ratio(income.ebitda, income.revenue)
49
+ end
50
+
51
+ # Return on equity: net income over average equity.
52
+ def roe
53
+ Decimal.ratio(income.net_income, average(:equity))
54
+ end
55
+
56
+ # Return on assets: net income over average total assets.
57
+ def roa
58
+ Decimal.ratio(income.net_income, average(:total_assets))
59
+ end
60
+
61
+ # Return on invested capital: after tax operating income over average
62
+ # debt plus equity.
63
+ def roic
64
+ nopat = income.operating_income && income.tax_rate && (income.operating_income * (1 - income.tax_rate))
65
+ Decimal.ratio(nopat, Decimal.sum(average(:total_debt), average(:equity)))
66
+ end
67
+
68
+ # Greenblatt's return on capital: operating income over net working
69
+ # capital plus net fixed assets.
70
+ def return_on_capital
71
+ Decimal.ratio(income.operating_income, Decimal.sum(average(:net_working_capital), average(:ppe)))
72
+ end
73
+
74
+ def current_ratio
75
+ Decimal.ratio(balance.current_assets, balance.current_liabilities)
76
+ end
77
+
78
+ # Current assets net of inventory over current liabilities.
79
+ def quick_ratio
80
+ Decimal.ratio(Decimal.subtract(balance.current_assets, balance.inventory || BigDecimal('0')), balance.current_liabilities)
81
+ end
82
+
83
+ def cash_ratio
84
+ Decimal.ratio(balance.liquid_assets, balance.current_liabilities)
85
+ end
86
+
87
+ def debt_to_equity
88
+ Decimal.ratio(balance.total_debt, balance.equity)
89
+ end
90
+
91
+ def debt_to_assets
92
+ Decimal.ratio(balance.total_debt, balance.total_assets)
93
+ end
94
+
95
+ def net_debt_to_ebitda
96
+ Decimal.ratio(balance.net_debt, income.ebitda)
97
+ end
98
+
99
+ # Operating income over interest expense.
100
+ def interest_coverage
101
+ Decimal.ratio(income.operating_income, income.interest_expense)
102
+ end
103
+
104
+ # Average total assets over average equity, the leverage leg of DuPont.
105
+ def equity_multiplier
106
+ Decimal.ratio(average(:total_assets), average(:equity))
107
+ end
108
+
109
+ def asset_turnover
110
+ Decimal.ratio(income.revenue, average(:total_assets))
111
+ end
112
+
113
+ def inventory_turnover
114
+ Decimal.ratio(income.cost_of_revenue, average(:inventory))
115
+ end
116
+
117
+ def receivables_turnover
118
+ Decimal.ratio(income.revenue, average(:receivables))
119
+ end
120
+
121
+ # Days of revenue tied up in receivables.
122
+ def days_sales_outstanding
123
+ days(average(:receivables), income.revenue)
124
+ end
125
+
126
+ # Days of cost of revenue held as inventory.
127
+ def days_inventory_outstanding
128
+ days(average(:inventory), income.cost_of_revenue)
129
+ end
130
+
131
+ # Days of cost of revenue owed to suppliers.
132
+ def days_payables_outstanding
133
+ days(average(:payables), income.cost_of_revenue)
134
+ end
135
+
136
+ # Days between paying suppliers and collecting from customers.
137
+ def cash_conversion_cycle
138
+ Decimal.subtract(Decimal.sum(days_sales_outstanding, days_inventory_outstanding), days_payables_outstanding)
139
+ end
140
+
141
+ def fcf_margin
142
+ Decimal.ratio(cash_flow.free_cash_flow, income.revenue)
143
+ end
144
+
145
+ # Operating cash flow over net income; above one means earnings are
146
+ # backed by cash.
147
+ def cash_conversion
148
+ Decimal.ratio(cash_flow.operating_cash_flow, income.net_income)
149
+ end
150
+
151
+ def capex_to_revenue
152
+ Decimal.ratio(cash_flow.capital_expenditure, income.revenue)
153
+ end
154
+
155
+ # Sloan's accrual ratio: earnings not backed by operating cash flow,
156
+ # over average total assets. High values flag low earnings quality.
157
+ def accrual_ratio
158
+ Decimal.ratio(Decimal.subtract(income.net_income, cash_flow.operating_cash_flow), average(:total_assets))
159
+ end
160
+
161
+ # Net interest income over average total assets, the bank's spread.
162
+ def net_interest_margin
163
+ Decimal.ratio(banking.net_interest_income, average(:total_assets))
164
+ end
165
+
166
+ # Noninterest expense over net interest plus noninterest income; lower
167
+ # is leaner.
168
+ def efficiency_ratio
169
+ Decimal.ratio(banking.noninterest_expense, banking.revenue)
170
+ end
171
+
172
+ def loan_to_deposit
173
+ Decimal.ratio(banking.loans, banking.deposits)
174
+ end
175
+
176
+ # Credit provisions over average loans.
177
+ def cost_of_risk
178
+ Decimal.ratio(banking.provision_for_credit_losses, average(:loans, of: :banking))
179
+ end
180
+
181
+ def allowance_to_loans
182
+ Decimal.ratio(banking.allowance_for_credit_losses, banking.loans)
183
+ end
184
+
185
+ # The DuPont decomposition of ROE: net margin times asset turnover times
186
+ # the equity multiplier.
187
+ def dupont
188
+ { net_margin: net_margin, asset_turnover: asset_turnover, equity_multiplier: equity_multiplier, roe: roe }
189
+ end
190
+
191
+ # Returns every ratio keyed by name; the banking ones only for banks.
192
+ def to_h
193
+ names = period.bank? ? ALL : ALL - BANKING
194
+ names.to_h { |name| [name, public_send(name)] }
195
+ end
196
+
197
+ def inspect_attributes # :nodoc:
198
+ to_h
199
+ end
200
+
201
+ private
202
+
203
+ def income
204
+ period.income
205
+ end
206
+
207
+ def balance
208
+ period.balance
209
+ end
210
+
211
+ def cash_flow
212
+ period.cash_flow
213
+ end
214
+
215
+ def banking
216
+ period.banking
217
+ end
218
+
219
+ def average(field, of: :balance)
220
+ current = period.public_send(of).public_send(field)
221
+ return current unless period.prior
222
+
223
+ Decimal.average(current, period.prior.public_send(of).public_send(field))
224
+ end
225
+
226
+ def days(balance_figure, flow)
227
+ turnover = Decimal.ratio(balance_figure, flow)
228
+ turnover && (turnover * period.days)
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Scores
5
+ # The Altman Z-score, the 1968 formulation for listed companies. Above
6
+ # 2.99 is the safe zone, below 1.81 the distress zone, and the grey zone
7
+ # lies in between.
8
+ #
9
+ # z = period.altman_z(market_cap: 4_700_000_000_000)
10
+ # z.score # => 0.9e1
11
+ # z.zone # => :safe
12
+ #
13
+ class AltmanZ
14
+ include Inspectable
15
+ include Serializable
16
+
17
+ SAFE_ABOVE = BigDecimal('2.99')
18
+ DISTRESS_BELOW = BigDecimal('1.81')
19
+
20
+ attr_reader :period, :market_cap
21
+
22
+ def initialize(period, market_cap:)
23
+ @period = period
24
+ @market_cap = Decimal.wrap(market_cap)
25
+ end
26
+
27
+ # The score, or +nil+ when a component is missing.
28
+ def score
29
+ weighted = components.zip(WEIGHTS).map { |value, weight| value && (value * weight) }
30
+ Decimal.sum(*weighted)
31
+ end
32
+
33
+ # +:safe+, +:grey+, +:distress+, or +nil+ without a score.
34
+ def zone
35
+ return nil unless score
36
+
37
+ if score > SAFE_ABOVE then :safe
38
+ elsif score < DISTRESS_BELOW then :distress
39
+ else :grey
40
+ end
41
+ end
42
+
43
+ def to_h
44
+ { score: score, zone: zone }
45
+ end
46
+
47
+ def inspect_attributes # :nodoc:
48
+ to_h
49
+ end
50
+
51
+ private
52
+
53
+ WEIGHTS = [BigDecimal('1.2'), BigDecimal('1.4'), BigDecimal('3.3'), BigDecimal('0.6'), BigDecimal('1.0')].freeze
54
+ private_constant :WEIGHTS
55
+
56
+ def components
57
+ balance = period.balance
58
+ assets = balance.total_assets
59
+ [
60
+ Decimal.ratio(balance.working_capital, assets),
61
+ Decimal.ratio(balance.retained_earnings, assets),
62
+ Decimal.ratio(period.income.operating_income, assets),
63
+ Decimal.ratio(market_cap, balance.total_liabilities),
64
+ Decimal.ratio(period.income.revenue, assets)
65
+ ]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Scores
5
+ # The Beneish M-score, the eight variable model that flags earnings
6
+ # likely to be manipulated. Every index compares the period with the
7
+ # prior one, so it needs a prior period. Above -1.78 is the warning
8
+ # zone.
9
+ #
10
+ # m = period.beneish
11
+ # m.score # => -0.25e1
12
+ # m.likely_manipulator? # => false
13
+ # m.indices # => {dsri: ..., gmi: ..., ...}
14
+ #
15
+ class Beneish
16
+ include Inspectable
17
+ include Serializable
18
+
19
+ THRESHOLD = BigDecimal('-1.78')
20
+ INTERCEPT = BigDecimal('-4.84')
21
+ WEIGHTS = {
22
+ dsri: BigDecimal('0.92'), gmi: BigDecimal('0.528'), aqi: BigDecimal('0.404'), sgi: BigDecimal('0.892'),
23
+ depi: BigDecimal('0.115'), sgai: BigDecimal('-0.172'), tata: BigDecimal('4.679'), lvgi: BigDecimal('-0.327')
24
+ }.freeze
25
+
26
+ attr_reader :period
27
+
28
+ def initialize(period)
29
+ @period = period
30
+ end
31
+
32
+ # The score, or +nil+ without a prior period or a component.
33
+ def score
34
+ weighted = WEIGHTS.map { |index, weight| indices[index] && (indices[index] * weight) }
35
+ Decimal.sum(INTERCEPT, *weighted)
36
+ end
37
+
38
+ # Whether the score sits above the warning threshold; +nil+ without
39
+ # a score.
40
+ def likely_manipulator?
41
+ score && score > THRESHOLD
42
+ end
43
+
44
+ # The eight indices keyed by their usual initials.
45
+ def indices
46
+ @indices ||= { dsri: dsri, gmi: gmi, aqi: aqi, sgi: sgi, depi: depi, sgai: sgai, tata: tata, lvgi: lvgi }
47
+ end
48
+
49
+ def to_h
50
+ indices.merge(score: score, likely_manipulator: likely_manipulator?)
51
+ end
52
+
53
+ def inspect_attributes # :nodoc:
54
+ { score: score, likely_manipulator: likely_manipulator? }
55
+ end
56
+
57
+ private
58
+
59
+ def prior
60
+ period.prior
61
+ end
62
+
63
+ def dsri
64
+ change { |candidate| Decimal.ratio(candidate.balance.receivables, candidate.income.revenue) }
65
+ end
66
+
67
+ def gmi
68
+ change(inverted: true) { |candidate| candidate.ratios.gross_margin }
69
+ end
70
+
71
+ def aqi
72
+ change do |candidate|
73
+ balance = candidate.balance
74
+ hard = Decimal.sum(balance.current_assets, balance.ppe)
75
+ hard && balance.total_assets && (1 - (hard / balance.total_assets))
76
+ end
77
+ end
78
+
79
+ def sgi
80
+ change { |candidate| candidate.income.revenue }
81
+ end
82
+
83
+ def depi
84
+ change(inverted: true) do |candidate|
85
+ depreciation = candidate.income.depreciation_amortization
86
+ Decimal.ratio(depreciation, Decimal.sum(depreciation, candidate.balance.ppe))
87
+ end
88
+ end
89
+
90
+ def sgai
91
+ change { |candidate| Decimal.ratio(candidate.income.sga, candidate.income.revenue) }
92
+ end
93
+
94
+ def tata
95
+ Decimal.ratio(Decimal.subtract(period.income.net_income, period.cash_flow.operating_cash_flow),
96
+ period.balance.total_assets)
97
+ end
98
+
99
+ def lvgi
100
+ change { |candidate| Decimal.ratio(candidate.balance.total_liabilities, candidate.balance.total_assets) }
101
+ end
102
+
103
+ def change(inverted: false)
104
+ return nil unless prior
105
+
106
+ current = yield(period)
107
+ previous = yield(prior)
108
+ inverted ? Decimal.ratio(previous, current) : Decimal.ratio(current, previous)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Scores
5
+ # The Piotroski F-score: nine yes-or-no signals of profitability,
6
+ # leverage and efficiency, scored from 0 to 9. Six of them compare the
7
+ # period with the prior one, so a period without a prior scores on the
8
+ # remaining three and reports the others as +nil+.
9
+ #
10
+ # score = period.piotroski
11
+ # score.score # => 7
12
+ # score.signals # => {positive_income: true, positive_cash_flow: true, ...}
13
+ #
14
+ class Piotroski
15
+ include Inspectable
16
+ include Serializable
17
+
18
+ SIGNALS = %i[positive_income positive_cash_flow improving_roa cash_exceeds_income
19
+ falling_leverage improving_liquidity no_dilution improving_margin improving_turnover].freeze
20
+
21
+ attr_reader :period
22
+
23
+ def initialize(period)
24
+ @period = period
25
+ end
26
+
27
+ # The number of signals that hold, 0 to 9.
28
+ def score
29
+ signals.values.count(true)
30
+ end
31
+
32
+ # Every signal keyed by name: +true+, +false+, or +nil+ when it cannot
33
+ # be evaluated.
34
+ def signals
35
+ SIGNALS.to_h { |signal| [signal, send(:"#{signal}?")] }
36
+ end
37
+
38
+ def to_h
39
+ signals.merge(score: score)
40
+ end
41
+
42
+ def inspect_attributes # :nodoc:
43
+ { score: score }
44
+ end
45
+
46
+ private
47
+
48
+ def positive_income?
49
+ positive(period.income.net_income)
50
+ end
51
+
52
+ def positive_cash_flow?
53
+ positive(period.cash_flow.operating_cash_flow)
54
+ end
55
+
56
+ def improving_roa?
57
+ improvement { |candidate| candidate.ratios.roa }
58
+ end
59
+
60
+ def cash_exceeds_income?
61
+ above(period.cash_flow.operating_cash_flow, period.income.net_income)
62
+ end
63
+
64
+ def falling_leverage?
65
+ improvement(direction: :down) do |candidate|
66
+ Decimal.ratio(candidate.balance.long_term_debt, candidate.balance.total_assets)
67
+ end
68
+ end
69
+
70
+ def improving_liquidity?
71
+ improvement { |candidate| candidate.ratios.current_ratio }
72
+ end
73
+
74
+ def no_dilution?
75
+ return nil unless prior
76
+
77
+ above(prior.balance.shares_outstanding, period.balance.shares_outstanding, or_equal: true)
78
+ end
79
+
80
+ def improving_margin?
81
+ improvement { |candidate| candidate.ratios.gross_margin }
82
+ end
83
+
84
+ def improving_turnover?
85
+ improvement { |candidate| candidate.ratios.asset_turnover }
86
+ end
87
+
88
+ def prior
89
+ period.prior
90
+ end
91
+
92
+ def positive(value)
93
+ value&.positive?
94
+ end
95
+
96
+ def above(left, right, or_equal: false)
97
+ return nil if left.nil? || right.nil?
98
+
99
+ or_equal ? left >= right : left > right
100
+ end
101
+
102
+ def improvement(direction: :up)
103
+ return nil unless prior
104
+
105
+ current = yield(period)
106
+ previous = yield(prior)
107
+ direction == :up ? above(current, previous) : above(previous, current)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Turns a value object into plain data. #to_h keeps every figure a
5
+ # BigDecimal; #as_json turns figures into Floats and dates into ISO
6
+ # strings, which is what JSON consumers expect, so +to_json+ and Rails'
7
+ # +render json:+ work out of the box.
8
+ module Serializable
9
+ def as_json(*)
10
+ Serializable.plain(to_h)
11
+ end
12
+
13
+ def to_json(*args)
14
+ JSON.generate(as_json, *args)
15
+ end
16
+
17
+ def self.plain(value) # :nodoc:
18
+ case value
19
+ when Hash then value.transform_values { |inner| plain(inner) }
20
+ when Array then value.map { |inner| plain(inner) }
21
+ when Serializable then value.as_json
22
+ else scalar(value)
23
+ end
24
+ end
25
+
26
+ def self.scalar(value) # :nodoc:
27
+ case value
28
+ when BigDecimal then value.to_f
29
+ when Date, Time then value.iso8601
30
+ else value
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Where a line item came from: the XBRL concept, the filing that
5
+ # reported it, and whether the figure was reported as is, summed from
6
+ # split concepts, or derived from year to date values.
7
+ class Source
8
+ include Inspectable
9
+
10
+ attr_reader :concept, :form, :filed, :derivation
11
+
12
+ def initialize(concept:, form:, filed:, derivation: :reported)
13
+ @concept = concept
14
+ @form = form
15
+ @filed = filed
16
+ @derivation = derivation
17
+ end
18
+
19
+ def reported?
20
+ derivation == :reported
21
+ end
22
+
23
+ def inspect_attributes # :nodoc:
24
+ { concept: concept, form: form, filed: filed, derivation: derivation }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Base class of the three financial statements. Subclasses declare their
5
+ # line items with ::field; every reader returns a BigDecimal or +nil+.
6
+ class Statement
7
+ include Inspectable
8
+ include Serializable
9
+
10
+ class << self
11
+ # Declares line items. Each becomes a reader and a keyword argument
12
+ # of ::new.
13
+ def field(*names)
14
+ names.each do |name|
15
+ fields << name
16
+ attr_reader name
17
+ end
18
+ end
19
+
20
+ # The declared line items, in order.
21
+ def fields
22
+ @fields ||= []
23
+ end
24
+
25
+ # Returns a statement adding up the reported items of +statements+,
26
+ # as the trailing twelve months does with four quarters. An item is
27
+ # +nil+ when any statement lacks it.
28
+ def sum(statements)
29
+ new(**fields.to_h { |field| [field, Decimal.sum(*statements.map { |statement| statement.public_send(field) })] })
30
+ end
31
+ end
32
+
33
+ def initialize(**values)
34
+ unknown = values.keys - self.class.fields
35
+ raise ArgumentError, "Unknown #{self.class.name} fields: #{unknown.join(', ')}" if unknown.any?
36
+
37
+ self.class.fields.each { |field| instance_variable_set(:"@#{field}", Decimal.wrap(values[field])) }
38
+ end
39
+
40
+ # Returns every line item, derived ones included, keyed by name.
41
+ def to_h
42
+ self.class.fields.to_h { |field| [field, public_send(field)] }
43
+ end
44
+
45
+ def inspect_attributes # :nodoc:
46
+ to_h
47
+ end
48
+ end
49
+ end