mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Stock filter (#1376)
* Initial pass at stock filtering * Rough in filter * Cleaning up security listing * Tweak to search function * Combobox tweaks * Clean up search query * Update trades test with combobox * Update securities.yml
This commit is contained in:
parent
c2561b5fb4
commit
7d8028b505
15 changed files with 104 additions and 7 deletions
1
Gemfile
1
Gemfile
|
@ -21,6 +21,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||||
# Hotwire
|
# Hotwire
|
||||||
gem "stimulus-rails"
|
gem "stimulus-rails"
|
||||||
gem "turbo-rails"
|
gem "turbo-rails"
|
||||||
|
gem "hotwire_combobox"
|
||||||
|
|
||||||
# Background Jobs
|
# Background Jobs
|
||||||
gem "good_job"
|
gem "good_job"
|
||||||
|
|
|
@ -188,6 +188,10 @@ GEM
|
||||||
actioncable (>= 6.0.0)
|
actioncable (>= 6.0.0)
|
||||||
listen (>= 3.0.0)
|
listen (>= 3.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
|
hotwire_combobox (0.3.2)
|
||||||
|
rails (>= 7.0.7.2)
|
||||||
|
stimulus-rails (>= 1.2)
|
||||||
|
turbo-rails (>= 1.2)
|
||||||
i18n (1.14.6)
|
i18n (1.14.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-tasks (1.0.14)
|
i18n-tasks (1.0.14)
|
||||||
|
@ -485,6 +489,7 @@ DEPENDENCIES
|
||||||
good_job
|
good_job
|
||||||
holidays
|
holidays
|
||||||
hotwire-livereload
|
hotwire-livereload
|
||||||
|
hotwire_combobox
|
||||||
i18n-tasks
|
i18n-tasks
|
||||||
image_processing (>= 1.2)
|
image_processing (>= 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
|
|
|
@ -19,7 +19,8 @@
|
||||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__label {
|
|
||||||
|
.form-field__label, .hw-combobox__label {
|
||||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,6 +121,33 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.combobox {
|
||||||
|
.hw-combobox__main__wrapper, .hw-combobox__input {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hw-combobox__main__wrapper {
|
||||||
|
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hw-combobox__listbox {
|
||||||
|
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hw_combobox__pagination__wrapper {
|
||||||
|
@apply h-px;
|
||||||
|
|
||||||
|
&:only-child {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||||
|
--hw-handle-width: 20px;
|
||||||
|
--hw-handle-height: 20px;
|
||||||
|
--hw-handle-offset-right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Small, single purpose classes that should take precedence over other styles */
|
/* Small, single purpose classes that should take precedence over other styles */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.scrollbar::-webkit-scrollbar {
|
.scrollbar::-webkit-scrollbar {
|
||||||
|
|
|
@ -33,6 +33,10 @@ class Account::TradesController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def securities
|
||||||
|
@pagy, @securities = pagy(Security.order(:name).search(params[:q]), limit: 20)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
|
|
|
@ -31,6 +31,7 @@ class Account::TradeBuilder < Account::EntryBuilder
|
||||||
end
|
end
|
||||||
|
|
||||||
def security
|
def security
|
||||||
|
return Security.find(ticker) if ticker.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
||||||
Security.find_or_create_by(ticker: ticker)
|
Security.find_or_create_by(ticker: ticker)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,36 @@ class Security < ApplicationRecord
|
||||||
validates :ticker, presence: true
|
validates :ticker, presence: true
|
||||||
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
|
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
|
||||||
|
|
||||||
|
scope :search, ->(query) {
|
||||||
|
return none if query.blank? || query.length < 2
|
||||||
|
|
||||||
|
# Clean and normalize the search terms
|
||||||
|
sanitized_query = query.split.map do |term|
|
||||||
|
cleaned_term = term.gsub(/[^a-zA-Z0-9]/, " ").strip
|
||||||
|
next if cleaned_term.blank?
|
||||||
|
cleaned_term
|
||||||
|
end.compact.join(" | ")
|
||||||
|
|
||||||
|
return none if sanitized_query.blank?
|
||||||
|
|
||||||
|
sanitized_query = ActiveRecord::Base.connection.quote(sanitized_query)
|
||||||
|
|
||||||
|
where("search_vector @@ to_tsquery('simple', #{sanitized_query}) AND exchange_mic IS NOT NULL")
|
||||||
|
.select("securities.*, ts_rank_cd(search_vector, to_tsquery('simple', #{sanitized_query})) AS rank")
|
||||||
|
.reorder("rank DESC")
|
||||||
|
}
|
||||||
|
|
||||||
def current_price
|
def current_price
|
||||||
@current_price ||= Security::Price.find_price(ticker:, date: Date.current)
|
@current_price ||= Security::Price.find_price(ticker:, date: Date.current)
|
||||||
return nil if @current_price.nil?
|
return nil if @current_price.nil?
|
||||||
Money.new(@current_price.price, @current_price.currency)
|
Money.new(@current_price.price, @current_price.currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_combobox_display
|
||||||
|
"#{ticker} - #{name} (#{exchange_acronym})"
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def upcase_ticker
|
def upcase_ticker
|
||||||
|
|
|
@ -7,10 +7,12 @@
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
|
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
|
||||||
<div data-trade-form-target="tickerInput">
|
<div data-trade-form-target="tickerInput">
|
||||||
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
|
<div class="form-field combobox">
|
||||||
|
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder"), autocomplete: :list, free_text: true %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form.date_field :date, label: true %>
|
<%= form.date_field :date, label: true, value: Date.current %>
|
||||||
|
|
||||||
<div data-trade-form-target="amountInput" hidden>
|
<div data-trade-form-target="amountInput" hidden>
|
||||||
<%= form.money_field :amount, label: t(".amount"), disable_currency: true %>
|
<%= form.money_field :amount, label: t(".amount"), disable_currency: true %>
|
||||||
|
|
7
app/views/account/trades/_tickers.turbo_stream.erb
Normal file
7
app/views/account/trades/_tickers.turbo_stream.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= image_tag("https://logo.synthfinance.com/ticker/#{tickers&.ticker}", class: "rounded-full h-8 w-8 inline-block mr-2") %>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-medium"><%= tickers&.name.presence || tickers&.ticker %></span>
|
||||||
|
<span class="text-xs text-gray-500"><%= "#{tickers&.ticker} (#{tickers&.exchange_acronym})" %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
3
app/views/account/trades/securities.turbo_stream.erb
Normal file
3
app/views/account/trades/securities.turbo_stream.erb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<%= async_combobox_options @securities,
|
||||||
|
render_in: { partial: "account/trades/tickers" },
|
||||||
|
next_page: @pagy.next %>
|
|
@ -9,6 +9,8 @@
|
||||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||||
|
|
||||||
|
<%= combobox_style_tag %>
|
||||||
|
|
||||||
<%= javascript_importmap_tags %>
|
<%= javascript_importmap_tags %>
|
||||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
<%= hotwire_livereload_tags if Rails.env.development? %>
|
||||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||||
|
|
|
@ -77,7 +77,9 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resources :transactions, only: %i[index update]
|
resources :transactions, only: %i[index update]
|
||||||
resources :valuations, only: %i[index new create]
|
resources :valuations, only: %i[index new create]
|
||||||
resources :trades, only: %i[index new create update]
|
resources :trades, only: %i[index new create update] do
|
||||||
|
get :securities, on: :collection
|
||||||
|
end
|
||||||
|
|
||||||
resources :entries, only: %i[edit update show destroy]
|
resources :entries, only: %i[edit update show destroy]
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AddSearchVectorToSecurities < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :securities, :search_vector, :virtual, type: :tsvector, as: "setweight(to_tsvector('simple', coalesce(ticker, '')), 'B') || to_tsvector('simple', coalesce(name, ''))", stored: true
|
||||||
|
add_index :securities, :search_vector, using: :gin
|
||||||
|
end
|
||||||
|
end
|
4
db/schema.rb
generated
4
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_10_25_174650) do
|
ActiveRecord::Schema[7.2].define(version: 2024_10_25_182612) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -481,7 +481,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_25_174650) do
|
||||||
t.string "country_code"
|
t.string "country_code"
|
||||||
t.string "exchange_mic"
|
t.string "exchange_mic"
|
||||||
t.string "exchange_acronym"
|
t.string "exchange_acronym"
|
||||||
|
t.virtual "search_vector", type: :tsvector, as: "(setweight(to_tsvector('simple'::regconfig, (COALESCE(ticker, ''::character varying))::text), 'B'::\"char\") || to_tsvector('simple'::regconfig, (COALESCE(name, ''::character varying))::text))", stored: true
|
||||||
t.index ["country_code"], name: "index_securities_on_country_code"
|
t.index ["country_code"], name: "index_securities_on_country_code"
|
||||||
|
t.index ["search_vector"], name: "index_securities_on_search_vector", using: :gin
|
||||||
t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true
|
t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
2
test/fixtures/securities.yml
vendored
2
test/fixtures/securities.yml
vendored
|
@ -1,7 +1,9 @@
|
||||||
aapl:
|
aapl:
|
||||||
ticker: AAPL
|
ticker: AAPL
|
||||||
name: Apple
|
name: Apple
|
||||||
|
exchange_mic: XNAS
|
||||||
|
|
||||||
msft:
|
msft:
|
||||||
ticker: MSFT
|
ticker: MSFT
|
||||||
name: Microsoft
|
name: Microsoft
|
||||||
|
exchange_mic: XNAS
|
||||||
|
|
|
@ -16,7 +16,8 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
|
|
||||||
open_new_trade_modal
|
open_new_trade_modal
|
||||||
|
|
||||||
fill_in "Ticker symbol", with: "NVDA"
|
fill_in "Ticker symbol", with: "AAPL"
|
||||||
|
select_combobox_option("Apple")
|
||||||
fill_in "Date", with: Date.current
|
fill_in "Date", with: Date.current
|
||||||
fill_in "Quantity", with: shares_qty
|
fill_in "Quantity", with: shares_qty
|
||||||
fill_in "account_entry[price]", with: 214.23
|
fill_in "account_entry[price]", with: 214.23
|
||||||
|
@ -27,7 +28,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
|
|
||||||
within_trades do
|
within_trades do
|
||||||
assert_text "Purchase 10 shares of AAPL"
|
assert_text "Purchase 10 shares of AAPL"
|
||||||
assert_text "Buy #{shares_qty} shares of NVDA"
|
assert_text "Buy #{shares_qty} shares of AAPL"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -38,6 +39,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
|
|
||||||
select "Sell", from: "Type"
|
select "Sell", from: "Type"
|
||||||
fill_in "Ticker symbol", with: aapl.ticker
|
fill_in "Ticker symbol", with: aapl.ticker
|
||||||
|
select_combobox_option(aapl.security.name)
|
||||||
fill_in "Date", with: Date.current
|
fill_in "Date", with: Date.current
|
||||||
fill_in "Quantity", with: aapl.qty
|
fill_in "Quantity", with: aapl.qty
|
||||||
fill_in "account_entry[price]", with: 215.33
|
fill_in "account_entry[price]", with: 215.33
|
||||||
|
@ -64,4 +66,10 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
def visit_account_trades
|
def visit_account_trades
|
||||||
visit account_url(@account, tab: "transactions")
|
visit account_url(@account, tab: "transactions")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def select_combobox_option(text)
|
||||||
|
within "#account_entry_ticker-hw-listbox" do
|
||||||
|
find("li", text: text).click
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue