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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +297 -0
- data/lib/fundamentalista/balance_sheet.rb +44 -0
- data/lib/fundamentalista/banking.rb +32 -0
- data/lib/fundamentalista/cash_flow_statement.rb +20 -0
- data/lib/fundamentalista/company.rb +81 -0
- data/lib/fundamentalista/comparison.rb +85 -0
- data/lib/fundamentalista/configuration.rb +71 -0
- data/lib/fundamentalista/dcf.rb +73 -0
- data/lib/fundamentalista/decimal.rb +57 -0
- data/lib/fundamentalista/error.rb +23 -0
- data/lib/fundamentalista/estimate.rb +38 -0
- data/lib/fundamentalista/financials.rb +103 -0
- data/lib/fundamentalista/income_statement.rb +40 -0
- data/lib/fundamentalista/inspectable.rb +26 -0
- data/lib/fundamentalista/period.rb +163 -0
- data/lib/fundamentalista/provider.rb +70 -0
- data/lib/fundamentalista/providers/edgar/facts.rb +172 -0
- data/lib/fundamentalista/providers/edgar/tags.rb +116 -0
- data/lib/fundamentalista/providers/edgar.rb +109 -0
- data/lib/fundamentalista/providers/fmp.rb +109 -0
- data/lib/fundamentalista/quote.rb +32 -0
- data/lib/fundamentalista/ratios.rb +231 -0
- data/lib/fundamentalista/scores/altman_z.rb +69 -0
- data/lib/fundamentalista/scores/beneish.rb +112 -0
- data/lib/fundamentalista/scores/piotroski.rb +111 -0
- data/lib/fundamentalista/serializable.rb +34 -0
- data/lib/fundamentalista/source.rb +27 -0
- data/lib/fundamentalista/statement.rb +49 -0
- data/lib/fundamentalista/valuation.rb +201 -0
- data/lib/fundamentalista/version.rb +6 -0
- data/lib/fundamentalista.rb +85 -0
- 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
|