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? %>
<%= t(".security_not_listed_hint") %>
-<%= t(".ticker_search_hint") %>
- <% else %> + <% if Security.provider.present? %> + <%# Always use searchable combobox when provider available - prevents picking wrong similar tickers %> +<%= t(".ticker_search_hint") %>
+ <% else %> + <%# No provider: show existing holdings dropdown with custom option %> + <% + # Get unique securities from account holdings for dropdown + 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? + price_cents = price ? (price.amount * 100).to_i : nil + [label, s.id, { "data-price-cents" => price_cents, "data-ticker" => s.ticker }] + end + %> + <% 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") %>
+<%= t(".ticker_hint") %>
- <% end %> -<%= t(".ticker_search_hint") %>
<% else %> <%= f.text_field :ticker, placeholder: t(".ticker_placeholder"), @@ -166,15 +156,18 @@<%= t(".trade_type_hint") %>
<%= t(".exchange_hint") %>
-<%= t(".exchange_hint") %>
+