diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index b1ae9e50..9a060f8f 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -29,6 +29,7 @@ class Import::ConfigurationsController < ApplicationController :account_col_label, :qty_col_label, :ticker_col_label, + :exchange_operating_mic_col_label, :price_col_label, :entity_type_col_label, :notes_col_label, diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index b7718ecf..67930cc1 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -20,6 +20,7 @@ module ImportsHelper notes: "Notes", qty: "Quantity", ticker: "Ticker", + exchange: "Exchange", price: "Price", entity_type: "Type" }[key] diff --git a/app/models/import.rb b/app/models/import.rb index b28c3c81..9542e187 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -111,6 +111,7 @@ class Import < ApplicationRecord date: row[date_col_label].to_s, qty: sanitize_number(row[qty_col_label]).to_s, ticker: row[ticker_col_label].to_s, + exchange_operating_mic: row[exchange_operating_mic_col_label].to_s, price: sanitize_number(row[price_col_label]).to_s, amount: sanitize_number(row[amount_col_label]).to_s, currency: (row[currency_col_label] || default_currency).to_s, diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 65deaae5..829a5b7f 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -128,11 +128,12 @@ class Provider::Synth raw_response: error end - def search_securities(query:, dataset: "limited", country_code:) + def search_securities(query:, dataset: "limited", country_code: nil, exchange_operating_mic: nil) response = client.get("#{base_url}/tickers/search") do |req| req.params["name"] = query req.params["dataset"] = dataset - req.params["country_code"] = country_code + req.params["country_code"] = country_code if country_code.present? + req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present? req.params["limit"] = 25 end diff --git a/app/models/security.rb b/app/models/security.rb index 4a2bd1a7..46672fb5 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -13,7 +13,8 @@ class Security < ApplicationRecord security_prices_provider.search_securities( query: query[:search], dataset: "limited", - country_code: query[:country] + country_code: query[:country], + exchange_operating_mic: query[:exchange_operating_mic] ).securities.map { |attrs| new(**attrs) } end end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index 4ca57ea1..ddaad904 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -5,14 +5,24 @@ class TradeImport < Import rows.each do |row| account = mappings.accounts.mappable_for(row.account) - security = Security.find_or_create_by(ticker: row.ticker) + + # Try to find or create security with ticker only + security = find_or_create_security( + ticker: row.ticker, + exchange_operating_mic: row.exchange_operating_mic + ) entry = account.entries.build \ date: row.date_iso, amount: row.signed_amount, name: row.name, - currency: row.currency, - entryable: Account::Trade.new(security: security, qty: row.qty, currency: row.currency, price: row.price), + currency: row.currency.presence || account.currency, + entryable: Account::Trade.new( + security: security, + qty: row.qty, + currency: row.currency.presence || account.currency, + price: row.price + ), import: self entry.save! @@ -29,7 +39,7 @@ class TradeImport < Import end def column_keys - %i[date ticker qty price currency account name] + %i[date ticker exchange_operating_mic currency qty price account name] end def dry_run @@ -41,12 +51,63 @@ class TradeImport < Import def csv_template template = <<-CSV - date*,ticker*,qty*,price*,currency,account,name - 05/15/2024,AAPL,10,150.00,USD,Trading Account,Apple Inc. Purchase - 05/16/2024,GOOGL,-5,2500.00,USD,Investment Account,Alphabet Inc. Sale - 05/17/2024,TSLA,2,700.50,USD,Retirement Account,Tesla Inc. Purchase + date*,ticker*,exchange_operating_mic,currency,qty*,price*,account,name + 05/15/2024,AAPL,XNAS,USD,10,150.00,Trading Account,Apple Inc. Purchase + 05/16/2024,GOOGL,XNAS,USD,-5,2500.00,Investment Account,Alphabet Inc. Sale + 05/17/2024,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase CSV CSV.parse(template, headers: true) end + + private + def find_or_create_security(ticker:, exchange_operating_mic:) + # Normalize empty string to nil for consistency + exchange_operating_mic = nil if exchange_operating_mic.blank? + + # First try to find an exact match in our DB, or if no exchange_operating_mic is provided, find by ticker only + internal_security = if exchange_operating_mic.present? + Security.find_by(ticker:, exchange_operating_mic:) + else + Security.find_by(ticker:) + end + + return internal_security if internal_security.present? + + # If security prices provider isn't properly configured or available, create with nil exchange_operating_mic + provider = Security.security_prices_provider + unless provider.present? && provider.respond_to?(:search_securities) + return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) + end + + # Cache provider responses so that when we're looping through rows and importing, + # we only hit our provider for the unique combinations of ticker / exchange_operating_mic + cache_key = [ ticker, exchange_operating_mic ] + @provider_securities_cache ||= {} + + provider_security = @provider_securities_cache[cache_key] ||= begin + response = provider.search_securities( + query: ticker, + exchange_operating_mic: exchange_operating_mic + ) + + if !response || !response.success? || !response.securities || response.securities.empty? + nil + else + response.securities.first + end + rescue => e + nil + end + + return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil? + + Security.find_or_create_by!(ticker: provider_security[:ticker], exchange_operating_mic: provider_security[:exchange_operating_mic]) do |security| + security.name = provider_security[:name] + security.country_code = provider_security[:country_code] + security.logo_url = provider_security[:logo_url] + security.exchange_acronym = provider_security[:exchange_acronym] + security.exchange_mic = provider_security[:exchange_mic] + end + end end diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index e49d29c3..a8d056b0 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -1,25 +1,37 @@ <%# locals: (import:) %> <%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> -
+ Note: The Synth provider is not configured. Exchange validation is disabled. + Securities will be created without exchange validation, and price history will not be available. +
+