fundamentalista-rails 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 (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE +21 -0
  4. data/README.md +68 -0
  5. data/app/controllers/fundamentalista_rails/application_controller.rb +26 -0
  6. data/app/controllers/fundamentalista_rails/companies_controller.rb +18 -0
  7. data/app/controllers/fundamentalista_rails/comparisons_controller.rb +18 -0
  8. data/app/controllers/fundamentalista_rails/holdings_controller.rb +20 -0
  9. data/app/controllers/fundamentalista_rails/snapshots_controller.rb +12 -0
  10. data/app/controllers/fundamentalista_rails/watchlists_controller.rb +27 -0
  11. data/app/helpers/fundamentalista_rails/figures_helper.rb +44 -0
  12. data/app/models/fundamentalista_rails/application_record.rb +8 -0
  13. data/app/models/fundamentalista_rails/holding.rb +28 -0
  14. data/app/models/fundamentalista_rails/snapshot.rb +18 -0
  15. data/app/models/fundamentalista_rails/watchlist.rb +10 -0
  16. data/app/views/fundamentalista_rails/companies/index.html.erb +7 -0
  17. data/app/views/fundamentalista_rails/companies/show.html.erb +36 -0
  18. data/app/views/fundamentalista_rails/comparisons/show.html.erb +41 -0
  19. data/app/views/fundamentalista_rails/shared/_ratios.html.erb +23 -0
  20. data/app/views/fundamentalista_rails/shared/_scores.html.erb +16 -0
  21. data/app/views/fundamentalista_rails/shared/_series.html.erb +17 -0
  22. data/app/views/fundamentalista_rails/shared/_valuation.html.erb +16 -0
  23. data/app/views/fundamentalista_rails/watchlists/index.html.erb +19 -0
  24. data/app/views/fundamentalista_rails/watchlists/show.html.erb +29 -0
  25. data/app/views/layouts/fundamentalista_rails/application.html.erb +45 -0
  26. data/config/routes.rb +12 -0
  27. data/db/migrate/20260906000000_create_fundamentalista_rails_tables.rb +27 -0
  28. data/lib/fundamentalista-rails.rb +17 -0
  29. data/lib/fundamentalista_rails/cache.rb +19 -0
  30. data/lib/fundamentalista_rails/engine.rb +19 -0
  31. data/lib/fundamentalista_rails/version.rb +6 -0
  32. metadata +105 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 301e35254de56b06adcdbb3e168c70998e63ef0b22141c016e03206744929101
4
+ data.tar.gz: 715976e46da9f92cf579a9d546d91262c9712a157e23ff1ff9e96388dbd44445
5
+ SHA512:
6
+ metadata.gz: f35926ae82acf6835a76aa04672e668228874d6a925d9484c0d170a07fdea270507a3c54e248563ba3d6c5ebb0a1bd7c3f9543e42055ccf761df9731eacabeab
7
+ data.tar.gz: b47411050fea5cdb894eae1ef9920aebccce3078fc1b3a3eb92089bbc2a8a9b6cb6c5f0cb38f9e396a3960203e64e79c894474883c5c3d295d1cb5281b7ab82d
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Mountable engine with company pages, peer comparison with the magic formula, and watchlists.
6
+ - Holdings keep a price and take snapshots of ratios, valuation and scores, so a valuation history accumulates.
7
+ - The fundamentalista gem uses the application's cache store by default, one day per provider response.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Costanzo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # fundamentalista-rails
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/fundamentalista-rails.svg)](https://rubygems.org/gems/fundamentalista-rails) [![CI](https://github.com/bruno-costanzo/fundamentalista-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/bruno-costanzo/fundamentalista-rails/actions/workflows/ci.yml)
4
+
5
+ Fundamental analysis pages for your Rails application, on top of the [fundamentalista](https://github.com/bruno-costanzo/fundamentalista) gem. Mount the engine and you get company pages with statements, ratios, scores and valuation, peer comparison with the magic formula, and watchlists whose valuation snapshots build a history over time.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "fundamentalista-rails"
11
+ ```
12
+
13
+ ```bash
14
+ bin/rails fundamentalista_rails:install:migrations
15
+ bin/rails db:migrate
16
+ ```
17
+
18
+ Mount the engine and configure the data source. The SEC asks for a User-Agent naming your application and a contact email; Financial Modeling Prep needs an API key.
19
+
20
+ ```ruby
21
+ # config/routes.rb
22
+ mount FundamentalistaRails::Engine, at: "/fundamentals"
23
+
24
+ # config/initializers/fundamentalista.rb
25
+ Fundamentalista.configure do |config|
26
+ config.edgar_user_agent = "Acme Research research@acme.com"
27
+ config.fmp_api_key = ENV["FMP_API_KEY"] # optional
28
+ end
29
+ ```
30
+
31
+ The engine points the gem at `Rails.cache` with a one day expiry unless you set `config.cache` yourself, so a company page costs one download and a watchlist does not hammer the SEC. Put the engine behind whatever authentication your application uses; it ships none.
32
+
33
+ ## Pages
34
+
35
+ | Path | What it does |
36
+ | --- | --- |
37
+ | `/fundamentals` | Look a ticker up |
38
+ | `/fundamentals/companies/AAPL` | Five years of statements with growth, every ratio, Piotroski, Beneish and owner earnings |
39
+ | `/fundamentals/companies/AAPL?price=320` | The same, plus multiples, yields, Graham number, Altman Z-score, intrinsic value and implied growth at that price |
40
+ | `/fundamentals/comparison?tickers=AAPL+MSFT+KO` | Peers side by side, with prices for the multiples, ranked by the magic formula |
41
+ | `/fundamentals/watchlists` | Named lists of companies, each holding with the price you value it at |
42
+
43
+ The pages are server rendered with a small stylesheet of their own, so they fit any application and need no assets, JavaScript or Tailwind.
44
+
45
+ ## Watchlists and snapshots
46
+
47
+ A watchlist holds companies with a price. Taking a snapshot of a holding stores that day's ratios, valuation and scores as JSON on a `Snapshot` record, so looking at a company regularly leaves you with its valuation history, which the underlying data sources never give you for free.
48
+
49
+ ```ruby
50
+ holding = FundamentalistaRails::Watchlist.find_by(name: "Quality").holdings.find_by(ticker: "AAPL")
51
+ holding.snapshot!
52
+ holding.snapshots.map { |snapshot| [snapshot.taken_on, snapshot.dig(:valuation, :pe)] }
53
+ ```
54
+
55
+ The models are plain Active Record and yours to extend: schedule `snapshot!` from a job to build the history without visiting the page.
56
+
57
+ ## Development
58
+
59
+ ```bash
60
+ bundle install
61
+ bundle exec rake # request specs against a dummy app, and RuboCop
62
+ ```
63
+
64
+ Specs stub the SEC with recorded responses; no keys are needed.
65
+
66
+ ## License
67
+
68
+ MIT.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Base controller of the engine: turns the gem's errors into flash
5
+ # messages and reads prices from parameters.
6
+ class ApplicationController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+
9
+ rescue_from Fundamentalista::CompanyNotFoundError, Fundamentalista::NoFinancialsError,
10
+ Fundamentalista::UnsupportedPeriodError do |error|
11
+ redirect_to root_path, alert: error.message
12
+ end
13
+
14
+ rescue_from Fundamentalista::ConfigurationError do |error|
15
+ render plain: error.message, status: :internal_server_error
16
+ end
17
+
18
+ private
19
+
20
+ def price_param(value = params[:price])
21
+ value.presence && BigDecimal(value.to_s)
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # The search box and the company page.
5
+ class CompaniesController < ApplicationController
6
+ def index
7
+ redirect_to company_path(params[:ticker].to_s.strip) if params[:ticker].present?
8
+ end
9
+
10
+ def show
11
+ @company = Fundamentalista.company(params[:ticker])
12
+ @financials = @company.financials(limit: 5)
13
+ @year = @company.latest_period
14
+ @price = price_param
15
+ @valuation = @company.valuation(price: @price) if @price
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Peers side by side, ranked by the magic formula.
5
+ class ComparisonsController < ApplicationController
6
+ METRICS = %i[pe fcf_yield ebit_yield roe return_on_capital net_margin debt_to_equity piotroski altman_z beneish].freeze
7
+
8
+ def show
9
+ @tickers = params[:tickers].to_s.split(/[\s,]+/).map(&:upcase).compact_blank
10
+ return if @tickers.empty?
11
+
12
+ @prices = @tickers.index_with { |ticker| price_param(params.dig(:prices, ticker)) }.compact
13
+ @comparison = Fundamentalista.compare(*@tickers, prices: @prices)
14
+ @table = @comparison.table(*METRICS)
15
+ @magic_formula = @comparison.magic_formula
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Adds companies to a watchlist and removes them.
5
+ class HoldingsController < ApplicationController
6
+ def create
7
+ watchlist = Watchlist.find(params[:watchlist_id])
8
+ holding = watchlist.holdings.create!(params.require(:holding).permit(:ticker, :price))
9
+ redirect_to watchlist, notice: "#{holding.ticker} added"
10
+ rescue ActiveRecord::RecordInvalid => e
11
+ redirect_to watchlist, alert: e.message
12
+ end
13
+
14
+ def destroy
15
+ holding = Holding.find(params[:id])
16
+ holding.destroy!
17
+ redirect_to holding.watchlist, notice: "#{holding.ticker} removed"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Takes a valuation snapshot of a holding.
5
+ class SnapshotsController < ApplicationController
6
+ def create
7
+ holding = Holding.find(params[:holding_id])
8
+ holding.snapshot!
9
+ redirect_to holding.watchlist, notice: "Snapshot of #{holding.ticker} taken"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Lists, creates and removes watchlists.
5
+ class WatchlistsController < ApplicationController
6
+ def index
7
+ @watchlists = Watchlist.order(:name)
8
+ @watchlist = Watchlist.new
9
+ end
10
+
11
+ def show
12
+ @watchlist = Watchlist.find(params[:id])
13
+ @holdings = @watchlist.holdings.includes(:snapshots).order(:ticker)
14
+ @holding = @holdings.build
15
+ end
16
+
17
+ def create
18
+ watchlist = Watchlist.create!(params.require(:watchlist).permit(:name))
19
+ redirect_to watchlist
20
+ end
21
+
22
+ def destroy
23
+ Watchlist.find(params[:id]).destroy!
24
+ redirect_to watchlists_path, notice: 'Watchlist removed'
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Formats the gem's figures for the page: rates as percentages, amounts
5
+ # in millions of the reporting currency, multiples with one decimal,
6
+ # and a dash where the filing has no figure.
7
+ module FiguresHelper
8
+ DASH = '–'
9
+ ACRONYMS = %w[roe roa roic ebitda ebit fcf pe ps pb ev peg sga capex eps].freeze
10
+
11
+ # Turns a metric name into a label, keeping the acronyms upper case.
12
+ def label(name)
13
+ name.to_s.humanize.gsub(/\b(#{ACRONYMS.join('|')})\b/i, &:upcase)
14
+ end
15
+
16
+ def percent(value)
17
+ value ? format('%.1f%%', value.to_f * 100) : DASH
18
+ end
19
+
20
+ def multiple(value)
21
+ value ? format('%.1fx', value.to_f) : DASH
22
+ end
23
+
24
+ def amount(value, currency = 'USD')
25
+ value ? "#{number_with_delimiter((value.to_f / 1_000_000).round)} M #{currency}" : DASH
26
+ end
27
+
28
+ def number(value, decimals = 2)
29
+ value ? number_with_precision(value.to_f, precision: decimals, delimiter: ',') : DASH
30
+ end
31
+
32
+ def days(value)
33
+ value ? "#{value.to_f.round} d" : DASH
34
+ end
35
+
36
+ def signal(value)
37
+ case value
38
+ when true then '✓'
39
+ when false then '✗'
40
+ else DASH
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Base record of the engine's tables.
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # One company on a Watchlist, with the price used for its valuation and
5
+ # the Snapshots taken of it over time.
6
+ class Holding < ApplicationRecord
7
+ belongs_to :watchlist
8
+ has_many :snapshots, -> { order(taken_on: :desc) }, dependent: :destroy, inverse_of: :holding
9
+
10
+ validates :ticker, presence: true, uniqueness: { scope: :watchlist_id }
11
+
12
+ before_validation { self.ticker = ticker.to_s.upcase.tr('.', '-') }
13
+
14
+ # The Company behind the ticker, through the default provider.
15
+ def company
16
+ @company ||= Fundamentalista.company(ticker)
17
+ end
18
+
19
+ # Takes a Snapshot of the latest annual period at the holding's price.
20
+ def snapshot!
21
+ valuation = company.valuation(price: price)
22
+ snapshots.create!(taken_on: Date.current, price: price,
23
+ figures: { ratios: valuation.period.ratios.as_json, valuation: valuation.as_json,
24
+ scores: { piotroski: valuation.period.piotroski.score, altman_z: valuation.altman_z.as_json,
25
+ beneish: valuation.period.beneish.score&.to_f } })
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # The ratios, valuation and scores of a Holding on one day, so the
5
+ # history of a company's valuation accumulates as you look at it.
6
+ class Snapshot < ApplicationRecord
7
+ belongs_to :holding
8
+
9
+ attribute :figures, :json
10
+
11
+ validates :taken_on, :figures, presence: true
12
+
13
+ # Returns a figure by path, such as +:valuation, :pe+.
14
+ def dig(*path)
15
+ figures.dig(*path.map(&:to_s))
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # A named list of companies to follow, each with the price you value it at.
5
+ class Watchlist < ApplicationRecord
6
+ has_many :holdings, dependent: :destroy
7
+
8
+ validates :name, presence: true
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ <% content_for :title, "Look up a company" %>
2
+ <h1>Look up a company</h1>
3
+ <p class="muted">Financial statements, ratios, scores and valuation from the SEC's EDGAR or Financial Modeling Prep.</p>
4
+ <%= form_with url: root_path, method: :get, class: "inline" do |form| %>
5
+ <%= form.text_field :ticker, placeholder: "AAPL", "aria-label": "Ticker", autofocus: true %>
6
+ <%= form.button "Look up" %>
7
+ <% end %>
@@ -0,0 +1,36 @@
1
+ <% content_for :title, @company.ticker %>
2
+ <h1><%= @company.name %> <span class="muted"><%= @company.ticker %></span></h1>
3
+ <p class="muted">
4
+ Fiscal <%= @year.fiscal_year %>, ended <%= @year.ended_on %>, in <%= @year.currency %>.
5
+ <% if @company.cik %>CIK <%= @company.cik %>.<% end %>
6
+ <% if @year.bank? %>Bank.<% end %>
7
+ </p>
8
+
9
+ <%= form_with url: company_path(@company.ticker), method: :get, class: "inline" do |form| %>
10
+ <%= form.number_field :price, value: @price, step: "any", placeholder: "Price", "aria-label": "Price" %>
11
+ <%= form.button "Value at this price" %>
12
+ <% end %>
13
+
14
+ <div class="grid">
15
+ <section>
16
+ <h2>Statements</h2>
17
+ <%= render "fundamentalista_rails/shared/series", financials: @financials, currency: @year.currency %>
18
+ </section>
19
+
20
+ <section>
21
+ <h2>Ratios</h2>
22
+ <%= render "fundamentalista_rails/shared/ratios", ratios: @year.ratios, bank: @year.bank? %>
23
+ </section>
24
+
25
+ <section>
26
+ <h2>Scores</h2>
27
+ <%= render "fundamentalista_rails/shared/scores", period: @year, valuation: @valuation %>
28
+ </section>
29
+
30
+ <% if @valuation %>
31
+ <section>
32
+ <h2>Valuation at <%= number(@price) %></h2>
33
+ <%= render "fundamentalista_rails/shared/valuation", valuation: @valuation %>
34
+ </section>
35
+ <% end %>
36
+ </div>
@@ -0,0 +1,41 @@
1
+ <% content_for :title, "Compare" %>
2
+ <h1>Compare companies</h1>
3
+ <%= form_with url: comparison_path, method: :get, class: "inline" do |form| %>
4
+ <%= form.text_field :tickers, value: @tickers&.join(" "), placeholder: "AAPL MSFT KO", "aria-label": "Tickers", size: 30 %>
5
+ <%= form.button "Compare" %>
6
+ <% end %>
7
+ <% if @tickers.present? %>
8
+ <p class="muted">Prices, for the valuation multiples:</p>
9
+ <%= form_with url: comparison_path, method: :get, class: "inline" do |form| %>
10
+ <%= form.hidden_field :tickers, value: @tickers.join(" ") %>
11
+ <% @tickers.each do |ticker| %>
12
+ <label><%= ticker %> <%= form.number_field "prices[#{ticker}]", value: @prices[ticker], step: "any", size: 8 %></label>
13
+ <% end %>
14
+ <%= form.button "Value" %>
15
+ <% end %>
16
+ <% end %>
17
+ <% if @table %>
18
+ <h2>Side by side</h2>
19
+ <table>
20
+ <thead><tr><th></th><% @table.each_key do |ticker| %><th><%= link_to ticker, company_path(ticker, price: @prices[ticker]) %></th><% end %></tr></thead>
21
+ <tbody>
22
+ <% FundamentalistaRails::ComparisonsController::METRICS.each do |metric| %>
23
+ <tr>
24
+ <td><%= label(metric) %></td>
25
+ <% @table.each_value do |row| %>
26
+ <% value = row[metric] %>
27
+ <td>
28
+ <% case metric
29
+ when :pe, :debt_to_equity then %><%= multiple(value) %><%
30
+ when :piotroski then %><%= value || "–" %><%
31
+ when :altman_z, :beneish then %><%= number(value) %><%
32
+ else %><%= percent(value) %><% end %>
33
+ </td>
34
+ <% end %>
35
+ </tr>
36
+ <% end %>
37
+ </tbody>
38
+ </table>
39
+ <h2>Magic formula</h2>
40
+ <p>Greenblatt's rank, best first: <strong><%= @magic_formula.join(" › ") %></strong></p>
41
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <% groups = { "Profitability" => Fundamentalista::Ratios::PROFITABILITY, "Liquidity" => Fundamentalista::Ratios::LIQUIDITY,
2
+ "Leverage" => Fundamentalista::Ratios::LEVERAGE, "Efficiency" => Fundamentalista::Ratios::EFFICIENCY,
3
+ "Working capital" => Fundamentalista::Ratios::WORKING_CAPITAL, "Cash" => Fundamentalista::Ratios::CASH } %>
4
+ <% groups["Banking"] = Fundamentalista::Ratios::BANKING if bank %>
5
+ <% rates = Fundamentalista::Ratios::PROFITABILITY + %i[debt_to_assets fcf_margin capex_to_revenue accrual_ratio net_interest_margin efficiency_ratio cost_of_risk allowance_to_loans loan_to_deposit] %>
6
+ <table>
7
+ <tbody>
8
+ <% groups.each do |group, names| %>
9
+ <tr><th colspan="2" style="text-align:left"><%= group %></th></tr>
10
+ <% names.each do |name| %>
11
+ <% value = ratios.public_send(name) %>
12
+ <tr>
13
+ <td><%= label(name) %></td>
14
+ <td>
15
+ <% if Fundamentalista::Ratios::WORKING_CAPITAL.include?(name) %><%= days(value) %>
16
+ <% elsif rates.include?(name) %><%= percent(value) %>
17
+ <% else %><%= multiple(value) %><% end %>
18
+ </td>
19
+ </tr>
20
+ <% end %>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
@@ -0,0 +1,16 @@
1
+ <% piotroski = period.piotroski %>
2
+ <% beneish = period.beneish %>
3
+ <table>
4
+ <tbody>
5
+ <tr><td>Piotroski F-score</td><td><strong><%= piotroski.score %></strong> / 9</td></tr>
6
+ <% piotroski.signals.each do |name, value| %>
7
+ <tr><td class="muted">&nbsp;&nbsp;<%= label(name) %></td><td><%= signal(value) %></td></tr>
8
+ <% end %>
9
+ <tr><td>Beneish M-score</td><td><%= number(beneish.score) %> <span class="muted"><%= beneish.likely_manipulator? ? "flagged" : (beneish.score ? "clear" : "") %></span></td></tr>
10
+ <% if valuation %>
11
+ <% z = valuation.altman_z %>
12
+ <tr><td>Altman Z-score</td><td><%= number(z.score) %> <span class="zone-<%= z.zone %>"><%= z.zone %></span></td></tr>
13
+ <% end %>
14
+ <tr><td>Owner earnings</td><td><%= amount(period.owner_earnings, period.currency) %></td></tr>
15
+ </tbody>
16
+ </table>
@@ -0,0 +1,17 @@
1
+ <% metrics = { revenue: "Revenue", operating_income: "Operating income", net_income: "Net income", free_cash_flow: "Free cash flow", equity: "Equity", total_debt: "Total debt", eps_diluted: "Diluted EPS" } %>
2
+ <table>
3
+ <thead>
4
+ <tr><th></th><% financials.each do |period| %><th><%= period.fiscal_year %></th><% end %><th>CAGR</th></tr>
5
+ </thead>
6
+ <tbody>
7
+ <% metrics.each do |metric, label| %>
8
+ <tr>
9
+ <td><%= label %></td>
10
+ <% financials.each do |period| %>
11
+ <td><%= metric == :eps_diluted ? number(period.metric(metric)) : amount(period.metric(metric), currency) %></td>
12
+ <% end %>
13
+ <td><%= percent(financials.growth(metric)) %></td>
14
+ </tr>
15
+ <% end %>
16
+ </tbody>
17
+ </table>
@@ -0,0 +1,16 @@
1
+ <table>
2
+ <tbody>
3
+ <tr><td>Market cap</td><td><%= amount(valuation.market_cap, valuation.period.currency) %></td></tr>
4
+ <tr><td>Enterprise value</td><td><%= amount(valuation.enterprise_value, valuation.period.currency) %></td></tr>
5
+ <% Fundamentalista::Valuation::MULTIPLES.each do |name| %>
6
+ <tr><td><%= label(name) %></td><td><%= multiple(valuation.public_send(name)) %></td></tr>
7
+ <% end %>
8
+ <% Fundamentalista::Valuation::YIELDS.each do |name| %>
9
+ <tr><td><%= label(name) %></td><td><%= percent(valuation.public_send(name)) %></td></tr>
10
+ <% end %>
11
+ <tr><td>Book value per share</td><td><%= number(valuation.book_value_per_share) %></td></tr>
12
+ <tr><td>Graham number</td><td><%= number(valuation.graham_number) %></td></tr>
13
+ <tr><td>Intrinsic value (6% growth, 9% discount)</td><td><%= number(valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09)) %></td></tr>
14
+ <tr><td>Implied growth at 9% discount</td><td><%= percent(valuation.implied_growth(discount_rate: 0.09)) %></td></tr>
15
+ </tbody>
16
+ </table>
@@ -0,0 +1,19 @@
1
+ <% content_for :title, "Watchlists" %>
2
+ <h1>Watchlists</h1>
3
+ <%= form_with model: @watchlist, url: watchlists_path, class: "inline" do |form| %>
4
+ <%= form.text_field :name, placeholder: "Quality compounders", "aria-label": "Name" %>
5
+ <%= form.button "Create" %>
6
+ <% end %>
7
+ <% if @watchlists.any? %>
8
+ <table>
9
+ <tbody>
10
+ <% @watchlists.each do |watchlist| %>
11
+ <tr>
12
+ <td><%= link_to watchlist.name, watchlist %></td>
13
+ <td><%= pluralize(watchlist.holdings.size, "company") %></td>
14
+ <td><%= button_to "Remove", watchlist, method: :delete, class: "quiet" %></td>
15
+ </tr>
16
+ <% end %>
17
+ </tbody>
18
+ </table>
19
+ <% end %>
@@ -0,0 +1,29 @@
1
+ <% content_for :title, @watchlist.name %>
2
+ <h1><%= @watchlist.name %></h1>
3
+ <%= form_with model: [@watchlist, @holding], class: "inline" do |form| %>
4
+ <%= form.text_field :ticker, placeholder: "Ticker", "aria-label": "Ticker", size: 8 %>
5
+ <%= form.number_field :price, placeholder: "Price", "aria-label": "Price", step: "any", size: 8 %>
6
+ <%= form.button "Add" %>
7
+ <% end %>
8
+ <% if @holdings.any? %>
9
+ <table>
10
+ <thead><tr><th>Company</th><th>Price</th><th>Snapshots</th><th>Latest P/E</th><th>Latest FCF yield</th><th>Piotroski</th><th></th></tr></thead>
11
+ <tbody>
12
+ <% @holdings.each do |holding| %>
13
+ <% latest = holding.snapshots.first %>
14
+ <tr>
15
+ <td><%= link_to holding.ticker, company_path(holding.ticker, price: holding.price) %></td>
16
+ <td><%= number(holding.price) %></td>
17
+ <td><%= holding.snapshots.size %><% if latest %> <span class="muted">last <%= latest.taken_on %></span><% end %></td>
18
+ <td><%= multiple(latest&.dig(:valuation, :pe)) %></td>
19
+ <td><%= percent(latest&.dig(:valuation, :fcf_yield)) %></td>
20
+ <td><%= latest&.dig(:scores, :piotroski) || "–" %></td>
21
+ <td>
22
+ <%= button_to "Snapshot", watchlist_holding_snapshots_path(@watchlist, holding), class: "quiet" %>
23
+ <%= button_to "Remove", watchlist_holding_path(@watchlist, holding), method: :delete, class: "quiet" %>
24
+ </td>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+ <% end %>
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for?(:title) ? "#{yield(:title)} · Fundamentalista" : "Fundamentalista" %></title>
7
+ <%= csrf_meta_tags %>
8
+ <style>
9
+ :root { color-scheme: light dark; --line: #d4d4d8; --muted: #71717a; --accent: #2563eb; }
10
+ body { margin: 0; font: 15px/1.5 system-ui, sans-serif; max-width: 72rem; padding: 1.5rem; margin-inline: auto; }
11
+ header.site { display: flex; gap: 1.5rem; align-items: baseline; border-bottom: 1px solid var(--line); padding-bottom: .75rem; margin-bottom: 1.5rem; }
12
+ header.site a { color: inherit; text-decoration: none; }
13
+ header.site strong { font-size: 1.1rem; }
14
+ h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
15
+ h2 { font-size: 1.05rem; margin: 2rem 0 .5rem; }
16
+ .muted { color: var(--muted); }
17
+ table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
18
+ th, td { text-align: right; padding: .35rem .6rem; border-bottom: 1px solid var(--line); }
19
+ th:first-child, td:first-child { text-align: left; }
20
+ thead th { font-weight: 600; color: var(--muted); }
21
+ form.inline { display: inline-flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
22
+ input[type=text], input[type=number] { font: inherit; padding: .35rem .5rem; border: 1px solid var(--line); border-radius: .375rem; background: transparent; color: inherit; }
23
+ button { font: inherit; padding: .35rem .8rem; border-radius: .375rem; border: 1px solid var(--accent); background: var(--accent); color: white; cursor: pointer; }
24
+ button.quiet { background: transparent; color: inherit; border-color: var(--line); }
25
+ .flash { padding: .5rem .75rem; border-radius: .375rem; border: 1px solid var(--line); margin-bottom: 1rem; }
26
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem 2.5rem; }
27
+ .zone-safe { color: #16a34a; } .zone-grey { color: #ca8a04; } .zone-distress { color: #dc2626; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <header class="site">
32
+ <strong><%= link_to "Fundamentalista", root_path %></strong>
33
+ <%= link_to "Compare", comparison_path %>
34
+ <%= link_to "Watchlists", watchlists_path %>
35
+ <%= form_with url: root_path, method: :get, class: "inline", style: "margin-left:auto" do |form| %>
36
+ <%= form.text_field :ticker, placeholder: "Ticker", "aria-label": "Ticker", size: 8 %>
37
+ <%= form.button "Look up" %>
38
+ <% end %>
39
+ </header>
40
+ <% flash.each do |kind, message| %>
41
+ <p class="flash" data-kind="<%= kind %>"><%= message %></p>
42
+ <% end %>
43
+ <%= yield %>
44
+ </body>
45
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ FundamentalistaRails::Engine.routes.draw do
4
+ root to: 'companies#index'
5
+ resources :companies, only: :show, param: :ticker
6
+ resource :comparison, only: :show
7
+ resources :watchlists, only: %i[index create show destroy] do
8
+ resources :holdings, only: %i[create destroy] do
9
+ resources :snapshots, only: :create
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFundamentalistaRailsTables < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :fundamentalista_rails_watchlists do |t|
6
+ t.string :name, null: false
7
+ t.timestamps
8
+ end
9
+
10
+ create_table :fundamentalista_rails_holdings do |t|
11
+ t.references :watchlist, null: false, foreign_key: { to_table: :fundamentalista_rails_watchlists }
12
+ t.string :ticker, null: false
13
+ t.decimal :price, precision: 20, scale: 6
14
+ t.timestamps
15
+ t.index %i[watchlist_id ticker], unique: true
16
+ end
17
+
18
+ create_table :fundamentalista_rails_snapshots do |t|
19
+ t.references :holding, null: false, foreign_key: { to_table: :fundamentalista_rails_holdings }
20
+ t.date :taken_on, null: false
21
+ t.decimal :price, precision: 20, scale: 6
22
+ t.text :figures, null: false
23
+ t.timestamps
24
+ t.index %i[holding_id taken_on]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fundamentalista'
4
+ require 'rails/engine'
5
+ require 'fundamentalista_rails/version'
6
+ require 'fundamentalista_rails/cache'
7
+ require 'fundamentalista_rails/engine'
8
+
9
+ # Fundamental analysis inside a Rails application: an engine with company
10
+ # pages, peer comparison and watchlists that keep valuation snapshots,
11
+ # on top of the fundamentalista gem.
12
+ #
13
+ # # config/routes.rb
14
+ # mount FundamentalistaRails::Engine, at: "/fundamentals"
15
+ #
16
+ module FundamentalistaRails
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Keeps provider responses in the application's cache store for a day,
5
+ # so a company page costs one download and a screen over a watchlist
6
+ # does not hammer the SEC.
7
+ class Cache
8
+ attr_reader :store, :expires_in
9
+
10
+ def initialize(store, expires_in: 1.day)
11
+ @store = store
12
+ @expires_in = expires_in
13
+ end
14
+
15
+ def fetch(key, &)
16
+ store.fetch(key, expires_in: expires_in, &)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # Mounts the company pages, comparisons and watchlists, and points the
5
+ # fundamentalista gem at the application's cache unless one is set.
6
+ #
7
+ # mount FundamentalistaRails::Engine, at: "/fundamentals"
8
+ #
9
+ # Configure the gem in an initializer as usual; the engine only adds
10
+ # the cache and the migrations for watchlists.
11
+ class Engine < ::Rails::Engine
12
+ isolate_namespace FundamentalistaRails
13
+
14
+ config.after_initialize do
15
+ Fundamentalista.config.cache ||= Cache.new(::Rails.cache) if ::Rails.cache
16
+ Fundamentalista.config.logger = ::Rails.logger
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FundamentalistaRails
4
+ # The version of the fundamentalista-rails gem, as a string.
5
+ VERSION = '0.1.0'
6
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fundamentalista-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bruno Costanzo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fundamentalista
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: 'A mountable engine on top of the fundamentalista gem: look up any listed
41
+ company, read its statements, ratios, scores and valuation, compare peers with the
42
+ magic formula, and keep watchlists whose valuation snapshots build a history. Uses
43
+ the application cache for provider data.'
44
+ email:
45
+ - dev.bcostanzo@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - app/controllers/fundamentalista_rails/application_controller.rb
54
+ - app/controllers/fundamentalista_rails/companies_controller.rb
55
+ - app/controllers/fundamentalista_rails/comparisons_controller.rb
56
+ - app/controllers/fundamentalista_rails/holdings_controller.rb
57
+ - app/controllers/fundamentalista_rails/snapshots_controller.rb
58
+ - app/controllers/fundamentalista_rails/watchlists_controller.rb
59
+ - app/helpers/fundamentalista_rails/figures_helper.rb
60
+ - app/models/fundamentalista_rails/application_record.rb
61
+ - app/models/fundamentalista_rails/holding.rb
62
+ - app/models/fundamentalista_rails/snapshot.rb
63
+ - app/models/fundamentalista_rails/watchlist.rb
64
+ - app/views/fundamentalista_rails/companies/index.html.erb
65
+ - app/views/fundamentalista_rails/companies/show.html.erb
66
+ - app/views/fundamentalista_rails/comparisons/show.html.erb
67
+ - app/views/fundamentalista_rails/shared/_ratios.html.erb
68
+ - app/views/fundamentalista_rails/shared/_scores.html.erb
69
+ - app/views/fundamentalista_rails/shared/_series.html.erb
70
+ - app/views/fundamentalista_rails/shared/_valuation.html.erb
71
+ - app/views/fundamentalista_rails/watchlists/index.html.erb
72
+ - app/views/fundamentalista_rails/watchlists/show.html.erb
73
+ - app/views/layouts/fundamentalista_rails/application.html.erb
74
+ - config/routes.rb
75
+ - db/migrate/20260906000000_create_fundamentalista_rails_tables.rb
76
+ - lib/fundamentalista-rails.rb
77
+ - lib/fundamentalista_rails/cache.rb
78
+ - lib/fundamentalista_rails/engine.rb
79
+ - lib/fundamentalista_rails/version.rb
80
+ homepage: https://github.com/bruno-costanzo/fundamentalista-rails
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ source_code_uri: https://github.com/bruno-costanzo/fundamentalista-rails
85
+ changelog_uri: https://github.com/bruno-costanzo/fundamentalista-rails/blob/main/CHANGELOG.md
86
+ bug_tracker_uri: https://github.com/bruno-costanzo/fundamentalista-rails/issues
87
+ rubygems_mfa_required: 'true'
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.1.3
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 4.0.16
103
+ specification_version: 4
104
+ summary: 'Fundamental analysis pages for Rails: companies, comparisons and watchlists.'
105
+ test_files: []