From 237035c8d4febd7b2756ea7eb668b8a4faddf9f9 Mon Sep 17 00:00:00 2001 From: LPW Date: Mon, 19 Jan 2026 09:49:51 -0500 Subject: [PATCH] Improve convert-to-trade security selection with search-first UX (#703) * Enhance security handling logic: - Prioritize user's country in sorting securities and country codes. - Add comprehensive mapping for MIC codes to user-friendly exchange names. - Revamp combobox to consistently pull from a provider when available. - Improve handling of custom ticker and exchange input fields. * Localize securities combobox display and exchange labels. --------- Co-authored-by: luckyPipewrench --- app/controllers/securities_controller.rb | 2 +- app/controllers/transactions_controller.rb | 14 +- app/models/security.rb | 136 ++++++++++++++++++ app/models/security/combobox_option.rb | 11 +- app/models/security/provided.rb | 15 +- app/models/security/resolver.rb | 12 +- .../_combobox_security.turbo_stream.erb | 4 +- .../transactions/convert_to_trade.html.erb | 129 ++++++++--------- config/locales/views/securities/en.yml | 6 + 9 files changed, 250 insertions(+), 79 deletions(-) create mode 100644 config/locales/views/securities/en.yml diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index f2e1b1b73..f369c33c5 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -2,7 +2,7 @@ class SecuritiesController < ApplicationController def index @securities = Security.search_provider( params[:q], - country_code: params[:country_code] == "US" ? "US" : nil + country_code: params[:country_code].presence ) end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index c97482c90..3060af3f5 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -365,6 +365,8 @@ class TransactionsController < ApplicationController # Helper methods for convert_to_trade def resolve_security_for_conversion + user_country = Current.family.country + if params[:security_id] == "__custom__" # User selected "Enter custom ticker" - check for combobox selection or manual entry if params[:ticker].present? @@ -372,13 +374,15 @@ class TransactionsController < ApplicationController ticker_symbol, exchange_operating_mic = params[:ticker].split("|") Security::Resolver.new( ticker_symbol.strip, - exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence + exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence, + country_code: user_country ).resolve elsif params[:custom_ticker].present? # Manual entry from combobox's name_when_new or fallback text field Security::Resolver.new( params[:custom_ticker].strip, - exchange_operating_mic: params[:exchange_operating_mic].presence + exchange_operating_mic: params[:exchange_operating_mic].presence, + country_code: user_country ).resolve else flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker") @@ -398,13 +402,15 @@ class TransactionsController < ApplicationController ticker_symbol, exchange_operating_mic = params[:ticker].split("|") Security::Resolver.new( ticker_symbol.strip, - exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence + exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence, + country_code: user_country ).resolve elsif params[:custom_ticker].present? # Manual entry from combobox's name_when_new (no existing holdings path) Security::Resolver.new( params[:custom_ticker].strip, - exchange_operating_mic: params[:exchange_operating_mic].presence + exchange_operating_mic: params[:exchange_operating_mic].presence, + country_code: user_country ).resolve end.tap do |security| if security.nil? && !performed? diff --git a/app/models/security.rb b/app/models/security.rb index e91d04b90..59307b414 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,6 +1,132 @@ class Security < ApplicationRecord include Provided + # ISO 10383 MIC codes mapped to user-friendly exchange names + # Source: https://www.iso20022.org/market-identifier-codes + EXCHANGE_NAMES = { + # United States - NASDAQ family (Operating MIC: XNAS) + "XNAS" => "NASDAQ", + "XNGS" => "NASDAQ", # Global Select Market + "XNMS" => "NASDAQ", # Global Market + "XNCM" => "NASDAQ", # Capital Market + "XBOS" => "NASDAQ BX", + "XPSX" => "NASDAQ PSX", + "XNDQ" => "NASDAQ Options", + + # United States - NYSE family (Operating MIC: XNYS) + "XNYS" => "NYSE", + "ARCX" => "NYSE Arca", + "XASE" => "NYSE American", # Formerly AMEX + "XCHI" => "NYSE Chicago", + "XCIS" => "NYSE National", + "AMXO" => "NYSE American Options", + "ARCO" => "NYSE Arca Options", + + # United States - OTC Markets (Operating MIC: OTCM) + "OTCM" => "OTC Markets", + "PINX" => "OTC Pink", + "OTCQ" => "OTCQX", + "OTCB" => "OTCQB", + "PSGM" => "OTC Grey", + + # United States - Other + "XCBO" => "CBOE", + "XCME" => "CME", + "XCBT" => "CBOT", + "XNYM" => "NYMEX", + "BATS" => "CBOE BZX", + "EDGX" => "CBOE EDGX", + "IEXG" => "IEX", + "MEMX" => "MEMX", + + # United Kingdom + "XLON" => "London Stock Exchange", + "XLME" => "London Metal Exchange", + + # Germany + "XETR" => "Xetra", + "XFRA" => "Frankfurt", + "XSTU" => "Stuttgart", + "XMUN" => "Munich", + "XBER" => "Berlin", + "XHAM" => "Hamburg", + "XDUS" => "Düsseldorf", + "XHAN" => "Hannover", + + # Euronext + "XPAR" => "Euronext Paris", + "XAMS" => "Euronext Amsterdam", + "XBRU" => "Euronext Brussels", + "XLIS" => "Euronext Lisbon", + "XDUB" => "Euronext Dublin", + "XOSL" => "Euronext Oslo", + "XMIL" => "Euronext Milan", + + # Other Europe + "XSWX" => "SIX Swiss", + "XVTX" => "SIX Swiss", + "XMAD" => "BME Madrid", + "XWBO" => "Vienna", + "XCSE" => "Copenhagen", + "XHEL" => "Helsinki", + "XSTO" => "Stockholm", + "XICE" => "Iceland", + "XPRA" => "Prague", + "XWAR" => "Warsaw", + "XATH" => "Athens", + "XIST" => "Istanbul", + + # Canada + "XTSE" => "Toronto", + "XTSX" => "TSX Venture", + "XCNQ" => "CSE", + "NEOE" => "NEO", + + # Australia & New Zealand + "XASX" => "ASX", + "XNZE" => "NZX", + + # Asia - Japan + "XTKS" => "Tokyo", + "XJPX" => "Japan Exchange", + "XOSE" => "Osaka", + "XNGO" => "Nagoya", + "XSAP" => "Sapporo", + "XFKA" => "Fukuoka", + + # Asia - China + "XSHG" => "Shanghai", + "XSHE" => "Shenzhen", + "XHKG" => "Hong Kong", + + # Asia - Other + "XKRX" => "Korea Exchange", + "XKOS" => "KOSDAQ", + "XTAI" => "Taiwan", + "XSES" => "Singapore", + "XBKK" => "Thailand", + "XIDX" => "Indonesia", + "XKLS" => "Malaysia", + "XPHS" => "Philippines", + "XBOM" => "BSE India", + "XNSE" => "NSE India", + + # Latin America + "XMEX" => "Mexico", + "XBUE" => "Buenos Aires", + "XBOG" => "Colombia", + "XSGO" => "Santiago", + "BVMF" => "B3 Brazil", + "XLIM" => "Lima", + + # Middle East & Africa + "XTAE" => "Tel Aviv", + "XDFM" => "Dubai", + "XADS" => "Abu Dhabi", + "XSAU" => "Saudi (Tadawul)", + "XJSE" => "Johannesburg" + }.freeze + before_validation :upcase_symbols has_many :trades, dependent: :nullify, class_name: "Trade" @@ -11,6 +137,16 @@ class Security < ApplicationRecord scope :online, -> { where(offline: false) } + # Returns user-friendly exchange name for a MIC code + def self.exchange_name_for(mic) + return nil if mic.blank? + EXCHANGE_NAMES[mic.upcase] || mic.upcase + end + + def exchange_name + self.class.exchange_name_for(exchange_operating_mic) + end + def current_price @current_price ||= find_or_fetch_price return nil if @current_price.nil? diff --git a/app/models/security/combobox_option.rb b/app/models/security/combobox_option.rb index 822fc635f..0123023f4 100644 --- a/app/models/security/combobox_option.rb +++ b/app/models/security/combobox_option.rb @@ -7,7 +7,16 @@ class Security::ComboboxOption "#{symbol}|#{exchange_operating_mic}" end + def exchange_name + Security.exchange_name_for(exchange_operating_mic) + end + def to_combobox_display - "#{symbol} - #{name} (#{exchange_operating_mic})" + I18n.t( + "securities.combobox.display", + symbol: symbol, + name: name, + exchange: exchange_name + ) end end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 1fbb1f272..9983414d4 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -21,7 +21,7 @@ module Security::Provided response = provider.search_securities(symbol, **params) if response.success? - response.data.map do |provider_security| + securities = response.data.map do |provider_security| # Need to map to domain model so Combobox can display via to_combobox_option Security.new( ticker: provider_security.symbol, @@ -31,6 +31,19 @@ module Security::Provided country_code: provider_security.country_code ) end + + # Sort results to prioritize user's country if provided + if country_code.present? + user_country = country_code.upcase + securities.sort_by do |s| + [ + s.country_code&.upcase == user_country ? 0 : 1, # User's country first + s.ticker.upcase == symbol.upcase ? 0 : 1 # Exact ticker match second + ] + end + else + securities + end else [] end diff --git a/app/models/security/resolver.rb b/app/models/security/resolver.rb index c8f403d4b..4591e64fa 100644 --- a/app/models/security/resolver.rb +++ b/app/models/security/resolver.rb @@ -124,9 +124,17 @@ class Security::Resolver end # Non-exhaustive list of common country codes for help in choosing "close" matches - # These are generally sorted by market cap. + # User's country (if provided) is prioritized first, then sorted by market cap. def sorted_country_codes_by_relevance - %w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO] + base_order = %w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO] + + # Prioritize user's country if provided + if country_code.present? + user_country = country_code.upcase + [ user_country ] + (base_order - [ user_country ]) + else + base_order + end end # Non-exhaustive list of common exchange operating MICs for help in choosing "close" matches diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb index 979551c0d..50d35b9bb 100644 --- a/app/views/securities/_combobox_security.turbo_stream.erb +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -8,13 +8,13 @@ <%= combobox_security.name.presence || combobox_security.symbol %> - <%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %> + <%= t("securities.combobox.exchange_label", symbol: combobox_security.symbol, exchange: combobox_security.exchange_name) %> <% if combobox_security.country_code.present? %>
<%= image_tag("https://hatscripts.github.io/circle-flags/flags/#{combobox_security.country_code.downcase}.svg", - class: "h-4 rounded-sm", # h-3 (12px) matches text-xs, w-5 for 3:5 aspect ratio + class: "h-4 rounded-sm", alt: "#{combobox_security.country_code.upcase} flag", title: combobox_security.country_code.upcase) %> diff --git a/app/views/transactions/convert_to_trade.html.erb b/app/views/transactions/convert_to_trade.html.erb index 32c3f172d..dc1e5a4ac 100644 --- a/app/views/transactions/convert_to_trade.html.erb +++ b/app/views/transactions/convert_to_trade.html.erb @@ -3,26 +3,6 @@ # otherwise determine default based on amount sign # Negative amount (money going out) = Buy, Positive (money coming in) = Sell default_label = params[:activity_label].presence || (@entry.amount > 0 ? "Sell" : "Buy") - - # Get unique securities from account holdings for ticker suggestions - # Include current price for display and validation - account_securities = @entry.account.holdings - .includes(:security) - .where.not(security: nil) - .map(&:security) - .uniq(&:ticker) - .sort_by(&:ticker) - - # Build options with price data for validation - security_options_with_prices = account_securities.map do |s| - price = s.current_price - price_display = price ? " (#{format_money(price)})" : "" - label = "#{s.ticker}#{price_display}" - label += " - #{s.name.truncate(25)}" if s.name.present? - # Store price as cents for JS comparison (amount is BigDecimal, multiply by 100 for cents) - price_cents = price ? (price.amount * 100).to_i : nil - [label, s.id, { "data-price-cents" => price_cents, "data-ticker" => s.ticker }] - end %> <%= render DS::Dialog.new(variant: "modal", reload_on_close: true) do |dialog| %> @@ -53,34 +33,55 @@
<%= f.label :security_id, t(".security_label"), class: "font-medium text-sm text-primary block" %> - <% if account_securities.any? %> - <%= f.select :security_id, - options_for_select( - [[t(".security_prompt"), ""]] + - security_options_with_prices + - [[t(".security_custom"), "__custom__"]], - nil - ), - {}, - { - class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container", - data: { - action: "change->convert-to-trade#toggleCustomTicker change->convert-to-trade#validatePrice", - convert_to_trade_target: "tickerSelect" - } - } %> -

<%= t(".security_not_listed_hint") %>

- -
- <%= f.label :exchange_operating_mic, t(".exchange_label"), class: "font-medium text-sm text-primary block" %> - <%= f.text_field :exchange_operating_mic, - placeholder: t(".exchange_placeholder"), - class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container", - pattern: "[A-Z]{4}", - title: t(".exchange_hint") %> -

<%= t(".exchange_hint") %>

-
+ <%# Only show exchange field when no provider - combobox selections already include exchange %> + <% unless Security.provider.present? %> +
+ <%= f.label :exchange_operating_mic, t(".exchange_label"), class: "font-medium text-sm text-primary block" %> + <%= f.text_field :exchange_operating_mic, + placeholder: t(".exchange_placeholder"), + class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container", + pattern: "[A-Z]{4}", + title: t(".exchange_hint") %> +

<%= t(".exchange_hint") %>

+
+ <% end %>
diff --git a/config/locales/views/securities/en.yml b/config/locales/views/securities/en.yml new file mode 100644 index 000000000..078dfd0d0 --- /dev/null +++ b/config/locales/views/securities/en.yml @@ -0,0 +1,6 @@ +--- +en: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})"