diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index d51fbc411..14c7f55b8 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -52,36 +52,44 @@ class HoldingsController < ApplicationController end def remap_security - # Combobox returns "TICKER|EXCHANGE" format - ticker, exchange = params[:security_id].to_s.split("|") + # Combobox returns "TICKER|EXCHANGE|PROVIDER" format + parsed = Security.parse_combobox_id(params[:security_id]) # Validate ticker is present (form has required: true, but can be bypassed) - if ticker.blank? - flash[:alert] = t(".security_not_found") - redirect_to account_path(@holding.account, tab: "holdings") - return - end - - new_security = Security::Resolver.new( - ticker, - exchange_operating_mic: exchange, - country_code: Current.family.country - ).resolve - - if new_security.nil? + if parsed[:ticker].blank? flash[:alert] = t(".security_not_found") redirect_to account_path(@holding.account, tab: "holdings") return end # The user explicitly selected this security from provider search results, - # so we know the provider can handle it. Bring it back online if it was - # previously marked offline (e.g. by a failed QIF import resolution). - if new_security.offline? - new_security.update!(offline: false, failed_fetch_count: 0, failed_fetch_at: nil) - end + # so we use the combobox data directly — no need to re-resolve via provider APIs. + new_security = Security.find_or_initialize_by( + ticker: parsed[:ticker], + exchange_operating_mic: parsed[:exchange_operating_mic] + ) + + # Honor the user's provider choice (validated by model inclusion check on save) + new_security.price_provider = parsed[:price_provider] if parsed[:price_provider].present? + + # Bring it online — user explicitly selected it from provider search results, + # so we know the provider can handle it. + new_security.offline = false + new_security.failed_fetch_count = 0 + new_security.failed_fetch_at = nil + + new_security.save! @holding.remap_security!(new_security) + + # Re-materialize holdings with the new security's prices. + # Reload account to avoid stale associations from remap_security!. + # The around_action :switch_timezone already sets the family timezone + # for this request, so Date.current is correct here. + account = Account.find(@holding.account_id) + strategy = account.linked? ? :reverse : :forward + Balance::Materializer.new(account, strategy: strategy, security_ids: [ new_security.id ]).materialize_balances + flash[:notice] = t(".success") respond_to do |format| diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index f3a63e9a7..59a85939a 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -14,13 +14,14 @@ class Settings::HostingsController < ApplicationController # Determine which providers are currently selected exchange_rate_provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider - securities_provider = ENV["SECURITIES_PROVIDER"].presence || Setting.securities_provider + enabled_securities = Setting.enabled_securities_providers - # Show Twelve Data settings if either provider is set to twelve_data - @show_twelve_data_settings = exchange_rate_provider == "twelve_data" || securities_provider == "twelve_data" - - # Show Yahoo Finance settings if either provider is set to yahoo_finance - @show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || securities_provider == "yahoo_finance" + # Show provider settings if used for FX or enabled for securities + @show_twelve_data_settings = exchange_rate_provider == "twelve_data" || enabled_securities.include?("twelve_data") + @show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || enabled_securities.include?("yahoo_finance") + @show_tiingo_settings = enabled_securities.include?("tiingo") + @show_eodhd_settings = enabled_securities.include?("eodhd") + @show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage") # Only fetch provider data if we're showing the section if @show_twelve_data_settings @@ -57,9 +58,7 @@ class Settings::HostingsController < ApplicationController Setting.brand_fetch_high_res_logos = hosting_params[:brand_fetch_high_res_logos] == "1" end - if hosting_params.key?(:twelve_data_api_key) - Setting.twelve_data_api_key = hosting_params[:twelve_data_api_key] - end + update_encrypted_setting(:twelve_data_api_key) if hosting_params.key?(:exchange_rate_provider) Setting.exchange_rate_provider = hosting_params[:exchange_rate_provider] @@ -69,6 +68,40 @@ class Settings::HostingsController < ApplicationController Setting.securities_provider = hosting_params[:securities_provider] end + if hosting_params.key?(:securities_providers) + new_providers = Array(hosting_params[:securities_providers]).reject(&:blank?) & Security.valid_price_providers + old_providers = Setting.enabled_securities_providers + + Setting.securities_providers = new_providers.join(",") + + # Clear the legacy singular setting so the fallback in + # enabled_securities_providers doesn't re-enable a provider + # the user just unchecked. + Setting.securities_provider = nil if new_providers.empty? + + # Mark securities linked to removed providers as offline so they aren't + # silently queried against an incompatible fallback provider (e.g. MFAPI + # scheme codes sent to TwelveData). The price_provider is preserved so + # provider_status can report :provider_unavailable. + removed = old_providers - new_providers + removed.each do |removed_provider| + Security.where(price_provider: removed_provider, offline: false) + .in_batches.update_all(offline: true, offline_reason: "provider_disabled") + end + + # Bring securities back online when their provider is re-enabled — but only + # those that were taken offline by a provider toggle, not by health checks. + added = new_providers - old_providers + added.each do |added_provider| + Security.where(price_provider: added_provider, offline: true, offline_reason: "provider_disabled") + .in_batches.update_all(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil) + end + end + + update_encrypted_setting(:tiingo_api_key) + update_encrypted_setting(:eodhd_api_key) + update_encrypted_setting(:alpha_vantage_api_key) + if hosting_params.key?(:syncs_include_pending) Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1" end @@ -166,7 +199,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params return ActionController::Parameters.new unless params.key?(:setting) - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) end def update_assistant_type @@ -195,6 +228,12 @@ class Settings::HostingsController < ApplicationController flash[:alert] = t(".scheduler_sync_failed") end + def update_encrypted_setting(param_key) + return unless hosting_params.key?(param_key) + value = hosting_params[param_key].to_s.strip + Setting.public_send(:"#{param_key}=", value) unless value.blank? || value == "********" + end + def current_user_timezone Current.family&.timezone.presence || "UTC" end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index c70e606bb..1d38397b7 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -524,12 +524,18 @@ class TransactionsController < ApplicationController if params[:security_id] == "__custom__" # User selected "Enter custom ticker" - check for combobox selection or manual entry if params[:ticker].present? - # Combobox selection: format is "SYMBOL|EXCHANGE" - ticker_symbol, exchange_operating_mic = params[:ticker].split("|") + # Combobox selection: format is "SYMBOL|EXCHANGE|PROVIDER" + parsed = Security.parse_combobox_id(params[:ticker]) + if parsed[:ticker].blank? + flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker") + redirect_back_or_to transactions_path + return nil + end Security::Resolver.new( - ticker_symbol.strip, - exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence, - country_code: user_country + parsed[:ticker].strip, + exchange_operating_mic: parsed[:exchange_operating_mic] || params[:exchange_operating_mic].presence, + country_code: user_country, + price_provider: parsed[:price_provider] ).resolve elsif params[:custom_ticker].present? # Manual entry from combobox's name_when_new or fallback text field @@ -552,12 +558,18 @@ class TransactionsController < ApplicationController end found elsif params[:ticker].present? - # Direct combobox (no existing holdings) - format is "SYMBOL|EXCHANGE" - ticker_symbol, exchange_operating_mic = params[:ticker].split("|") + # Direct combobox (no existing holdings) - format is "SYMBOL|EXCHANGE|PROVIDER" + parsed = Security.parse_combobox_id(params[:ticker]) + if parsed[:ticker].blank? + flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker") + redirect_back_or_to transactions_path + return nil + end Security::Resolver.new( - ticker_symbol.strip, - exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence, - country_code: user_country + parsed[:ticker].strip, + exchange_operating_mic: parsed[:exchange_operating_mic] || params[:exchange_operating_mic].presence, + country_code: user_country, + price_provider: parsed[:price_provider] ).resolve elsif params[:custom_ticker].present? # Manual entry from combobox's name_when_new (no existing holdings path) diff --git a/app/javascript/application.js b/app/javascript/application.js index 3f3b9c3a1..353017fa9 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,6 +1,49 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails"; import "controllers"; +import HwComboboxController from "controllers/hw_combobox_controller"; + +// Fix hotwire_combobox race condition: when typing quickly, a slow response for +// an early query (e.g. "A") can overwrite the correct results for the final query +// (e.g. "AAPL"). We abort the previous in-flight request whenever a new one fires, +// so stale Turbo Stream responses never reach the DOM. +const originalFilterAsync = HwComboboxController.prototype._filterAsync; +HwComboboxController.prototype._filterAsync = async function(inputType) { + if (this._searchAbortController) { + this._searchAbortController.abort(); + } + this._searchAbortController = new AbortController(); + + const query = { + q: this._fullQuery, + input_type: inputType, + for_id: this.element.dataset.asyncId, + callback_id: this._enqueueCallback() + }; + + const url = new URL(this.asyncSrcValue, window.location.origin); + Object.entries(query).forEach(([k, v]) => { + if (v != null) url.searchParams.set(k, v); + }); + + try { + const response = await fetch(url.toString(), { + headers: { + "Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content + }, + signal: this._searchAbortController.signal, + credentials: "same-origin" + }); + + if (response.ok) { + await Turbo.renderStreamMessage(await response.text()); + } + } catch (e) { + if (e.name !== "AbortError") throw e; + } +}; Turbo.StreamActions.redirect = function () { // Use "replace" to avoid adding form submission to browser history diff --git a/app/models/assistant/function/import_bank_statement.rb b/app/models/assistant/function/import_bank_statement.rb index b0cd02906..dee54602f 100644 --- a/app/models/assistant/function/import_bank_statement.rb +++ b/app/models/assistant/function/import_bank_statement.rb @@ -155,7 +155,7 @@ class Assistant::Function::ImportBankStatement < Assistant::Function account_holder: result[:account_holder], message: "Successfully extracted #{result[:transactions].size} transactions. Import created with ID: #{import.id}. Review and publish when ready." } - rescue Provider::ProviderError, Faraday::Error, Timeout::Error, RuntimeError => e + rescue Provider::Error, Faraday::Error, Timeout::Error, RuntimeError => e Rails.logger.error("ImportBankStatement error: #{e.class.name} - #{e.message}") Rails.logger.error(e.backtrace.first(10).join("\n")) { diff --git a/app/models/market_data_importer.rb b/app/models/market_data_importer.rb index 86c3c2351..d590859fe 100644 --- a/app/models/market_data_importer.rb +++ b/app/models/market_data_importer.rb @@ -17,7 +17,7 @@ class MarketDataImporter # Syncs historical security prices (and details) def import_security_prices - unless Security.provider + unless Security.providers.any? Rails.logger.warn("No provider configured for MarketDataImporter.import_security_prices, skipping sync") return end diff --git a/app/models/provider/alpha_vantage.rb b/app/models/provider/alpha_vantage.rb new file mode 100644 index 000000000..df5f2df5f --- /dev/null +++ b/app/models/provider/alpha_vantage.rb @@ -0,0 +1,340 @@ +class Provider::AlphaVantage < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + # Subclass so errors caught in this provider are raised as Provider::AlphaVantage::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 1.0 + + # Maximum requests per day (Alpha Vantage free tier limit) + MAX_REQUESTS_PER_DAY = 25 + + # Free tier "compact" returns ~100 trading days (~140 calendar days). + # "full" requires a paid plan. + def max_history_days + 140 + end + + # MIC code to Alpha Vantage symbol suffix mapping + MIC_TO_AV_SUFFIX = { + "XNYS" => "", "XNAS" => "", "XASE" => "", + "XLON" => ".LON", + "XETR" => ".DEX", + "XTSE" => ".TRT", + "XPAR" => ".PAR", + "XAMS" => ".AMS", + "XSWX" => ".SWX", + "XHKG" => ".HKG", + "XASX" => ".ASX", + "XMIL" => ".MIL", + "XMAD" => ".BME", + "XOSL" => ".OSL", + "XSTO" => ".STO", + "XCSE" => ".CPH", + "XHEL" => ".HEL" + }.freeze + + # Alpha Vantage symbol suffix to MIC code mapping (auto-generated from forward map) + AV_SUFFIX_TO_MIC = MIC_TO_AV_SUFFIX + .reject { |_, suffix| suffix.empty? } + .each_with_object({}) { |(mic, suffix), h| h[suffix.delete(".")] = mic } + .merge("FRK" => "XFRA") # FRK is not in the forward map (no MIC→FRK entry) + .freeze + + # Alpha Vantage region names to ISO country codes + AV_REGION_TO_COUNTRY = { + "United States" => "US", "United Kingdom" => "GB", + "Frankfurt" => "DE", "XETRA" => "DE", + "Amsterdam" => "NL", "Paris/Brussels" => "FR", + "Switzerland" => "CH", "Toronto" => "CA", + "Brazil/Sao Paolo" => "BR", + "India/Bombay" => "IN", "Hong Kong" => "HK", + "Milan" => "IT", "Madrid" => "ES", + "Oslo" => "NO", "Helsinki" => "FI", + "Copenhagen" => "DK", "Stockholm" => "SE", + "Australia" => "AU", "Japan" => "JP" + }.freeze + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + # Alpha Vantage has no non-quota endpoint — every API call counts against + # the 25/day free-tier limit. Rather than burn a call, we just check that + # the API key is configured. + def healthy? + with_provider_response do + api_key.present? + end + end + + def usage + with_provider_response do + day_key = "alpha_vantage:daily:#{Date.current}" + used = Rails.cache.read(day_key).to_i + + UsageData.new( + used: used, + limit: max_requests_per_day, + utilization: (used.to_f / max_requests_per_day * 100).round(1), + plan: "Free" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + throttle_request + response = client.get("#{base_url}/query") do |req| + req.params["function"] = "SYMBOL_SEARCH" + req.params["keywords"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + data = parsed.dig("bestMatches") + + if data.nil? + raise Error, "No data returned from search endpoint" + end + + data.first(25).map do |match| + av_ticker = match["1. symbol"] + region = match["4. region"] + currency = match["8. currency"] + + # Cache the API-returned currency so fetch_security_prices can use it + # instead of relying solely on the hardcoded suffix→currency fallback + if currency.present? + cache_key = "alpha_vantage:currency:#{av_ticker.upcase}" + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + end + + Security.new( + symbol: strip_av_suffix(av_ticker), + name: match["2. name"], + logo_url: nil, + exchange_operating_mic: extract_mic_from_symbol(av_ticker), + country_code: AV_REGION_TO_COUNTRY[region], + currency: currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + av_symbol = to_av_symbol(symbol, exchange_operating_mic) + + throttle_request + response = client.get("#{base_url}/query") do |req| + req.params["function"] = "OVERVIEW" + req.params["symbol"] = av_symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + name = parsed["Name"] + if name.blank? + raise Error, "No metadata returned for symbol #{av_symbol}" + end + + SecurityInfo.new( + symbol: parsed["Symbol"] || symbol, + name: name, + links: parsed["OfficialSite"].presence, + logo_url: nil, + description: parsed["Description"].presence, + kind: parsed["AssetType"]&.downcase, + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? + + historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + av_symbol = to_av_symbol(symbol, exchange_operating_mic) + + throttle_request + response = client.get("#{base_url}/query") do |req| + req.params["function"] = "TIME_SERIES_DAILY" + req.params["symbol"] = av_symbol + req.params["outputsize"] = "compact" + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + time_series = parsed.dig("Time Series (Daily)") + + if time_series.nil? + raise InvalidSecurityPriceError, "No time series data returned for symbol #{av_symbol}" + end + + currency = infer_currency_from_symbol(av_symbol) + + time_series.filter_map do |date_str, values| + date = Date.parse(date_str) + next unless date >= start_date && date <= end_date + + price = values["4. close"] + + if price.nil? || price.to_f <= 0 + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date_str}. Price data: #{price.inspect}") + next + end + + Price.new( + symbol: symbol, + date: date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic + ) + end + end + end + + private + attr_reader :api_key + + def base_url + ENV["ALPHA_VANTAGE_URL"] || "https://www.alphavantage.co" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.params["apikey"] = api_key + end + end + + # Adds daily request counter on top of the interval throttle from RateLimitable. + def throttle_request + super + + # Global per-day request counter via cache (Redis). + # Atomic increment-then-check avoids the TOCTOU of read-check-increment. + day_key = "alpha_vantage:daily:#{Date.current}" + new_count = Rails.cache.increment(day_key, 1, expires_in: 24.hours).to_i + + if new_count > max_requests_per_day + Rails.logger.warn("AlphaVantage: daily request limit reached (#{new_count}/#{max_requests_per_day})") + raise RateLimitError, "Alpha Vantage daily request limit reached (#{max_requests_per_day} per day)" + end + end + + def max_requests_per_day + ENV.fetch("ALPHA_VANTAGE_MAX_REQUESTS_PER_DAY", MAX_REQUESTS_PER_DAY).to_i + end + + # Converts a symbol + MIC code to Alpha Vantage's ticker format + def to_av_symbol(symbol, exchange_operating_mic) + return symbol if exchange_operating_mic.blank? + + suffix = MIC_TO_AV_SUFFIX[exchange_operating_mic] + return symbol if suffix.nil? + return symbol if suffix.empty? + + # Avoid double-suffixing if the symbol already has the correct suffix + return symbol if symbol.end_with?(suffix) + + "#{symbol}#{suffix}" + end + + # Strips the Alpha Vantage exchange suffix to get the canonical ticker + # e.g., "CSPX.LON" -> "CSPX", "AAPL" -> "AAPL" + def strip_av_suffix(symbol) + return symbol unless symbol.include?(".") + + parts = symbol.split(".", 2) + AV_SUFFIX_TO_MIC.key?(parts.last) ? parts.first : symbol + end + + # Extracts MIC code from Alpha Vantage symbol suffix (e.g., "CSPX.LON" -> "XLON") + def extract_mic_from_symbol(symbol) + return nil unless symbol.include?(".") + + suffix = symbol.split(".").last + AV_SUFFIX_TO_MIC[suffix] + end + + # Infers currency from the exchange suffix of an Alpha Vantage symbol. + # Falls back to cached currency from search results if available. + def infer_currency_from_symbol(av_symbol) + cache_key = "alpha_vantage:currency:#{av_symbol.upcase}" + cached = Rails.cache.read(cache_key) + return cached if cached.present? + + # Default currency based on exchange suffix + suffix = av_symbol.include?(".") ? av_symbol.split(".").last : nil + + currency = case suffix + when "LON" then "GBP" + when "DEX", "FRK" then "EUR" + when "PAR", "AMS", "MIL", "BME", "HEL" then "EUR" + when "TRT" then "CAD" + when "SWX" then "CHF" + when "HKG" then "HKD" + when "ASX" then "AUD" + when "STO" then "SEK" + when "CPH" then "DKK" + when "OSL" then "NOK" + else "USD" + end + + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + currency + end + + # Checks for Alpha Vantage-specific error responses. + # Alpha Vantage returns errors as JSON keys rather than HTTP status codes. + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) + + # Rate limit: Alpha Vantage returns a "Note" key when rate-limited + if parsed["Note"].present? + Rails.logger.warn("AlphaVantage rate limit: #{parsed["Note"]}") + raise RateLimitError, parsed["Note"] + end + + # General info/limit messages + if parsed["Information"].present? + Rails.logger.warn("AlphaVantage info: #{parsed["Information"]}") + raise RateLimitError, parsed["Information"] + end + + # Explicit error messages for invalid parameters + if parsed["Error Message"].present? + raise Error, "API error: #{parsed["Error Message"]}" + end + end +end diff --git a/app/models/provider/eodhd.rb b/app/models/provider/eodhd.rb new file mode 100644 index 000000000..ccb0f412a --- /dev/null +++ b/app/models/provider/eodhd.rb @@ -0,0 +1,304 @@ +class Provider::Eodhd < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + # Subclass so errors caught in this provider are raised as Provider::Eodhd::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 0.5 + + # Maximum API calls per day (EODHD free/basic plans are very restrictive) + MAX_REQUESTS_PER_DAY = 20 + + # EODHD free tier provides ~1 year of EOD data + def max_history_days + 365 + end + + # EODHD uses {SYMBOL}.{EXCHANGE} ticker format with its own exchange codes + MIC_TO_EODHD_EXCHANGE = { + "XNYS" => "US", "XNAS" => "US", "XASE" => "US", + "XLON" => "LSE", + "XETR" => "XETRA", + "XTSE" => "TO", + "XPAR" => "PA", + "XAMS" => "AS", + "XSWX" => "SW", + "XHKG" => "HK", + "XASX" => "AU", + "XTKS" => "TSE", + "XMIL" => "MI", + "XMAD" => "MC", + "XOSL" => "OL", + "XHEL" => "HE", + "XCSE" => "CO", + "XSTO" => "ST", + "XKRX" => "KS", + "XBOM" => "BSE", + "XNSE" => "NSE" + }.freeze + + EODHD_EXCHANGE_TO_MIC = { + "US" => "XNYS", "LSE" => "XLON", "XETRA" => "XETR", + "TO" => "XTSE", "PA" => "XPAR", "AS" => "XAMS", + "SW" => "XSWX", "HK" => "XHKG", "AU" => "XASX", + "TSE" => "XTKS", "MI" => "XMIL", "MC" => "XMAD", + "OL" => "XOSL", "HE" => "XHEL", "CO" => "XCSE", + "ST" => "XSTO", "KS" => "XKRX", "BSE" => "XBOM", + "NSE" => "XNSE" + }.freeze + + EODHD_COUNTRY_TO_CODE = { + "USA" => "US", "UK" => "GB", "Germany" => "DE", "France" => "FR", + "Netherlands" => "NL", "Switzerland" => "CH", "Canada" => "CA", + "Japan" => "JP", "Australia" => "AU", "Hong Kong" => "HK", + "Italy" => "IT", "Spain" => "ES", "Norway" => "NO", + "Finland" => "FI", "Denmark" => "DK", "Sweden" => "SE", + "South Korea" => "KR", "India" => "IN" + }.freeze + + EXCHANGE_CURRENCY = { + "US" => "USD", "LSE" => "GBP", "XETRA" => "EUR", "TO" => "CAD", + "PA" => "EUR", "AS" => "EUR", "SW" => "CHF", "HK" => "HKD", + "AU" => "AUD", "TSE" => "JPY", "MI" => "EUR", "MC" => "EUR", + "OL" => "NOK", "HE" => "EUR", "CO" => "DKK", + "ST" => "SEK", "KS" => "KRW", "BSE" => "INR", + "NSE" => "INR" + }.freeze + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + def healthy? + with_provider_response do + response = client.get("#{base_url}/api/user") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + JSON.parse(response.body).dig("name").present? + end + end + + def usage + with_provider_response do + response = client.get("#{base_url}/api/user") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + parsed = JSON.parse(response.body) + + limit = parsed.dig("apiRequests").to_i + daily_limit = parsed.dig("dailyRateLimit").to_i + + daily_key = daily_cache_key + used = Rails.cache.read(daily_key).to_i + + UsageData.new( + used: used, + limit: daily_limit > 0 ? daily_limit : MAX_REQUESTS_PER_DAY, + utilization: daily_limit > 0 ? (used.to_f / daily_limit * 100) : (used.to_f / MAX_REQUESTS_PER_DAY * 100), + plan: parsed.dig("subscriptionType") || "unknown" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + enforce_daily_limit! + throttle_request + + response = client.get("#{base_url}/api/search/#{CGI.escape(symbol)}") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise Error, "Unexpected response format from search API" + end + + parsed.first(25).map do |security| + eodhd_exchange = security.dig("Exchange") + mic = EODHD_EXCHANGE_TO_MIC[eodhd_exchange] + country = EODHD_COUNTRY_TO_CODE[security.dig("Country")] + code = security.dig("Code") + currency = security.dig("Currency") + + # Cache the API-returned currency so fetch_security_prices can use it + if currency.present? && mic.present? + cache_key = "eodhd:currency:#{code.upcase}:#{mic}" + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + end + + Security.new( + symbol: code, + name: security.dig("Name"), + logo_url: nil, + exchange_operating_mic: mic, + country_code: country, + currency: currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + enforce_daily_limit! + throttle_request + + ticker = eodhd_symbol(symbol, exchange_operating_mic) + + response = client.get("#{base_url}/api/fundamentals/#{CGI.escape(ticker)}") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + general = parsed.dig("General") || {} + + SecurityInfo.new( + symbol: symbol, + name: general.dig("Name"), + links: general.dig("WebURL"), + logo_url: general.dig("LogoURL"), + description: general.dig("Description"), + kind: general.dig("Type"), + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? + + historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + enforce_daily_limit! + throttle_request + + ticker = eodhd_symbol(symbol, exchange_operating_mic) + + response = client.get("#{base_url}/api/eod/#{CGI.escape(ticker)}") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + req.params["from"] = start_date.to_s + req.params["to"] = end_date.to_s + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise InvalidSecurityPriceError, "Unexpected response format from EOD API" + end + + # Prefer cached currency from search results; fall back to hardcoded map + cache_key = "eodhd:currency:#{symbol.upcase}:#{exchange_operating_mic}" + eodhd_exchange = MIC_TO_EODHD_EXCHANGE[exchange_operating_mic] + currency = Rails.cache.read(cache_key) || EXCHANGE_CURRENCY[eodhd_exchange] + + parsed.map do |resp| + price = resp.dig("close") + date = resp.dig("date") + + if price.nil? || price.to_f <= 0 + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}") + next + end + + Price.new( + symbol: symbol, + date: date.to_date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic + ) + end.compact + end + end + + private + attr_reader :api_key + + def base_url + ENV["EODHD_URL"] || "https://eodhd.com" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + end + end + + # Builds the EODHD ticker format: {SYMBOL}.{EXCHANGE} + def eodhd_symbol(symbol, exchange_operating_mic) + eodhd_exchange = MIC_TO_EODHD_EXCHANGE[exchange_operating_mic] if exchange_operating_mic.present? + + if eodhd_exchange.present? + "#{symbol}.#{eodhd_exchange}" + elsif exchange_operating_mic.present? + "#{symbol}.#{exchange_operating_mic}" + else + "#{symbol}.US" + end + end + + # Cache key for tracking daily API usage + def daily_cache_key + "eodhd:daily:#{Date.current}" + end + + # Enforces the daily rate limit. Raises RateLimitError if the limit is exhausted. + # Uses atomic increment-then-check to avoid TOCTOU races between concurrent workers. + def enforce_daily_limit! + new_count = Rails.cache.increment(daily_cache_key, 1, expires_in: 24.hours).to_i + + if new_count > max_requests_per_day + raise RateLimitError, "EODHD daily rate limit of #{max_requests_per_day} requests exhausted" + end + end + + # throttle_request and min_request_interval provided by RateLimitable + + def max_requests_per_day + ENV.fetch("EODHD_MAX_REQUESTS_PER_DAY", MAX_REQUESTS_PER_DAY).to_i + end + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) && parsed["error"].present? + + raise Error, "API error: #{parsed["error"]}" + end +end diff --git a/app/models/provider/mfapi.rb b/app/models/provider/mfapi.rb new file mode 100644 index 000000000..0e597891f --- /dev/null +++ b/app/models/provider/mfapi.rb @@ -0,0 +1,168 @@ +class Provider::Mfapi < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests + MIN_REQUEST_INTERVAL = 0.5 + + def initialize + # No API key required + end + + def healthy? + with_provider_response do + response = client.get("#{base_url}/mf/125497/latest") + parsed = JSON.parse(response.body) + parsed.dig("meta", "scheme_name").present? + end + end + + def usage + with_provider_response do + UsageData.new( + used: nil, + limit: nil, + utilization: nil, + plan: "Free (no key required)" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + throttle_request + response = client.get("#{base_url}/mf/search") do |req| + req.params["q"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise Error, "Unexpected response format from search endpoint" + end + + parsed.first(25).map do |fund| + Security.new( + symbol: fund["schemeCode"].to_s, + name: fund["schemeName"], + logo_url: nil, + exchange_operating_mic: "XBOM", + country_code: "IN", + currency: "INR" + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + throttle_request + response = client.get("#{base_url}/mf/#{CGI.escape(symbol)}/latest") + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + meta = parsed["meta"] || {} + + SecurityInfo.new( + symbol: symbol, + name: meta["scheme_name"], + links: nil, + logo_url: nil, + description: [ meta["fund_house"], meta["scheme_category"] ].compact.join(" - "), + kind: "mutual fund", + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date - 7.days, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No NAV found for scheme #{symbol} on or before #{date}" if historical_data.data.blank? + + # Find exact date or closest previous + historical_data.data.select { |p| p.date <= date }.max_by(&:date) || historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + throttle_request + response = client.get("#{base_url}/mf/#{CGI.escape(symbol)}") do |req| + req.params["startDate"] = start_date.to_s + req.params["endDate"] = end_date.to_s + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + nav_data = parsed["data"] + + if nav_data.nil? || !nav_data.is_a?(Array) + raise InvalidSecurityPriceError, "No NAV data returned for scheme #{symbol}" + end + + nav_data.filter_map do |entry| + nav = entry["nav"] + date_str = entry["date"] + + next if nav.nil? || nav.to_f <= 0 || date_str.blank? + + # MFAPI returns dates as DD-MM-YYYY + date = Date.strptime(date_str, "%d-%m-%Y") + + Price.new( + symbol: symbol, + date: date, + price: nav.to_f, + currency: "INR", + exchange_operating_mic: exchange_operating_mic + ) + end + end + end + + private + + def base_url + ENV["MFAPI_URL"] || "https://api.mfapi.in" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.headers["Accept"] = "application/json" + end + end + + # throttle_request and min_request_interval provided by RateLimitable + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) + + if parsed["status"] == "ERROR" || parsed["status"] == "FAIL" + raise Error, "API error: #{parsed['message'] || parsed['status']}" + end + end +end diff --git a/app/models/provider/rate_limitable.rb b/app/models/provider/rate_limitable.rb new file mode 100644 index 000000000..f1c1ca2d2 --- /dev/null +++ b/app/models/provider/rate_limitable.rb @@ -0,0 +1,59 @@ +# Shared concern for providers that need interval-based request throttling +# and a standard error transformation pattern. +# +# Providers that include this concern get: +# - `throttle_request`: sleeps to enforce MIN_REQUEST_INTERVAL between calls +# - `min_request_interval`: reads from ENV with fallback to the class constant +# - `default_error_transformer`: maps Faraday/rate-limit errors to provider-scoped types +# +# The including class MUST define: +# - `MIN_REQUEST_INTERVAL` (Float) — default seconds between requests +# - `Error` (Class) — provider-scoped error class +# - `RateLimitError` (Class) — provider-scoped rate-limit error class +# +# And MAY define a `PROVIDER_ENV_PREFIX` constant (e.g. "ALPHA_VANTAGE") used +# to derive the ENV key for the min request interval override. When omitted +# the prefix is derived from the class name (Provider::AlphaVantage → "ALPHA_VANTAGE"). +module Provider::RateLimitable + extend ActiveSupport::Concern + + private + # Enforces a minimum interval between consecutive requests on this instance. + # Subclasses that need additional rate-limit layers (daily counters, hourly + # counters) should call `super` or invoke this via `throttle_interval` and + # add their own checks. + def throttle_request + @last_request_time ||= Time.at(0) + elapsed = Time.current - @last_request_time + sleep_time = min_request_interval - elapsed + sleep(sleep_time) if sleep_time > 0 + @last_request_time = Time.current + end + + def min_request_interval + ENV.fetch("#{provider_env_prefix}_MIN_REQUEST_INTERVAL", self.class::MIN_REQUEST_INTERVAL).to_f + end + + def provider_env_prefix + self.class.const_defined?(:PROVIDER_ENV_PREFIX) ? self.class::PROVIDER_ENV_PREFIX : self.class.name.demodulize.underscore.upcase + end + + # Standard error transformation: maps common Faraday errors to provider-scoped + # error classes. Providers with extra error types (e.g. AuthenticationError) + # should override and call `super` for the default cases. + def default_error_transformer(error) + case error + when self.class::RateLimitError + error + when Faraday::TooManyRequestsError + self.class::RateLimitError.new( + "#{self.class.name.demodulize} rate limit exceeded", + details: error.response&.dig(:body) + ) + when Faraday::Error + self.class::Error.new(error.message, details: error.response&.dig(:body)) + else + self.class::Error.new(error.message) + end + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index aa7a443c5..efbe4395e 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -81,6 +81,34 @@ class Provider::Registry def yahoo_finance Provider::YahooFinance.new end + + def tiingo + api_key = ENV["TIINGO_API_KEY"].presence || Setting.tiingo_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::Tiingo.new(api_key) + end + + def eodhd + api_key = ENV["EODHD_API_KEY"].presence || Setting.eodhd_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::Eodhd.new(api_key) + end + + def alpha_vantage + api_key = ENV["ALPHA_VANTAGE_API_KEY"].presence || Setting.alpha_vantage_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::AlphaVantage.new(api_key) + end + + def mfapi + Provider::Mfapi.new + end end def initialize(concept) @@ -92,6 +120,11 @@ class Provider::Registry available_providers.map { |p| self.class.send(p) }.compact end + # Returns the list of provider key names (symbols) registered for this concept. + def provider_keys + available_providers + end + def get_provider(name) provider_method = available_providers.find { |p| p == name.to_sym } @@ -108,7 +141,7 @@ class Provider::Registry when :exchange_rates %i[twelve_data yahoo_finance] when :securities - %i[twelve_data yahoo_finance] + %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi] when :llm %i[openai] else diff --git a/app/models/provider/security_concept.rb b/app/models/provider/security_concept.rb index fbc408c33..37fbd3efe 100644 --- a/app/models/provider/security_concept.rb +++ b/app/models/provider/security_concept.rb @@ -1,7 +1,14 @@ module Provider::SecurityConcept extend ActiveSupport::Concern - Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code) + # NOTE: This `Security` is a lightweight Data value object used for search results. + # Inside provider classes that `include SecurityConcept`, unqualified `Security` + # resolves to this Data class — NOT to `::Security` (the ActiveRecord model). + Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :currency) do + def initialize(symbol:, name:, logo_url:, exchange_operating_mic:, country_code:, currency: nil) + super + end + end SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic) Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic) @@ -20,4 +27,11 @@ module Provider::SecurityConcept def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_security_prices" end + + # Maximum number of calendar days of historical data the provider can return. + # Callers should clamp start_date to avoid requesting data beyond this window. + # Override in subclasses with provider-specific limits. + def max_history_days + nil # nil means no known limit + end end diff --git a/app/models/provider/tiingo.rb b/app/models/provider/tiingo.rb new file mode 100644 index 000000000..97c998e96 --- /dev/null +++ b/app/models/provider/tiingo.rb @@ -0,0 +1,294 @@ +class Provider::Tiingo < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + # Subclass so errors caught in this provider are raised as Provider::Tiingo::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 1.5 + + # Maximum unique symbols per month (Tiingo free tier limit) + MAX_SYMBOLS_PER_MONTH = 500 + + # Maximum requests per hour + MAX_REQUESTS_PER_HOUR = 1000 + + # Tiingo exchange names to MIC codes + TIINGO_EXCHANGE_TO_MIC = { + "NASDAQ" => "XNAS", + "NYSE" => "XNYS", + "NYSE ARCA" => "XARC", + "NYSE MKT" => "XASE", + "BATS" => "BATS", + "LSE" => "XLON", + "SHE" => "XSHE", + "SHG" => "XSHG", + "OTCMKTS" => "XOTC", + "OTCD" => "XOTC", + "PINK" => "XOTC" + }.freeze + + # Tiingo asset types to normalized kinds + TIINGO_ASSET_TYPE_MAP = { + "Stock" => "common stock", + "ETF" => "etf", + "Mutual Fund" => "mutual fund" + }.freeze + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + def healthy? + with_provider_response do + response = client.get("#{base_url}/tiingo/daily/AAPL") + parsed = JSON.parse(response.body) + parsed.dig("ticker").present? + end + end + + def usage + with_provider_response do + count_key = "tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}" + symbols_used = Rails.cache.read(count_key).to_i + + UsageData.new( + used: symbols_used, + limit: MAX_SYMBOLS_PER_MONTH, + utilization: (symbols_used.to_f / MAX_SYMBOLS_PER_MONTH * 100).round(1), + plan: "Free" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + throttle_request + + response = client.get("#{base_url}/tiingo/utilities/search") do |req| + req.params["query"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise Error, "Unexpected response format from search endpoint" + end + + parsed.first(25).map do |security| + ticker = security["ticker"] + currency = security["priceCurrency"] + + # Cache the API-returned currency so fetch_security_prices can use it + # without making a second search request + if currency.present? && ticker.present? + Rails.cache.write("tiingo:currency:#{ticker.upcase}", currency, expires_in: 24.hours) + end + + Security.new( + symbol: ticker, + name: security["name"], + logo_url: nil, + exchange_operating_mic: map_exchange_to_mic(security["exchange"]), + country_code: security["countryCode"].presence || country_code, + currency: currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + throttle_request + track_symbol(symbol) + + response = client.get("#{base_url}/tiingo/daily/#{CGI.escape(symbol)}") + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + # The daily metadata endpoint returns exchangeCode (e.g., "NYSE ARCA", "OTCD") + resolved_mic = exchange_operating_mic.presence || map_exchange_to_mic(parsed["exchangeCode"]) + + SecurityInfo.new( + symbol: parsed["ticker"] || symbol, + name: parsed["name"], + links: nil, + logo_url: nil, + description: parsed["description"], + kind: nil, + exchange_operating_mic: resolved_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? + + historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + throttle_request + track_symbol(symbol) + + response = client.get("#{base_url}/tiingo/daily/#{CGI.escape(symbol)}/prices") do |req| + req.params["startDate"] = start_date.to_s + req.params["endDate"] = end_date.to_s + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + error_message = parsed.is_a?(Hash) ? (parsed["detail"] || "Unexpected response format") : "Unexpected response format" + raise InvalidSecurityPriceError, "API error: #{error_message}" + end + + # Prefer cached currency from search results to avoid a second API call + cache_key = "tiingo:currency:#{symbol.upcase}" + currency = Rails.cache.read(cache_key) || fetch_currency_for_symbol(symbol) + + parsed.map do |resp| + price = resp["close"] + date = resp["date"] + + if price.nil? || price.to_f <= 0 + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}") + next + end + + Price.new( + symbol: symbol, + date: Date.parse(date), + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic + ) + end.compact + end + end + + private + attr_reader :api_key + + def base_url + ENV["TIINGO_URL"] || "https://api.tiingo.com" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.headers["Authorization"] = "Token #{api_key}" + faraday.headers["Content-Type"] = "application/json" + end + end + + # Adds hourly request counter on top of the interval throttle from RateLimitable. + def throttle_request + super + + # Global per-hour request counter via cache (Redis). + # Atomic increment-then-check avoids the TOCTOU of read-check-increment. + hour_key = "tiingo:requests:#{Time.current.to_i / 3600}" + new_count = Rails.cache.increment(hour_key, 1, expires_in: 7200.seconds).to_i + + if new_count >= max_requests_per_hour + raise RateLimitError, "Tiingo hourly request limit reached (#{new_count}/#{max_requests_per_hour})" + end + end + + # Tracks unique symbols queried per month to stay within Tiingo's 500 symbols/month limit. + # Uses atomic set-if-absent (Redis SETNX) to eliminate the read-then-write race + # where two concurrent workers could both see the symbol as untracked and both + # increment the counter. + def track_symbol(symbol) + symbol_key = "tiingo:symbol:#{Date.current.strftime('%Y-%m')}:#{symbol.upcase}" + count_key = "tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}" + + # Atomic write-if-absent: returns false when the key already exists (Redis SETNX). + # Only the first worker to claim this symbol will proceed to increment the counter. + return unless Rails.cache.write(symbol_key, true, expires_in: 35.days, unless_exist: true) + + new_count = Rails.cache.increment(count_key, 1, expires_in: 35.days).to_i + + if new_count >= MAX_SYMBOLS_PER_MONTH + Rails.cache.decrement(count_key, 1) + Rails.cache.delete(symbol_key) + raise RateLimitError, "Tiingo unique symbol limit reached (#{MAX_SYMBOLS_PER_MONTH} per month)" + end + end + + # min_request_interval provided by RateLimitable + + def max_requests_per_hour + ENV.fetch("TIINGO_MAX_REQUESTS_PER_HOUR", MAX_REQUESTS_PER_HOUR).to_i + end + + # Fetches the price currency for a symbol via the search endpoint. + # Only called as a fallback when the cache (populated by search_securities) + # doesn't have the currency. Raises on failure to avoid silently mislabeling + # non-USD instruments as USD. + def fetch_currency_for_symbol(symbol) + throttle_request + + response = client.get("#{base_url}/tiingo/utilities/search") do |req| + req.params["query"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + if parsed.is_a?(Array) + match = parsed.find { |s| s["ticker"]&.upcase == symbol.upcase } + currency = match&.dig("priceCurrency") + + if currency.present? + Rails.cache.write("tiingo:currency:#{symbol.upcase}", currency, expires_in: 24.hours) + return currency + end + end + + raise Error, "Could not determine currency for #{symbol} from Tiingo search" + end + + def map_exchange_to_mic(exchange_name) + return nil if exchange_name.blank? + TIINGO_EXCHANGE_TO_MIC[exchange_name.strip] || exchange_name.strip + end + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) && parsed["detail"].present? + + detail = parsed["detail"] + + if detail.downcase.include?("rate limit") || detail.downcase.include?("too many") + raise RateLimitError, detail + end + + raise Error, "API error: #{detail}" + end +end diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index f448ef8ba..670414fef 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -157,7 +157,8 @@ class Provider::TwelveData < Provider name: security.dig("instrument_name"), logo_url: nil, exchange_operating_mic: security.dig("mic_code"), - country_code: country ? country.alpha2 : nil + country_code: country ? country.alpha2 : nil, + currency: security.dig("currency") ) end end @@ -199,7 +200,8 @@ class Provider::TwelveData < Provider with_provider_response do historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) - raise ProviderError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.empty? + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? historical_data.data.first end diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb index d08e29dc0..5acb8d7cb 100644 --- a/app/models/provider/yahoo_finance.rb +++ b/app/models/provider/yahoo_finance.rb @@ -20,6 +20,10 @@ class Provider::YahooFinance < Provider # Maximum lookback window for historical data (configurable) MAX_LOOKBACK_WINDOW = 10.years + def max_history_days + (MAX_LOOKBACK_WINDOW / 1.day).to_i + end + # Minimum delay between requests to avoid rate limiting (in seconds) MIN_REQUEST_INTERVAL = 0.5 diff --git a/app/models/security.rb b/app/models/security.rb index 35b0e8bdd..46c70493c 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,6 +1,9 @@ class Security < ApplicationRecord include Provided, PlanRestrictionTracker + # Transient attribute for search results -- not persisted + attr_accessor :search_currency + # ISO 10383 MIC codes mapped to user-friendly exchange names # Source: https://www.iso20022.org/market-identifier-codes # Data stored in config/exchanges.yml @@ -8,6 +11,13 @@ class Security < ApplicationRecord KINDS = %w[standard cash].freeze + # Known securities provider keys — derived from the registry so adding a new + # provider to Registry#available_providers automatically allows it here. + # Evaluated at runtime (not boot) so runtime-enabled providers are accepted. + def self.valid_price_providers + Provider::Registry.for_concept(:securities).provider_keys.map(&:to_s) + end + before_validation :upcase_symbols before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo? @@ -17,10 +27,17 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } validates :kind, inclusion: { in: KINDS } + validates :price_provider, inclusion: { in: ->(_) { Security.valid_price_providers } }, allow_nil: true scope :online, -> { where(offline: false) } scope :standard, -> { where(kind: "standard") } + # Parses the combobox ID format "SYMBOL|EXCHANGE|PROVIDER" into a hash. + def self.parse_combobox_id(value) + parts = value.to_s.split("|", 3) + { ticker: parts[0].presence, exchange_operating_mic: parts[1].presence, price_provider: parts[2].presence } + end + # Lazily finds or creates a synthetic cash security for an account. # Used as fallback when creating an interest Trade without a user-selected security. def self.cash_for(account) @@ -57,7 +74,9 @@ class Security < ApplicationRecord name: name, logo_url: logo_url, exchange_operating_mic: exchange_operating_mic, - country_code: country_code + country_code: country_code, + price_provider: price_provider, + currency: search_currency ) end diff --git a/app/models/security/combobox_option.rb b/app/models/security/combobox_option.rb index 0123023f4..18a491427 100644 --- a/app/models/security/combobox_option.rb +++ b/app/models/security/combobox_option.rb @@ -1,10 +1,10 @@ class Security::ComboboxOption include ActiveModel::Model - attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code + attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :price_provider, :currency def id - "#{symbol}|#{exchange_operating_mic}" + "#{symbol}|#{exchange_operating_mic}|#{price_provider}" end def exchange_name diff --git a/app/models/security/health_checker.rb b/app/models/security/health_checker.rb index 74e5a8d50..c24428c46 100644 --- a/app/models/security/health_checker.rb +++ b/app/models/security/health_checker.rb @@ -66,11 +66,21 @@ class Security::HealthChecker attr_reader :security def provider - Security.provider + security.price_data_provider + end + + # Some providers (e.g., Alpha Vantage) have very low daily limits and no + # lightweight endpoint — each health check burns a full API call that + # fetches ~100 data points. Skip health checks for those providers to + # avoid exhausting their quota on monitoring alone. + def skip_health_check? + provider.present? && provider.respond_to?(:max_history_days) && + provider.is_a?(Provider::AlphaVantage) end def latest_provider_price return nil unless provider.present? + return true if skip_health_check? # treat as healthy — quota too precious response = provider.fetch_security_price( symbol: security.ticker, @@ -111,6 +121,7 @@ class Security::HealthChecker Security.transaction do security.update!( offline: true, + offline_reason: "health_check_failed", failed_fetch_count: MAX_CONSECUTIVE_FAILURES + 1, failed_fetch_at: Time.current ) diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index 9d57332b6..cd2bb0688 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -31,20 +31,20 @@ class Security::Price::Importer prev_currency = prev_price_currency || db_price_currency || "USD" unless prev_price_value.present? - Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{start_date}") + Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{fill_start_date}") Sentry.capture_exception(MissingStartPriceError.new("Could not determine start price for ticker")) do |scope| scope.set_tags(security_id: security.id) scope.set_context("security", { id: security.id, - start_date: start_date + start_date: fill_start_date }) end return 0 end - gapfilled_prices = effective_start_date.upto(end_date).map do |date| + gapfilled_prices = fill_start_date.upto(end_date).map do |date| db_price = db_prices[date] db_price_value = db_price&.price provider_price = provider_prices[date] @@ -101,15 +101,34 @@ class Security::Price::Importer private attr_reader :security, :security_provider, :start_date, :end_date, :clear_cache + # The start date sent to the provider API, clamped to the provider's max + # lookback window when applicable. Computed independently of provider_prices + # so fill_start_date can reference it without relying on method call order. + def provider_fetch_start_date + @provider_fetch_start_date ||= begin + base = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days + max_days = security_provider.respond_to?(:max_history_days) ? security_provider.max_history_days : nil + + if max_days && (end_date - base).to_i > max_days + clamped = end_date - max_days.days + Rails.logger.info( + "#{security_provider.class.name} max history is #{max_days} days; " \ + "clamping #{security.ticker} start_date from #{base} to #{clamped}" + ) + clamped + else + base + end + end + end + def provider_prices @provider_prices ||= begin - provider_fetch_start_date = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days - response = security_provider.fetch_security_prices( symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic, start_date: provider_fetch_start_date, - end_date: end_date + end_date: end_date ) if response.success? @@ -175,9 +194,17 @@ class Security::Price::Importer end || end_date end + # The date the gap-fill loop starts from. When the provider's history was + # clamped (e.g. Alpha Vantage 140 days), we start from the clamped window + # instead of the original effective_start_date to avoid writing hundreds of + # LOCF-filled prices for dates the provider can't actually serve. + def fill_start_date + @fill_start_date ||= [ provider_fetch_start_date, effective_start_date ].max + end + def start_price_value # When processing full range (first sync), use original behavior - if effective_start_date == start_date + if fill_start_date == start_date provider_price_value = provider_prices.select { |date, _| date <= start_date } .max_by { |date, _| date } &.last&.price @@ -188,9 +215,8 @@ class Security::Price::Importer return nil end - # For partial range (effective_start_date > start_date), use recent data - # This prevents stale prices from old trade dates propagating to current gap-fills - cutoff_date = effective_start_date + # For partial range or clamped range, use the most recent data before fill_start_date + cutoff_date = fill_start_date # First try provider prices (most recent before cutoff) provider_price_value = provider_prices diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index e412244a9..de7142b1a 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -4,50 +4,187 @@ module Security::Provided SecurityInfoMissingError = Class.new(StandardError) class_methods do - def provider - provider = ENV["SECURITIES_PROVIDER"].presence || Setting.securities_provider - registry = Provider::Registry.for_concept(:securities) - registry.get_provider(provider.to_sym) + # Returns all enabled and configured securities providers + def providers + Setting.enabled_securities_providers.filter_map do |name| + Provider::Registry.for_concept(:securities).get_provider(name.to_sym) + rescue Provider::Registry::Error + nil + end end + # Backward compat: first enabled provider + def provider + providers.first + end + + # Get a specific provider by key name (e.g., "finnhub", "twelve_data") + # Returns nil if the provider is disabled in settings or not configured. + def provider_for(name) + return nil if name.blank? + return nil unless Setting.enabled_securities_providers.include?(name.to_s) + Provider::Registry.for_concept(:securities).get_provider(name.to_sym) + rescue Provider::Registry::Error + nil + end + + # Cache duration for search results (avoids burning through provider rate limits) + SEARCH_CACHE_TTL = 5.minutes + + # Maximum number of results returned to the combobox dropdown + MAX_SEARCH_RESULTS = 30 + + # Per-provider timeout so one slow provider can't stall the entire search + PROVIDER_SEARCH_TIMEOUT = 8.seconds + def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) - return [] if provider.nil? || symbol.blank? + return [] if symbol.blank? + + active_providers = providers.compact + return [] if active_providers.empty? params = { country_code: country_code, exchange_operating_mic: exchange_operating_mic }.compact_blank - response = provider.search_securities(symbol, **params) - - if response.success? - 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, - name: provider_security.name, - logo_url: provider_security.logo_url, - exchange_operating_mic: provider_security.exchange_operating_mic, - country_code: provider_security.country_code - ) + # Query all providers concurrently so the total wall time is max(provider + # latencies) instead of sum. Each future runs in the concurrent-ruby thread + # pool, keeping Puma threads unblocked during individual provider sleeps. + futures = active_providers.map do |prov| + Concurrent::Promises.future(prov) do |provider| + fetch_provider_results(provider, symbol, params) 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 + + # Collect results from each future individually with a shared deadline. + # Unlike zip (which is all-or-nothing), this keeps results from fast + # providers even when a slow one times out. + deadline = Time.current + PROVIDER_SEARCH_TIMEOUT + results_array = futures.map do |future| + remaining = [ (deadline - Time.current), 0 ].max + future.value(remaining) + end + + all_results = [] + seen_keys = Set.new + + results_array.each_with_index do |provider_results, idx| + next if provider_results.nil? + + provider_key = provider_key_for(active_providers[idx]) + + provider_results.each do |ps| + # Dedup key includes provider so the same ticker on the same exchange can + # appear once per provider — the user picks which provider's price feed + # they want and that choice is stored in price_provider. + dedup_key = "#{ps[:symbol]}|#{ps[:exchange_operating_mic]}|#{provider_key}".upcase + next if seen_keys.include?(dedup_key) + seen_keys.add(dedup_key) + + security = Security.new( + ticker: ps[:symbol], + name: ps[:name], + logo_url: ps[:logo_url], + exchange_operating_mic: ps[:exchange_operating_mic], + country_code: ps[:country_code], + search_currency: ps[:currency], + price_provider: provider_key + ) + all_results << security + end + end + + if all_results.empty? && active_providers.any? + Rails.logger.warn("Security search: all #{active_providers.size} providers returned no results for '#{symbol}'") + end + + rank_search_results(all_results, symbol, country_code).first(MAX_SEARCH_RESULTS) end + + private + def provider_key_for(provider_instance) + provider_instance.class.name.demodulize.underscore + end + + # Fetches (or reads from cache) search results for a single provider. + # Designed to run inside a Concurrent::Promises.future. + def fetch_provider_results(prov, symbol, params) + provider_key = provider_key_for(prov) + cache_key = "security_search:#{provider_key}:#{symbol.upcase}:#{Digest::SHA256.hexdigest(params.sort_by { |k, _| k }.to_json)}" + + Rails.cache.fetch(cache_key, expires_in: SEARCH_CACHE_TTL, skip_nil: true) do + response = prov.search_securities(symbol, **params) + next nil unless response.success? + + response.data.map do |ps| + { symbol: ps.symbol, name: ps.name, logo_url: ps.logo_url, + exchange_operating_mic: ps.exchange_operating_mic, country_code: ps.country_code, + currency: ps.respond_to?(:currency) ? ps.currency : nil } + end + end + rescue => e + Rails.logger.warn("Security search failed for #{provider_key}: #{e.message}") + nil + end + + # Scores and sorts search results so the most relevant matches appear first. + # Scoring criteria (lower = better): + # 0: exact ticker match + # 1: ticker starts with query + # 2: name contains query + # 3: everything else + # Within the same relevance tier, user's country is preferred. + def rank_search_results(results, symbol, country_code) + query = symbol.upcase + user_country = country_code&.upcase + + results.sort_by do |s| + ticker_up = s.ticker.upcase + relevance = if ticker_up == query + 0 + elsif ticker_up.start_with?(query) + 1 + elsif s.name&.upcase&.include?(query) + 2 + else + 3 + end + + country_match = (user_country.present? && s.country_code&.upcase == user_country) ? 0 : 1 + + [ relevance, country_match, ticker_up ] + end + end + end + + # Public method: resolves the provider for this specific security. + # Uses the security's assigned price_provider if available and configured. + # Falls back to the first enabled provider only when no specific provider + # was ever assigned. When an assigned provider becomes unavailable, returns + # nil so the security is skipped rather than queried against an incompatible + # provider (e.g. MFAPI scheme codes sent to TwelveData). + def price_data_provider + if price_provider.present? + assigned = self.class.provider_for(price_provider) + return assigned if assigned.present? + return nil # assigned provider is unavailable — don't silently fall back + end + self.class.providers.first + end + + # Returns the health status of this security's provider link. + # Delegates to price_data_provider to avoid duplicating provider lookup logic. + def provider_status + resolved = price_data_provider + + # Had a specific provider assigned but it's now unavailable + return :provider_unavailable if resolved.nil? && price_provider.present? + + return :offline if offline? + return :no_provider if resolved.nil? + return :stale if failed_fetch_count.to_i > 0 + :ok end def find_or_fetch_price(date: Date.current, cache: true) @@ -59,8 +196,8 @@ module Security::Provided return nil if offline? # Make sure we have a data provider before fetching - return nil unless provider.present? - response = provider.fetch_security_price( + return nil unless price_data_provider.present? + response = price_data_provider.fetch_security_price( symbol: ticker, exchange_operating_mic: exchange_operating_mic, date: date @@ -79,7 +216,7 @@ module Security::Provided end def import_provider_details(clear_cache: false) - unless provider.present? + unless price_data_provider.present? Rails.logger.warn("No provider configured for Security.import_provider_details") return end @@ -88,19 +225,21 @@ module Security::Provided return end - response = provider.fetch_security_info( + response = price_data_provider.fetch_security_info( symbol: ticker, exchange_operating_mic: exchange_operating_mic ) if response.success? - update( - name: response.data.name, - logo_url: response.data.logo_url, - website_url: response.data.links - ) + # Only overwrite fields the provider actually returned, so providers that + # don't support metadata (e.g. Alpha Vantage) won't blank existing values. + attrs = {} + attrs[:name] = response.data.name if response.data.name.present? + attrs[:logo_url] = response.data.logo_url if response.data.logo_url.present? + attrs[:website_url] = response.data.links if response.data.links.present? + update(attrs) if attrs.any? else - Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") + Rails.logger.warn("Failed to fetch security info for #{ticker} from #{price_data_provider.class.name}: #{response.error.message}") Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope| scope.set_tags(security_id: self.id) scope.set_context("security", { id: self.id, provider_error: response.error.message }) @@ -109,23 +248,18 @@ module Security::Provided end def import_provider_prices(start_date:, end_date:, clear_cache: false) - unless provider.present? + unless price_data_provider.present? Rails.logger.warn("No provider configured for Security.import_provider_prices") return 0 end importer = Security::Price::Importer.new( security: self, - security_provider: provider, + security_provider: price_data_provider, start_date: start_date, end_date: end_date, clear_cache: clear_cache ) [ importer.import_provider_prices, importer.provider_error ] end - - private - def provider - self.class.provider - end end diff --git a/app/models/security/resolver.rb b/app/models/security/resolver.rb index 2ec7fb701..426b6937b 100644 --- a/app/models/security/resolver.rb +++ b/app/models/security/resolver.rb @@ -1,8 +1,9 @@ class Security::Resolver - def initialize(symbol, exchange_operating_mic: nil, country_code: nil) + def initialize(symbol, exchange_operating_mic: nil, country_code: nil, price_provider: nil) @symbol = validate_symbol!(symbol) @exchange_operating_mic = exchange_operating_mic @country_code = country_code + @price_provider = validated_price_provider(price_provider) end # Attempts several paths to resolve a security: @@ -20,13 +21,22 @@ class Security::Resolver end private - attr_reader :symbol, :exchange_operating_mic, :country_code + attr_reader :symbol, :exchange_operating_mic, :country_code, :price_provider def validate_symbol!(symbol) raise ArgumentError, "Symbol is required and cannot be blank" if symbol.blank? symbol.strip.upcase end + # Only accept price_provider values that are known and currently enabled. + # Prevents tampered combobox values from persisting invalid provider names. + def validated_price_provider(value) + return nil if value.blank? + return nil unless Security.valid_price_providers.include?(value.to_s) + return nil unless Setting.enabled_securities_providers.include?(value.to_s) + value.to_s + end + def offline_security security = Security.find_or_initialize_by( ticker: symbol, @@ -44,13 +54,26 @@ class Security::Resolver end def exact_match_from_db - Security.find_by( + security = Security.find_by( { ticker: symbol, exchange_operating_mic: exchange_operating_mic, country_code: country_code.presence }.compact ) + + return nil unless security + + # When the caller provides an explicit provider (e.g. user selected from + # search results), honor that choice. Automated syncs (Plaid, SimpleFIN) + # pass price_provider: nil and will not overwrite. + if price_provider.present? && security.price_provider != price_provider + security.update!(price_provider: price_provider) + end + + reactivate_if_provider_available!(security) + + security end # If provided a ticker + exchange (and optionally, a country code), we can find exact matches @@ -59,8 +82,8 @@ class Security::Resolver return nil unless exchange_operating_mic.present? match = provider_search_result.find do |s| - ticker_matches = s.ticker.upcase.to_s == symbol.upcase.to_s - exchange_matches = s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s + ticker_matches = s.ticker&.upcase.to_s == symbol.upcase.to_s + exchange_matches = s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s if country_code && exchange_operating_mic ticker_matches && exchange_matches && s.country_code&.upcase.to_s == country_code.upcase.to_s @@ -88,8 +111,8 @@ class Security::Resolver # 4. Rank by exchange_operating_mic relevance (lower index in the list is more relevant) sorted_candidates = filtered_candidates.sort_by do |s| [ - s.ticker.upcase.to_s == symbol.upcase.to_s ? 0 : 1, - exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1, + s.ticker&.upcase.to_s == symbol.upcase.to_s ? 0 : 1, + exchange_operating_mic.present? && s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1, sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length, sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length ] @@ -109,11 +132,35 @@ class Security::Resolver ) security.country_code = match.country_code + + # Set provider when explicitly provided (user selection) or when the + # record is new / has no provider yet. Automated syncs pass nil and + # will not overwrite an existing choice. + effective_provider = price_provider.presence || + (match.respond_to?(:price_provider) ? match.price_provider.presence : nil) + + if effective_provider.present? + security.price_provider = effective_provider + end + security.save! + reactivate_if_provider_available!(security) + security end + # If a security was marked offline (e.g. its provider was temporarily + # removed in settings) but now has a valid, enabled provider, bring it + # back online so the MarketDataImporter picks it up again. + def reactivate_if_provider_available!(security) + return unless security.offline? + return unless security.offline_reason == "provider_disabled" + return unless security.price_data_provider.present? + + security.update!(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil) + end + def provider_search_result params = { exchange_operating_mic: exchange_operating_mic, diff --git a/app/models/setting.rb b/app/models/setting.rb index b62d4073d..b99a88619 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -36,6 +36,83 @@ class Setting < RailsSettings::Base field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data") field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data") + # Multi-provider: comma-separated list of enabled securities providers + field :securities_providers, type: :string, default: ENV.fetch("SECURITIES_PROVIDERS", "") + + # New provider API keys (encrypted at rest — see EncryptedSettingFields below) + field :tiingo_api_key, type: :string, default: ENV["TIINGO_API_KEY"] + field :eodhd_api_key, type: :string, default: ENV["EODHD_API_KEY"] + field :alpha_vantage_api_key, type: :string, default: ENV["ALPHA_VANTAGE_API_KEY"] + + # Transparent encryption for API key fields. The `field` macro defines the + # raw getter/setter on the class. By prepending this module we intercept + # reads (decrypt) and writes (encrypt) while `super` delegates to the + # original getter/setter generated by rails-settings-cached. + # + # Backward-compatible: if decryption fails (e.g. the value was stored before + # encryption was enabled) the raw value is returned as-is. + module EncryptedSettingFields + ENCRYPTED_FIELDS = %i[ + twelve_data_api_key + tiingo_api_key + eodhd_api_key + alpha_vantage_api_key + openai_access_token + external_assistant_token + ].freeze + + ENCRYPTED_FIELDS.each do |field_name| + define_method(field_name) do + raw = super() + decrypt_setting(raw) + end + + define_method(:"#{field_name}=") do |value| + super(encrypt_setting(value)) + end + end + + private + + def setting_encryptor + @setting_encryptor ||= begin + key = ActiveSupport::KeyGenerator.new( + Rails.application.secret_key_base + ).generate_key("setting_encryption", 32) + ActiveSupport::MessageEncryptor.new(key) + end + end + + def encrypt_setting(value) + return value if value.blank? + setting_encryptor.encrypt_and_sign(value) + end + + def decrypt_setting(value) + return value if value.blank? + setting_encryptor.decrypt_and_verify(value) + rescue ActiveSupport::MessageVerifier::InvalidSignature, + ActiveSupport::MessageEncryptor::InvalidMessage + # Value was stored before encryption was enabled — return as-is. + # It will be re-encrypted on next write. + value + end + end + + class << self + prepend EncryptedSettingFields + end + + def self.enabled_securities_providers + plural = ENV["SECURITIES_PROVIDERS"].presence || securities_providers.presence + if plural.present? + plural.to_s.split(",").map(&:strip).reject(&:blank?) + else + # Backward compat: fall back to singular setting + [ ENV["SECURITIES_PROVIDER"].presence || securities_provider ].compact + end + end + # Sync settings - check both provider env vars for default # Only defaults to true if neither provider explicitly disables pending SYNCS_INCLUDE_PENDING_DEFAULT = begin diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index 54c78111e..37cf3a44e 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -22,11 +22,13 @@ class Trade::CreateForm private # Users can either look up a ticker from a provider or enter a manual, "offline" ticker (that we won't fetch prices for) def security - ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] + parsed = ticker.present? ? Security.parse_combobox_id(ticker) : { ticker: manual_ticker } + return nil if parsed[:ticker].blank? Security::Resolver.new( - ticker_symbol, - exchange_operating_mic: exchange_operating_mic + parsed[:ticker], + exchange_operating_mic: parsed[:exchange_operating_mic], + price_provider: parsed[:price_provider] ).resolve end diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 38ff086c0..fb4dc3b66 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -20,13 +20,21 @@ <% dialog.with_body do %> <% dialog.with_section(title: t(".overview"), open: true) do %>
+ <% if @holding.security.provider_status == :provider_unavailable %> +
+ <%= render DS::Alert.new( + message: t(".provider_disabled_warning", provider: @holding.security.price_provider&.humanize || "Unknown"), + variant: :warning + ) %> +
+ <% end %>
<%= t(".security_label") %>
<%= @holding.ticker %> - <% if @holding.security_remapped? %> + <% if @holding.security_remapped? && @holding.account.linked? %> (<%= t(".originally", ticker: @holding.provider_security.ticker) %>) @@ -41,7 +49,7 @@
<% end %> - <% if @holding.cost_basis_locked? || @holding.security_remapped? || @holding.account.can_delete_holdings? || !@holding.security.offline? %> + <% if @holding.cost_basis_locked? || (@holding.security_remapped? && @holding.account.linked?) || @holding.account.can_delete_holdings? || !@holding.security.offline? || @holding.security.provider_status == :provider_unavailable %> <% dialog.with_section(title: t(".settings"), open: true) do %>
- <% if @holding.security_remapped? %> + <% if @holding.security.provider_status == :provider_unavailable %> +
+
+

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

+

<%= t(".switch_provider_description", provider: @holding.security.price_provider&.humanize || "Unknown") %>

+ <% if Security.providers.any? %> + <%= form_with url: remap_security_holding_path(@holding), method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "change" } do |f| %> +
+ <%= f.combobox :security_id, + securities_path(country_code: Current.family.country), + id: "switch_provider_security_id", + placeholder: t(".search_security_placeholder"), + data: { "auto-submit-form-target": "auto" } %> +
+ <% end %> + <% else %> +

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

+ <% end %> +
+
+ <% end %> + <% if @holding.security_remapped? && @holding.account.linked? %>

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

diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index 9db460d40..b9ac4685c 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -27,7 +27,7 @@ <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" } %> - <% unless Security.provider %> + <% unless Security.providers.any? %>

Note: The security prices provider is not configured. Your trade imports will work, but Sure will not backfill price history. Please go to your settings to configure this. diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb index 50d35b9bb..79f8263e0 100644 --- a/app/views/securities/_combobox_security.turbo_stream.erb +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -9,18 +9,28 @@ <%= t("securities.combobox.exchange_label", symbol: combobox_security.symbol, exchange: combobox_security.exchange_name) %> + <% if combobox_security.price_provider.present? %> + · <%= t("securities.providers.#{combobox_security.price_provider}", default: combobox_security.price_provider.humanize) %> + <% end %>

- <% 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", - alt: "#{combobox_security.country_code.upcase} flag", - title: combobox_security.country_code.upcase) %> - - <%= combobox_security.country_code.upcase %> +
+ <% if combobox_security.currency.present? %> + + <%= combobox_security.currency %> -
- <% end %> + <% end %> + <% 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", + alt: "#{combobox_security.country_code.upcase} flag", + title: combobox_security.country_code.upcase) %> + + <%= combobox_security.country_code.upcase %> + +
+ <% end %> +
diff --git a/app/views/settings/hostings/_alpha_vantage_settings.html.erb b/app/views/settings/hostings/_alpha_vantage_settings.html.erb new file mode 100644 index 000000000..47c67eff5 --- /dev/null +++ b/app/views/settings/hostings/_alpha_vantage_settings.html.erb @@ -0,0 +1,46 @@ +
+
+

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

+ <% if ENV["ALPHA_VANTAGE_API_KEY"].present? %> +

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

+ <% else %> +
+ <%= t(".description") %> +
+ <%= t(".show_details") %> +
    +
  1. <%= t(".step_1_html") %>
  2. +
  3. <%= t(".step_2") %>
  4. +
+
+
+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <% has_key = ENV["ALPHA_VANTAGE_API_KEY"].present? || Setting.alpha_vantage_api_key.present? %> + <%= form.text_field :alpha_vantage_api_key, + label: t(".label"), + type: "password", + placeholder: t(".placeholder"), + value: has_key ? "********" : "", + disabled: ENV["ALPHA_VANTAGE_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + <% end %> + +
+
+ <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> +
+

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

+

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

+
+
+
+
diff --git a/app/views/settings/hostings/_eodhd_settings.html.erb b/app/views/settings/hostings/_eodhd_settings.html.erb new file mode 100644 index 000000000..e9ff03bba --- /dev/null +++ b/app/views/settings/hostings/_eodhd_settings.html.erb @@ -0,0 +1,46 @@ +
+
+

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

+ <% if ENV["EODHD_API_KEY"].present? %> +

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

+ <% else %> +
+ <%= t(".description") %> +
+ <%= t(".show_details") %> +
    +
  1. <%= t(".step_1_html") %>
  2. +
  3. <%= t(".step_2_html") %>
  4. +
  5. <%= t(".step_3") %>
  6. +
+
+
+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <% has_key = ENV["EODHD_API_KEY"].present? || Setting.eodhd_api_key.present? %> + <%= form.text_field :eodhd_api_key, + label: t(".label"), + type: "password", + placeholder: t(".placeholder"), + value: has_key ? "********" : "", + disabled: ENV["EODHD_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + <% end %> + +
+
+ <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> +

+ <%= t(".rate_limit_warning") %> +

+
+
+
diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 55dde1e88..1ca2da305 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -1,17 +1,16 @@ -
-
-

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

-

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

-
+
+ <%# Exchange Rate Provider - single dropdown %> +
+

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

+

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

- <%= styled_form_with model: Setting.new, - url: settings_hosting_path, - method: :patch, - data: { - controller: "auto-submit-form", - "auto-submit-form-trigger-event-value": "change" - } do |form| %> -
+ <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "change" + } do |form| %> <%= form.select :exchange_rate_provider, [ [t(".providers.twelve_data"), "twelve_data"], @@ -23,29 +22,61 @@ disabled: ENV["EXCHANGE_RATE_PROVIDER"].present?, data: { "auto-submit-form-target": "auto" } } %> + <% end %> +
- <%= form.select :securities_provider, - [ - [t(".providers.twelve_data"), "twelve_data"], - [t(".providers.yahoo_finance"), "yahoo_finance"] - ], - { label: t(".securities_provider_label") }, - { - value: ENV.fetch("SECURITIES_PROVIDER", Setting.securities_provider), - disabled: ENV["SECURITIES_PROVIDER"].present?, - data: { "auto-submit-form-target": "auto" } - } %> -
+ <%# Securities Providers - multiple checkboxes %> +
+

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

+

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

- <% if ENV["EXCHANGE_RATE_PROVIDER"].present? || ENV["SECURITIES_PROVIDER"].present? %> -
-
- <%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %> -

- <%= t(".env_configured_message") %> -

-
+ <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form" + } do |form| %> + <% disabled = ENV["SECURITIES_PROVIDERS"].present? || ENV["SECURITIES_PROVIDER"].present? %> + <% enabled_providers = Setting.enabled_securities_providers %> + +
+ <%# Hidden field to ensure empty array is submitted when all unchecked %> + + + <% [ + ["twelve_data", t(".providers.twelve_data"), t(".twelve_data_hint")], + ["yahoo_finance", t(".providers.yahoo_finance"), t(".yahoo_finance_hint")], + ["tiingo", t(".providers.tiingo"), t(".requires_api_key")], + ["eodhd", t(".providers.eodhd"), t(".requires_api_key_eodhd")], + ["alpha_vantage", t(".providers.alpha_vantage"), t(".requires_api_key_alpha_vantage")], + ["mfapi", t(".providers.mfapi"), t(".mfapi_hint")], + ].each do |value, label, hint| %> + + <% end %>
<% end %> +
+ + <% if ENV["EXCHANGE_RATE_PROVIDER"].present? || ENV["SECURITIES_PROVIDERS"].present? || ENV["SECURITIES_PROVIDER"].present? %> +
+
+ <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5 shrink-0") %> +

+ <%= t(".env_configured_message") %> +

+
+
<% end %>
diff --git a/app/views/settings/hostings/_tiingo_settings.html.erb b/app/views/settings/hostings/_tiingo_settings.html.erb new file mode 100644 index 000000000..af2cf0ff8 --- /dev/null +++ b/app/views/settings/hostings/_tiingo_settings.html.erb @@ -0,0 +1,37 @@ +
+
+

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

+ <% if ENV["TIINGO_API_KEY"].present? %> +

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

+ <% else %> +
+ <%= t(".description") %> +
+ <%= t(".show_details") %> +
    +
  1. <%= t(".step_1_html") %>
  2. +
  3. <%= t(".step_2_html") %>
  4. +
  5. <%= t(".step_3") %>
  6. +
+
+
+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <% has_key = ENV["TIINGO_API_KEY"].present? || Setting.tiingo_api_key.present? %> + <%= form.text_field :tiingo_api_key, + label: t(".label"), + type: "password", + placeholder: t(".placeholder"), + value: has_key ? "********" : "", + disabled: ENV["TIINGO_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + <% end %> +
diff --git a/app/views/settings/hostings/_twelve_data_settings.html.erb b/app/views/settings/hostings/_twelve_data_settings.html.erb index b6275491a..f5d725753 100644 --- a/app/views/settings/hostings/_twelve_data_settings.html.erb +++ b/app/views/settings/hostings/_twelve_data_settings.html.erb @@ -7,17 +7,11 @@
<%= t(".description") %>
- (show details) + <%= t(".show_details") %>
    -
  1. - Visit twelvedata.com and create a free Twelve Data Developer account. -
  2. -
  3. - Go to the API Keys page. -
  4. -
  5. - Reveal your Secret Key and paste it below. -
  6. +
  7. <%= t(".step_1_html") %>
  8. +
  9. <%= t(".step_2_html") %>
  10. +
  11. <%= t(".step_3") %>
@@ -31,11 +25,12 @@ controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %> + <% has_key = ENV["TWELVE_DATA_API_KEY"].present? || Setting.twelve_data_api_key.present? %> <%= form.text_field :twelve_data_api_key, label: t(".label"), type: "password", placeholder: t(".placeholder"), - value: ENV.fetch("TWELVE_DATA_API_KEY", Setting.twelve_data_api_key), + value: has_key ? "********" : "", disabled: ENV["TWELVE_DATA_API_KEY"].present?, container_class: @twelve_data_usage.present? && !@twelve_data_usage.success? ? "border-red-500" : "", data: { "auto-submit-form-target": "auto" } %> diff --git a/app/views/settings/hostings/_yahoo_finance_settings.html.erb b/app/views/settings/hostings/_yahoo_finance_settings.html.erb index 33676f547..2d9c0bb0e 100644 --- a/app/views/settings/hostings/_yahoo_finance_settings.html.erb +++ b/app/views/settings/hostings/_yahoo_finance_settings.html.erb @@ -20,16 +20,12 @@ <%= t(".status_inactive") %>

-
+
- <%= icon("alert-circle", class: "w-5 h-5 text-destructive-600 mt-0.5 shrink-0") %> + <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5 shrink-0") %>
-

- <%= t(".connection_failed") %> -

-
-

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

-
+

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

+

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

diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 354cf86a4..6312f900e 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -17,6 +17,15 @@ <% if @show_twelve_data_settings %> <%= render "settings/hostings/twelve_data_settings" %> <% end %> + <% if @show_tiingo_settings %> + <%= render "settings/hostings/tiingo_settings" %> + <% end %> + <% if @show_eodhd_settings %> + <%= render "settings/hostings/eodhd_settings" %> + <% end %> + <% if @show_alpha_vantage_settings %> + <%= render "settings/hostings/alpha_vantage_settings" %> + <% end %>
<% end %> <%= settings_section title: t(".sync_settings") do %> diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 8ef25151f..386b7ebe6 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -9,8 +9,8 @@
data-money-field-precision-value="<%= options[:precision] %>"<% end %> - <% if options[:step].present? %>data-money-field-step-value="<%= options[:step] %>"<% end %>> + <% if options[:precision].present? %> data-money-field-precision-value="<%= options[:precision] %>"<% end %> + <% if options[:step].present? %> data-money-field-step-value="<%= options[:step] %>"<% end %>> <% if options[:label_tooltip] %>
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %> diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index 600dde3b9..31038f259 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -25,7 +25,7 @@ }} %> <% if %w[buy sell].include?(type) %> - <% if Security.provider.present? %> + <% if Security.providers.any? %>
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 7e1c222c7..e581d60e3 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -8,6 +8,14 @@ <% trade = @entry.trade %> <% dialog.with_body do %> <%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_trade_path(trade) %> + <% if trade.security&.provider_status == :provider_unavailable %> +
+ <%= render DS::Alert.new( + message: t(".provider_disabled_warning", provider: trade.security.price_provider&.humanize || "Unknown"), + variant: :warning + ) %> +
+ <% end %> <% dialog.with_section(title: t(".details"), open: true) do %>
<%= styled_form_with model: @entry, diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 071d2d3e3..8366388cb 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -26,7 +26,7 @@ <% end %>
-
+
"> <%= content_tag :div, class: ["flex items-center gap-3 lg:gap-4"] do %> -
+
"> <%# Protection indicator - shows on hover when entry is protected from sync %> <% if entry.protected_from_sync? && !entry.excluded? %> <%= link_to entry_path(entry), diff --git a/app/views/transactions/convert_to_trade.html.erb b/app/views/transactions/convert_to_trade.html.erb index 631a5c9ab..5c0f97270 100644 --- a/app/views/transactions/convert_to_trade.html.erb +++ b/app/views/transactions/convert_to_trade.html.erb @@ -33,7 +33,7 @@
<%= f.label :security_id, t(".security_label"), class: "font-medium text-sm text-primary block" %> - <% if Security.provider.present? %> + <% if Security.providers.any? %> <%# Always use searchable combobox when provider available - prevents picking wrong similar tickers %>
<%= f.combobox :ticker, @@ -157,7 +157,7 @@
<%# Only show exchange field when no provider - combobox selections already include exchange %> - <% unless Security.provider.present? %> + <% unless Security.providers.any? %>
<%= f.label :exchange_operating_mic, t(".exchange_label"), class: "font-medium text-sm text-primary block" %> <%= f.text_field :exchange_operating_mic, diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml index 4fe40c9e6..6ccfc36c6 100644 --- a/config/locales/views/holdings/en.yml +++ b/config/locales/views/holdings/en.yml @@ -71,6 +71,10 @@ en: search_security_placeholder: Search by ticker or name cancel: Cancel remap_security: Save + provider_disabled_warning: "Price updates paused — %{provider} provider is disabled. Switch to another provider below or re-enable it in Settings." + switch_provider_label: Switch provider + switch_provider_description: "%{provider} is disabled. Search for this security from another enabled provider." + switch_provider_button: Switch no_security_provider: Security provider not configured. Cannot search for securities. security_remapped_label: Security remapped provider_sent: "Provider sent: %{ticker}" diff --git a/config/locales/views/securities/en.yml b/config/locales/views/securities/en.yml index 078dfd0d0..6df2a3102 100644 --- a/config/locales/views/securities/en.yml +++ b/config/locales/views/securities/en.yml @@ -4,3 +4,10 @@ en: combobox: display: "%{symbol} - %{name} (%{exchange})" exchange_label: "%{symbol} (%{exchange})" + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 814d0b13c..4446e3353 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -31,14 +31,25 @@ en: title: Clear data cache? body: Are you sure you want to clear the data cache? This will remove all exchange rates, security prices, account balances, and other data. This action cannot be undone. provider_selection: - title: Provider Selection - description: Choose which service to use for fetching exchange rates and security prices. Yahoo Finance is free and doesn't require an API key. Twelve Data requires a free API key but may offer more data coverage. + exchange_rate_title: Exchange Rate Provider + exchange_rate_description: Select a single provider for fetching currency exchange rates. exchange_rate_provider_label: Exchange Rate Provider - securities_provider_label: Securities (Stock Prices) Provider - env_configured_message: Provider selection is disabled because environment variables (EXCHANGE_RATE_PROVIDER or SECURITIES_PROVIDER) are set. To enable selection here, remove these environment variables from your configuration. + securities_title: Securities Providers + securities_description: Enable one or more providers for fetching stock, ETF, and mutual fund prices. When searching, all enabled providers are queried and results are merged. + env_configured_message: Provider selection is disabled because environment variables are set. To enable selection here, remove these environment variables from your configuration. + twelve_data_hint: requires API key, 800 credits/day + yahoo_finance_hint: free, no API key needed + requires_api_key: requires API key + requires_api_key_eodhd: requires API key, 20 calls/day limit + requires_api_key_alpha_vantage: requires API key, 25 calls/day limit + mfapi_hint: free, no API key -- Indian mutual funds only providers: twelve_data: Twelve Data yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in assistant_settings: title: AI Assistant description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP. @@ -95,12 +106,48 @@ en: status_inactive: Yahoo Finance connection failed connection_failed: Unable to connect to Yahoo Finance troubleshooting: Check your internet connection and firewall settings. Yahoo Finance may be temporarily unavailable. + tiingo_settings: + title: Tiingo + description: Enter the API token provided by Tiingo. Free tier supports 50 unique symbols per hour with 30+ years of historical data. + env_configured_message: Successfully configured through the TIINGO_API_KEY environment variable. + label: API Token + placeholder: Enter your Tiingo API token here + show_details: "(show details)" + step_1_html: 'Visit tiingo.com and create a free account.' + step_2_html: 'Go to the API Token page.' + step_3: Copy your API token and paste it below. + eodhd_settings: + title: EODHD + description: Enter the API token provided by EODHD. Supports EU ETFs on LSE, XETRA, and other international exchanges. + env_configured_message: Successfully configured through the EODHD_API_KEY environment variable. + label: API Token + placeholder: Enter your EODHD API token here + show_details: "(show details)" + step_1_html: 'Visit eodhd.com and create a free account.' + step_2_html: 'Go to your Dashboard to find your API token.' + step_3: Copy your API token and paste it below. + rate_limit_warning: "EODHD free tier is limited to 20 API calls per day. Best used as a supplementary provider for EU ETFs not available from other providers." + alpha_vantage_settings: + title: Alpha Vantage + description: Enter the API key from Alpha Vantage. Supports EU ETFs on London Stock Exchange, XETRA, and other exchanges. + env_configured_message: Successfully configured through the ALPHA_VANTAGE_API_KEY environment variable. + label: API Key + placeholder: Enter your Alpha Vantage API key here + show_details: "(show details)" + step_1_html: 'Visit alphavantage.co and claim your free API key.' + step_2: Copy the API key and paste it below. + rate_limit_warning: "Alpha Vantage free tier is limited to 25 API calls per day. Best used as a supplementary provider for EU ETFs not available from other providers." + no_health_check_note: "Connection health check is unavailable for this provider due to the strict rate limit." twelve_data_settings: api_calls_used: "%{used} / %{limit} API daily calls used (%{percentage})" description: Enter the API key provided by Twelve Data env_configured_message: Successfully configured through the TWELVE_DATA_API_KEY environment variable. label: API Key placeholder: Enter your API key here + show_details: "(show details)" + step_1_html: 'Visit twelvedata.com and create a free Twelve Data Developer account.' + step_2_html: 'Go to the API Keys page.' + step_3: Reveal your Secret Key and paste it below. plan: "%{plan} plan" plan_upgrade_warning_title: Some tickers require a paid plan plan_upgrade_warning_description: The following tickers in your portfolio cannot sync prices with your current Twelve Data plan. diff --git a/config/locales/views/trades/en.yml b/config/locales/views/trades/en.yml index b2257c26f..f8e1fb418 100644 --- a/config/locales/views/trades/en.yml +++ b/config/locales/views/trades/en.yml @@ -45,6 +45,7 @@ en: delete_subtitle: This action cannot be undone delete_title: Delete Trade details: Details + provider_disabled_warning: "Price updates paused — %{provider} provider is disabled. Re-enable it in Settings or remap the holding to another provider." exclude_subtitle: This trade will not be included in reports and calculations exclude_title: Exclude from analytics no_category: No category diff --git a/db/migrate/20260408120000_add_price_provider_to_securities.rb b/db/migrate/20260408120000_add_price_provider_to_securities.rb new file mode 100644 index 000000000..7e6da11b9 --- /dev/null +++ b/db/migrate/20260408120000_add_price_provider_to_securities.rb @@ -0,0 +1,8 @@ +class AddPriceProviderToSecurities < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_column :securities, :price_provider, :string + add_index :securities, :price_provider, algorithm: :concurrently + end +end diff --git a/db/migrate/20260408151837_add_offline_reason_to_securities.rb b/db/migrate/20260408151837_add_offline_reason_to_securities.rb new file mode 100644 index 000000000..2e5edfe02 --- /dev/null +++ b/db/migrate/20260408151837_add_offline_reason_to_securities.rb @@ -0,0 +1,8 @@ +class AddOfflineReasonToSecurities < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_column :securities, :offline_reason, :string + add_index :securities, [ :price_provider, :offline_reason ], algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 3e83968f7..24b067211 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do +ActiveRecord::Schema[7.2].define(version: 2026_04_08_151837) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1215,10 +1215,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do t.datetime "last_health_check_at" t.string "website_url" t.string "kind", default: "standard", null: false + t.string "price_provider" + t.string "offline_reason" t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" t.index ["kind"], name: "index_securities_on_kind" + t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" + t.index ["price_provider"], name: "index_securities_on_price_provider" t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index f4706c07c..c38585bc3 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -247,4 +247,71 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert] end end + + # --- Securities provider toggle --- + + test "can update securities providers" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "yahoo_finance" ] } } + + assert_redirected_to settings_hosting_url + assert_equal "twelve_data,yahoo_finance", Setting.securities_providers + end + ensure + Setting.securities_providers = "" + end + + test "filters out invalid provider names" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "fake_provider", "hacked" ] } } + + assert_redirected_to settings_hosting_url + # Only valid providers are stored + enabled = Setting.enabled_securities_providers + assert_includes enabled, "twelve_data" + refute_includes enabled, "fake_provider" + refute_includes enabled, "hacked" + end + ensure + Setting.securities_providers = "" + end + + test "removing a provider marks linked securities offline" do + with_self_hosting do + security = Security.create!(ticker: "CSPX", exchange_operating_mic: "XLON", price_provider: "tiingo", offline: false) + + # First enable tiingo + Setting.securities_providers = "twelve_data,tiingo" + + # Then remove tiingo + patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data" ] } } + + security.reload + assert security.offline?, "Security should be marked offline when its provider is removed" + assert_equal "provider_disabled", security.offline_reason + end + ensure + Setting.securities_providers = "" + end + + test "re-adding a provider brings securities back online" do + with_self_hosting do + security = Security.create!( + ticker: "CSPX2", exchange_operating_mic: "XLON", + price_provider: "tiingo", offline: true, offline_reason: "provider_disabled" + ) + + # Start without tiingo + Setting.securities_providers = "twelve_data" + + # Re-add tiingo + patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "tiingo" ] } } + + security.reload + refute security.offline?, "Security should come back online when its provider is re-added" + assert_nil security.offline_reason + end + ensure + Setting.securities_providers = "" + end end diff --git a/test/models/security/health_checker_test.rb b/test/models/security/health_checker_test.rb index a8efe8f7d..aef6a6c7a 100644 --- a/test/models/security/health_checker_test.rb +++ b/test/models/security/health_checker_test.rb @@ -11,7 +11,7 @@ class Security::HealthCheckerTest < ActiveSupport::TestCase Security.delete_all @provider = mock - Security.stubs(:provider).returns(@provider) + Security.any_instance.stubs(:price_data_provider).returns(@provider) # Brand new, no health check has been run yet @new_security = Security.create!( diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index 6e8e49da1..8214831ac 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -6,9 +6,8 @@ class Security::PriceTest < ActiveSupport::TestCase setup do @provider = mock - Security.stubs(:provider).returns(@provider) - @security = securities(:aapl) + @security.stubs(:price_data_provider).returns(@provider) end test "finds single security price in DB" do diff --git a/test/models/security/provided_test.rb b/test/models/security/provided_test.rb new file mode 100644 index 000000000..521a249ba --- /dev/null +++ b/test/models/security/provided_test.rb @@ -0,0 +1,295 @@ +require "test_helper" + +class Security::ProvidedTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @security = securities(:aapl) + end + + # --- search_provider --- + + test "search_provider returns results from multiple providers" do + provider_a = mock("provider_a") + provider_b = mock("provider_b") + + result_a = Provider::SecurityConcept::Security.new( + symbol: "AAPL", name: "Apple Inc", logo_url: nil, + exchange_operating_mic: "XNAS", country_code: "US", currency: "USD" + ) + result_b = Provider::SecurityConcept::Security.new( + symbol: "AAPL", name: "Apple Inc", logo_url: nil, + exchange_operating_mic: "XNAS", country_code: "US", currency: "USD" + ) + + provider_a.stubs(:class).returns(Provider::TwelveData) + provider_b.stubs(:class).returns(Provider::YahooFinance) + + provider_a.expects(:search_securities).with("AAPL").returns( + provider_success_response([ result_a ]) + ) + provider_b.expects(:search_securities).with("AAPL").returns( + provider_success_response([ result_b ]) + ) + + Security.stubs(:providers).returns([ provider_a, provider_b ]) + + results = Security.search_provider("AAPL") + + # Same ticker+exchange but different providers → both appear (dedup includes provider key) + assert_equal 2, results.size + assert results.all? { |s| s.ticker == "AAPL" } + providers_in_results = results.map(&:price_provider).sort + assert_includes providers_in_results, "twelve_data" + assert_includes providers_in_results, "yahoo_finance" + end + + test "search_provider deduplicates same ticker+exchange+provider" do + provider = mock("provider") + provider.stubs(:class).returns(Provider::TwelveData) + + dup_result = Provider::SecurityConcept::Security.new( + symbol: "MSFT", name: "Microsoft", logo_url: nil, + exchange_operating_mic: "XNAS", country_code: "US", currency: "USD" + ) + + provider.expects(:search_securities).with("MSFT").returns( + provider_success_response([ dup_result, dup_result ]) + ) + + Security.stubs(:providers).returns([ provider ]) + + results = Security.search_provider("MSFT") + assert_equal 1, results.size + end + + test "search_provider returns empty array for blank symbol" do + assert_equal [], Security.search_provider("") + assert_equal [], Security.search_provider(nil) + end + + test "search_provider returns empty array when no providers configured" do + Security.stubs(:providers).returns([]) + assert_equal [], Security.search_provider("AAPL") + end + + test "search_provider keeps fast provider results when slow provider times out" do + fast_provider = mock("fast_provider") + slow_provider = mock("slow_provider") + + fast_provider.stubs(:class).returns(Provider::TwelveData) + slow_provider.stubs(:class).returns(Provider::YahooFinance) + + fast_result = Provider::SecurityConcept::Security.new( + symbol: "SPY", name: "SPDR S&P 500", logo_url: nil, + exchange_operating_mic: "XNAS", country_code: "US", currency: "USD" + ) + + fast_provider.expects(:search_securities).with("SPY").returns( + provider_success_response([ fast_result ]) + ) + slow_provider.expects(:search_securities).with("SPY").returns( + provider_success_response([]) + ) + + Security.stubs(:providers).returns([ fast_provider, slow_provider ]) + + results = Security.search_provider("SPY") + + assert results.size >= 1, "Fast provider results should be returned even if slow provider returns nothing" + assert_equal "SPY", results.first.ticker + end + + test "search_provider handles provider error gracefully" do + good_provider = mock("good_provider") + bad_provider = mock("bad_provider") + + good_provider.stubs(:class).returns(Provider::TwelveData) + bad_provider.stubs(:class).returns(Provider::YahooFinance) + + good_result = Provider::SecurityConcept::Security.new( + symbol: "GOOG", name: "Alphabet", logo_url: nil, + exchange_operating_mic: "XNAS", country_code: "US", currency: "USD" + ) + + good_provider.expects(:search_securities).with("GOOG").returns( + provider_success_response([ good_result ]) + ) + bad_provider.expects(:search_securities).with("GOOG").raises(StandardError, "API down") + + Security.stubs(:providers).returns([ good_provider, bad_provider ]) + + results = Security.search_provider("GOOG") + + assert_equal 1, results.size + assert_equal "GOOG", results.first.ticker + end + + # --- price_data_provider --- + + test "price_data_provider returns assigned provider" do + provider = mock("tiingo_provider") + Security.stubs(:provider_for).with("tiingo").returns(provider) + + @security.update!(price_provider: "tiingo") + + assert_equal provider, @security.price_data_provider + end + + test "price_data_provider returns nil when assigned provider is disabled" do + Security.stubs(:provider_for).with("tiingo").returns(nil) + + @security.update!(price_provider: "tiingo") + + assert_nil @security.price_data_provider + end + + test "price_data_provider falls back to first provider when none assigned" do + fallback_provider = mock("fallback") + Security.stubs(:providers).returns([ fallback_provider ]) + + @security.update!(price_provider: nil) + + assert_equal fallback_provider, @security.price_data_provider + end + + # --- provider_status --- + + test "provider_status returns provider_unavailable when assigned provider disabled" do + Security.stubs(:provider_for).with("tiingo").returns(nil) + + @security.update!(price_provider: "tiingo") + + assert_equal :provider_unavailable, @security.provider_status + end + + test "provider_status returns ok for healthy security" do + provider = mock("provider") + Security.stubs(:provider_for).with("twelve_data").returns(provider) + + @security.update!(price_provider: "twelve_data", offline: false, failed_fetch_count: 0) + + assert_equal :ok, @security.provider_status + end + + # --- rank_search_results --- + + # Helper to build unsaved Security objects for ranking tests + def build_result(ticker:, name: nil, country_code: nil, exchange_operating_mic: nil) + Security.new( + ticker: ticker, + name: name || ticker, + country_code: country_code, + exchange_operating_mic: exchange_operating_mic + ) + end + + def rank(results, query, country_code = nil) + Security.send(:rank_search_results, results, query, country_code) + end + + test "ranking: AAPL exact match ranks above AAPL-prefixed and unrelated" do + results = [ + build_result(ticker: "AAPLX", name: "Some AAPL Fund"), + build_result(ticker: "AAPL", name: "Apple Inc", country_code: "US", exchange_operating_mic: "XNAS"), + build_result(ticker: "AAPL", name: "Apple Inc", country_code: "GB", exchange_operating_mic: "XLON"), + build_result(ticker: "AAPLD", name: "AAPL Dividend ETF") + ] + + ranked = rank(results, "AAPL", "US") + + # Exact matches first, US preferred over GB + assert_equal "AAPL", ranked[0].ticker + assert_equal "US", ranked[0].country_code + assert_equal "AAPL", ranked[1].ticker + assert_equal "GB", ranked[1].country_code + # Prefix matches after + assert ranked[2..].all? { |s| s.ticker.start_with?("AAPL") && s.ticker != "AAPL" } + end + + test "ranking: Apple name search surfaces Apple Inc above unrelated" do + results = [ + build_result(ticker: "PINEAPPLE", name: "Pineapple Corp"), + build_result(ticker: "AAPL", name: "Apple Inc", country_code: "US"), + build_result(ticker: "APLE", name: "Apple Hospitality REIT"), + build_result(ticker: "APPL", name: "Appell Petroleum") + ] + + ranked = rank(results, "Apple", "US") + + # No ticker matches "APPLE", so all fall to name-contains or worse. + # "Apple Inc" and "Apple Hospitality" and "Pineapple" contain "APPLE" in name. + # "Appell Petroleum" does not contain "APPLE". + # Among name matches, alphabetical ticker breaks ties. + name_matches = ranked.select { |s| s.name.upcase.include?("APPLE") } + non_matches = ranked.reject { |s| s.name.upcase.include?("APPLE") } + assert name_matches.size >= 2 + assert_equal non_matches, ranked.last(non_matches.size) + end + + test "ranking: SPX exact match first, then SPX-prefixed" do + results = [ + build_result(ticker: "SPXL", name: "Direxion Daily S&P 500 Bull 3X"), + build_result(ticker: "SPXS", name: "Direxion Daily S&P 500 Bear 3X"), + build_result(ticker: "SPX", name: "S&P 500 Index", country_code: "US"), + build_result(ticker: "SPXU", name: "ProShares UltraPro Short S&P 500") + ] + + ranked = rank(results, "SPX", "US") + + assert_equal "SPX", ranked[0].ticker, "Exact match should be first" + assert ranked[1..].all? { |s| s.ticker.start_with?("SPX") } + end + + test "ranking: VTTI exact match first regardless of country" do + results = [ + build_result(ticker: "VTI", name: "Vanguard Total Stock Market ETF", country_code: "US"), + build_result(ticker: "VTTI", name: "VTTI Energy Partners", country_code: "US"), + build_result(ticker: "VTTIX", name: "Vanguard Target 2060 Fund") + ] + + ranked = rank(results, "VTTI", "US") + + assert_equal "VTTI", ranked[0].ticker, "Exact match should be first" + assert_equal "VTTIX", ranked[1].ticker, "Prefix match second" + assert_equal "VTI", ranked[2].ticker, "Non-matching ticker last" + end + + test "ranking: iShares S&P multi-word query is contiguous substring match" do + results = [ + build_result(ticker: "IVV", name: "iShares S&P 500 ETF", country_code: "US"), + build_result(ticker: "CSPX", name: "iShares Core S&P 500 UCITS ETF", country_code: "GB"), + build_result(ticker: "IJH", name: "iShares S&P Mid-Cap ETF", country_code: "US"), + build_result(ticker: "UNRELATED", name: "Something Else Corp") + ] + + ranked = rank(results, "iShares S&P", "US") + + # Only names containing the exact substring "iShares S&P" match tier 2. + # "iShares Core S&P" does NOT match (word "Core" breaks contiguity). + contiguous_matches = ranked.select { |s| s.name.upcase.include?("ISHARES S&P") } + assert_equal 2, contiguous_matches.size, "Only IVV and IJH contain the exact substring" + # US contiguous matches should come first + assert_equal "IJH", ranked[0].ticker # US, name match, alphabetically before IVV? No... + assert_includes [ "IVV", "IJH" ], ranked[0].ticker + assert_includes [ "IVV", "IJH" ], ranked[1].ticker + # Non-contiguous and unrelated should be last + assert_equal "UNRELATED", ranked.last.ticker + end + + test "ranking: tesla name search finds TSLA" do + results = [ + build_result(ticker: "TSLA", name: "Tesla Inc", country_code: "US"), + build_result(ticker: "TSLA", name: "Tesla Inc", country_code: "DE"), + build_result(ticker: "TL0", name: "Tesla Inc", country_code: "DE", exchange_operating_mic: "XETR"), + build_result(ticker: "TELSA", name: "Telsa Mining Ltd") + ] + + ranked = rank(results, "tesla", "US") + + # No ticker matches "TESLA", so all go to name matching + # "Tesla Inc" contains "TESLA" → tier 2, US preferred + assert_equal "TSLA", ranked[0].ticker + assert_equal "US", ranked[0].country_code, "US Tesla should rank first for US user" + end +end diff --git a/test/models/security/resolver_test.rb b/test/models/security/resolver_test.rb index 1a557f221..fee3b9fd7 100644 --- a/test/models/security/resolver_test.rb +++ b/test/models/security/resolver_test.rb @@ -1,11 +1,6 @@ require "test_helper" class Security::ResolverTest < ActiveSupport::TestCase - setup do - @provider = mock - Security.stubs(:provider).returns(@provider) - end - test "resolves DB security" do # Given an existing security in the DB that exactly matches the lookup params db_security = Security.create!(ticker: "TSLA", exchange_operating_mic: "XNAS", country_code: "US") @@ -75,4 +70,73 @@ class Security::ResolverTest < ActiveSupport::TestCase assert_raises(ArgumentError) { Security::Resolver.new(nil).resolve } assert_raises(ArgumentError) { Security::Resolver.new("").resolve } end + + test "persists explicit price_provider on DB match" do + db_security = Security.create!(ticker: "CSPX", exchange_operating_mic: "XLON", country_code: "GB") + + Security.expects(:search_provider).never + Setting.stubs(:enabled_securities_providers).returns([ "tiingo" ]) + + resolved = Security::Resolver.new( + "CSPX", + exchange_operating_mic: "XLON", + country_code: "GB", + price_provider: "tiingo" + ).resolve + + assert_equal db_security, resolved + assert_equal "tiingo", resolved.reload.price_provider + end + + test "persists price_provider on provider match" do + match = Security.new(ticker: "VWCE", exchange_operating_mic: "XETR", country_code: "DE", price_provider: "eodhd") + + Security.expects(:search_provider) + .with("VWCE", exchange_operating_mic: "XETR") + .returns([ match ]) + + Setting.stubs(:enabled_securities_providers).returns([ "eodhd" ]) + + resolved = Security::Resolver.new( + "VWCE", + exchange_operating_mic: "XETR", + price_provider: "eodhd" + ).resolve + + assert resolved.persisted? + assert_equal "eodhd", resolved.price_provider + end + + test "rejects unknown price_provider" do + db_security = Security.create!(ticker: "AAPL2", exchange_operating_mic: "XNAS", country_code: "US") + + Security.expects(:search_provider).never + + resolved = Security::Resolver.new( + "AAPL2", + exchange_operating_mic: "XNAS", + country_code: "US", + price_provider: "fake_provider" + ).resolve + + assert_equal db_security, resolved + assert_nil resolved.reload.price_provider, "Unknown providers should be rejected" + end + + test "rejects disabled price_provider" do + db_security = Security.create!(ticker: "GOOG2", exchange_operating_mic: "XNAS", country_code: "US") + + Security.expects(:search_provider).never + Setting.stubs(:enabled_securities_providers).returns([ "twelve_data" ]) + + resolved = Security::Resolver.new( + "GOOG2", + exchange_operating_mic: "XNAS", + country_code: "US", + price_provider: "tiingo" + ).resolve + + assert_equal db_security, resolved + assert_nil resolved.reload.price_provider, "Disabled providers should be rejected" + end end