From 0aca297e9cd152286444240e389555a625218979 Mon Sep 17 00:00:00 2001 From: soky srm Date: Fri, 10 Apr 2026 15:43:22 +0200 Subject: [PATCH] Add binance security provider for crypto (#1424) * Binance as securities provider * Disable twelve data crypto results * Add logo support and new currency pairs * FIX importer fallback * Add price clamping and optiimize retrieval * Review * Update adding-a-securities-provider.md * day gap miss fix * New fixes * Brandfetch doesn't support crypto. add new CDN * Update _investment_performance.html.erb --- AGENTS.md | 4 + app/models/provider/binance_public.rb | 320 +++++++++ app/models/provider/registry.rb | 6 +- app/models/provider/twelve_data.rb | 12 +- app/models/security.rb | 42 +- app/models/security/price/importer.rb | 89 ++- app/models/security/resolver.rb | 15 +- app/views/holdings/_holding.html.erb | 6 +- app/views/holdings/show.html.erb | 16 +- .../reports/_investment_performance.html.erb | 6 +- .../hostings/_provider_selection.html.erb | 1 + config/exchanges.yml | 5 + config/locales/views/holdings/en.yml | 1 + config/locales/views/securities/en.yml | 1 + config/locales/views/settings/hostings/en.yml | 2 + ...d_first_provider_price_on_to_securities.rb | 5 + db/schema.rb | 3 +- .../adding-a-securities-provider.md | 493 ++++++++++++++ test/models/provider/binance_public_test.rb | 608 ++++++++++++++++++ test/models/provider/twelve_data_test.rb | 71 ++ test/models/security/price/importer_test.rb | 239 +++++++ test/models/security/resolver_test.rb | 69 ++ test/models/security_test.rb | 102 +++ 23 files changed, 2091 insertions(+), 25 deletions(-) create mode 100644 app/models/provider/binance_public.rb create mode 100644 db/migrate/20260410114435_add_first_provider_price_on_to_securities.rb create mode 100644 docs/llm-guides/adding-a-securities-provider.md create mode 100644 test/models/provider/binance_public_test.rb diff --git a/AGENTS.md b/AGENTS.md index d3e52c168..30637862d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,10 @@ When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST* ### Post-commit API consistency (LLM checklist) After every API endpoint commit, ensure: (1) **Minitest** behavioral coverage in `test/controllers/api/v1/{resource}_controller_test.rb` (no behavioral assertions in rswag); (2) **rswag** remains docs-only (no `expect`/`assert_*` in `spec/requests/api/v1/`); (3) **rswag auth** uses the same API key pattern everywhere (`X-Api-Key`, not OAuth/Bearer). Full checklist: [.cursor/rules/api-endpoint-consistency.mdc](.cursor/rules/api-endpoint-consistency.mdc). +## Securities Providers + +If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests. + ## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection diff --git a/app/models/provider/binance_public.rb b/app/models/provider/binance_public.rb new file mode 100644 index 000000000..37c263f38 --- /dev/null +++ b/app/models/provider/binance_public.rb @@ -0,0 +1,320 @@ +class Provider::BinancePublic < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + MIN_REQUEST_INTERVAL = 0.1 + + # Binance's official ISO 10383 operating MIC (assigned Jan 2026, country AE). + # Crypto is not tied to a national jurisdiction, so we intentionally do NOT + # propagate the ISO-assigned country code to search results — the resolver + # treats a nil candidate country as a wildcard, letting any family resolve + # a Binance pick regardless of their own country. + BINANCE_MIC = "BNCX".freeze + + # Quote assets we expose in search results. Order = preference when multiple + # quote variants exist for the same base asset. USDT is Binance's dominant + # dollar quote and is surfaced to users as USD. GBP is absent because + # Binance has zero GBP trading pairs today; GBP-family users fall back to + # USDT->USD via the app's FX conversion, same as HUF/CZK/PLN users. + SUPPORTED_QUOTES = %w[USDT EUR JPY BRL TRY].freeze + + # Binance quote asset -> user-facing currency & ticker suffix. + QUOTE_TO_CURRENCY = { + "USDT" => "USD", + "EUR" => "EUR", + "JPY" => "JPY", + "BRL" => "BRL", + "TRY" => "TRY" + }.freeze + + # Per-asset logo PNGs served via jsDelivr from a GitHub repo that tracks the + # full Binance-listed asset set. We originally used bin.bnbstatic.com directly + # — Binance's own CDN — but that host enforces Referer-based hotlink + # protection at CloudFront: any request with a non-Binance Referer returns + # 403. A server-side HEAD from Faraday (no Referer) succeeds, which masked + # the breakage until the URL hit an actual tag in the browser. jsDelivr + # is CORS-open and hotlink-friendly, so the URL we persist is the URL the + # browser can actually load. File names are uppercase PNGs (BTC.png, ETH.png). + LOGO_CDN_BASE = "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img".freeze + + KLINE_MAX_LIMIT = 1000 + MS_PER_DAY = 24 * 60 * 60 * 1000 + SEARCH_LIMIT = 25 + + def initialize + # No API key required — public market data only. + end + + def healthy? + with_provider_response do + client.get("#{base_url}/api/v3/ping") + true + 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 + query = symbol.to_s.strip.upcase + next [] if query.empty? + + symbols = exchange_info_symbols + + matches = symbols.select do |s| + base = s["baseAsset"].to_s.upcase + quote = s["quoteAsset"].to_s.upcase + symbol = s["symbol"].to_s.upcase + + next false unless SUPPORTED_QUOTES.include?(quote) + + # Match on either the base asset (so "BTC" surfaces every BTC pair) or + # the full Binance pair symbol (so users pasting their own portfolio + # ticker like "BTCEUR" or "BTCUSD" — which prefixes Binance's raw + # "BTCUSDT" — also hit a result). + base.include?(query) || symbol == query || symbol.start_with?(query) + end + + ranked = matches.sort_by do |s| + base = s["baseAsset"].to_s.upcase + quote = s["quoteAsset"].to_s.upcase + symbol = s["symbol"].to_s.upcase + quote_index = SUPPORTED_QUOTES.index(quote) || 99 + + relevance = if symbol == query + 0 # exact full-ticker match — highest priority + elsif symbol.start_with?(query) + 1 # ticker prefix match (e.g. "BTCUSD" against "BTCUSDT") + elsif base == query + 2 # exact base-asset match (e.g. "BTC") + elsif base.start_with?(query) + 3 + else + 4 + end + + [ relevance, quote_index, base ] + end + + ranked.first(SEARCH_LIMIT).map do |s| + base = s["baseAsset"].to_s.upcase + quote = s["quoteAsset"].to_s.upcase + display_currency = QUOTE_TO_CURRENCY[quote] + + Security.new( + symbol: "#{base}#{display_currency}", + name: base, + logo_url: "#{LOGO_CDN_BASE}/#{base}.png", + exchange_operating_mic: BINANCE_MIC, + country_code: nil, + currency: display_currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + parsed = parse_ticker(symbol) + raise Error, "Unsupported Binance ticker: #{symbol}" if parsed.nil? + + SecurityInfo.new( + symbol: symbol, + name: parsed[:base], + links: "https://www.binance.com/en/trade/#{parsed[:binance_pair]}", + logo_url: verified_logo_url(parsed[:base]), + description: nil, + kind: "crypto", + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic:, date:) + with_provider_response do + historical = fetch_security_prices( + symbol: symbol, + exchange_operating_mic: exchange_operating_mic, + start_date: date, + end_date: date + ) + + raise historical.error if historical.error.present? + raise InvalidSecurityPriceError, "No price found for #{symbol} on #{date}" if historical.data.blank? + + historical.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) + with_provider_response do + parsed = parse_ticker(symbol) + raise InvalidSecurityPriceError, "Unsupported Binance ticker: #{symbol}" if parsed.nil? + + binance_pair = parsed[:binance_pair] + display_currency = parsed[:display_currency] + prices = [] + cursor = start_date + seen_data = false + + while cursor <= end_date + window_end = [ cursor + (KLINE_MAX_LIMIT - 1).days, end_date ].min + + throttle_request + response = client.get("#{base_url}/api/v3/klines") do |req| + req.params["symbol"] = binance_pair + req.params["interval"] = "1d" + req.params["startTime"] = date_to_ms(cursor) + req.params["endTime"] = date_to_ms(window_end) + MS_PER_DAY - 1 + req.params["limit"] = KLINE_MAX_LIMIT + end + + batch = JSON.parse(response.body) + + if batch.empty? + # Empty window. Two cases: + # 1. cursor is before the pair's listing date — keep advancing + # until we hit the first window containing valid klines. + # Critical for long-range imports (e.g. account sync from a + # trade start date that predates the Binance listing). + # 2. We have already collected prices and this window is past + # the end of available history — stop to avoid wasted calls + # on delisted pairs. + break if seen_data + else + seen_data = true + batch.each do |row| + open_time_ms = row[0].to_i + close_price = row[4].to_f + next if close_price <= 0 + + prices << Price.new( + symbol: symbol, + date: Time.at(open_time_ms / 1000).utc.to_date, + price: close_price, + currency: display_currency, + exchange_operating_mic: exchange_operating_mic + ) + end + end + + # Note: we intentionally do NOT break on a short (non-empty) batch. + # A window that straddles the pair's listing date legitimately returns + # fewer than KLINE_MAX_LIMIT rows while there is still valid data in + # subsequent windows. + cursor = window_end + 1.day + end + + prices + end + end + + private + def base_url + ENV["BINANCE_PUBLIC_URL"] || "https://data-api.binance.vision" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + # Explicit timeouts so a hanging Binance endpoint can't stall a Sidekiq + # worker or Puma thread indefinitely. Values are deliberately generous + # enough for a full 1000-row klines response but capped to bound the + # worst-case retry chain (3 attempts * 20s + backoff ~= 65s). + faraday.options.open_timeout = 5 + faraday.options.timeout = 20 + + faraday.request(:retry, { + max: 3, + interval: 0.5, + 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 + + # Maps a user-visible ticker (e.g. "BTCUSD", "ETHEUR") to the Binance pair + # symbol, base asset, and display currency. Returns nil if the ticker does + # not end with a supported quote currency. + def parse_ticker(ticker) + ticker_up = ticker.to_s.upcase + SUPPORTED_QUOTES.each do |quote| + display_currency = QUOTE_TO_CURRENCY[quote] + next unless ticker_up.end_with?(display_currency) + + base = ticker_up.delete_suffix(display_currency) + next if base.empty? + + return { binance_pair: "#{base}#{quote}", base: base, display_currency: display_currency } + end + nil + end + + # Cached for 24h — exchangeInfo returns the full symbol universe (thousands + # of rows, weight 10) and rarely changes. + def exchange_info_symbols + Rails.cache.fetch("binance_public:exchange_info", expires_in: 24.hours) do + throttle_request + response = client.get("#{base_url}/api/v3/exchangeInfo") + parsed = JSON.parse(response.body) + (parsed["symbols"] || []).select { |s| s["status"] == "TRADING" } + end + end + + def date_to_ms(date) + Time.utc(date.year, date.month, date.day).to_i * 1000 + end + + # Returns the asset-specific jsDelivr logo URL if the HEAD succeeds, else + # nil. Returning nil (rather than a hard-coded fallback URL) lets + # Security#display_logo_url swap in a Brandfetch binance.com URL at render + # time — a config-dependent path that can't be baked into a constant here. + # Cached per base asset for 30 days so we HEAD at most once per coin and + # only when Security#import_provider_details runs (never during search, + # which must stay fast). + def verified_logo_url(base_asset) + Rails.cache.fetch("binance_public:logo:#{base_asset}", expires_in: 30.days) do + candidate = "#{LOGO_CDN_BASE}/#{base_asset}.png" + logo_client.head(candidate) + candidate + rescue Faraday::Error + nil + end + end + + # Dedicated Faraday client for the logo CDN host (jsdelivr.net is a + # different origin from data-api.binance.vision). HEAD-only with a tight + # timeout so CDN hiccups can't stall Security info imports. + def logo_client + @logo_client ||= Faraday.new(url: LOGO_CDN_BASE, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.options.timeout = 3 + faraday.options.open_timeout = 2 + faraday.response :raise_error + end + end + + # Preserve BinancePublic::Error subclasses (e.g. InvalidSecurityPriceError) + # through with_provider_response. The inherited RateLimitable transformer + # only preserves RateLimitError and would otherwise downcast our custom + # errors to the generic Error class. + def default_error_transformer(error) + return error if error.is_a?(self.class::Error) + super + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index efbe4395e..4782c1ee1 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -109,6 +109,10 @@ class Provider::Registry def mfapi Provider::Mfapi.new end + + def binance_public + Provider::BinancePublic.new + end end def initialize(concept) @@ -141,7 +145,7 @@ class Provider::Registry when :exchange_rates %i[twelve_data yahoo_finance] when :securities - %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi] + %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public] when :llm %i[openai] else diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index 670414fef..ba332dfe5 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -149,7 +149,7 @@ class Provider::TwelveData < Provider raise Error, "API error (code: #{error_code}): #{error_message}" end - data.map do |security| + data.reject { |row| crypto_row?(row) }.map do |security| country = ISO3166::Country.find_country_by_any_name(security.dig("country")) Security.new( @@ -250,6 +250,16 @@ class Provider::TwelveData < Provider private attr_reader :api_key + # TwelveData tags crypto symbols with `instrument_type: "Digital Currency"` and + # `mic_code: "DIGITAL_CURRENCY"`, and returns an empty `currency` field for them. + # We exclude them so crypto is handled exclusively by Provider::BinancePublic — + # TD's empty currency would otherwise cascade into Security::Price rows defaulting + # to USD, silently mispricing EUR/GBP crypto holdings. + def crypto_row?(row) + row["instrument_type"].to_s.casecmp?("Digital Currency") || + row["mic_code"].to_s.casecmp?("DIGITAL_CURRENCY") + end + def base_url ENV["TWELVE_DATA_URL"] || "https://api.twelvedata.com" end diff --git a/app/models/security.rb b/app/models/security.rb index 46c70493c..af3518d9e 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -20,6 +20,7 @@ class Security < ApplicationRecord before_validation :upcase_symbols before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo? + before_save :reset_first_provider_price_on_if_provider_changed has_many :trades, dependent: :nullify, class_name: "Trade" has_many :prices, dependent: :destroy @@ -52,6 +53,31 @@ class Security < ApplicationRecord kind == "cash" end + # True when this security represents a crypto asset. Today the only signal + # is the Binance ISO MIC — when we add a second crypto provider, extend + # this check rather than duplicating the test at every call site. + def crypto? + exchange_operating_mic == Provider::BinancePublic::BINANCE_MIC + end + + # Single source of truth for which logo URL the UI should render. The order + # differs by asset class: + # + # - Crypto: prefer the verified per-asset jsDelivr logo (set during + # import by Provider::BinancePublic#verified_logo_url). On a miss, fall + # back to Brandfetch with a forced `binance.com` identifier so the + # generic Binance brand mark shows instead of a ticker lettermark. + # + # - Everything else: Brandfetch first (domain-derived or ticker lettermark), + # then any provider-set logo_url. + def display_logo_url + if crypto? + logo_url.presence || brandfetch_icon_url(identifier: "binance.com") + else + brandfetch_icon_url.presence || logo_url.presence + end + end + # Returns user-friendly exchange name for a MIC code def self.exchange_name_for(mic) return nil if mic.blank? @@ -80,13 +106,13 @@ class Security < ApplicationRecord ) end - def brandfetch_icon_url(width: nil, height: nil) + def brandfetch_icon_url(width: nil, height: nil, identifier: nil) return nil unless Setting.brand_fetch_client_id.present? w = width || Setting.brand_fetch_logo_size h = height || Setting.brand_fetch_logo_size - identifier = extract_domain(website_url) if website_url.present? + identifier ||= extract_domain(website_url) if website_url.present? identifier ||= ticker return nil unless identifier.present? @@ -123,4 +149,16 @@ class Security < ApplicationRecord def generate_logo_url_from_brandfetch self.logo_url = brandfetch_icon_url end + + # When a user remaps a security to a different provider (via the holdings + # remap combobox or Security::Resolver), the previously-discovered + # first_provider_price_on belongs to the OLD provider and may no longer + # reflect what the new provider can serve. Reset it so the next sync's + # fallback rediscovers the correct earliest date for the new provider. + # Skip when the caller explicitly set both columns in the same save. + def reset_first_provider_price_on_if_provider_changed + return unless price_provider_changed? + return if first_provider_price_on_changed? + self.first_provider_price_on = nil + end end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index cd2bb0688..e0e323ccc 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -30,6 +30,37 @@ class Security::Price::Importer prev_price_value = start_price_value prev_currency = prev_price_currency || db_price_currency || "USD" + # Fallback for holdings that predate the asset's listing on the provider + # (e.g. a 2018 BTCEUR trade vs. Binance's 2020-01-03 listing date, or a + # 2023 RDDT trade vs. the 2024-03-21 IPO on Yahoo/Twelve Data). We can't + # anchor a price on or before start_date, but provider_prices has real + # data later in the range — advance fill_start_date to the earliest + # available provider date and use that price as the LOCF anchor. Days + # before that are intentionally left out of the DB (honest gap) rather + # than backfilled from a future price. + advanced_first_price_on = nil + + if prev_price_value.blank? + # Filter for valid rows BEFORE picking the earliest — otherwise a + # single listing-day / halt-day row with a nil or zero price would + # cause us to fall through to the MissingStartPriceError bail even + # when plenty of valid prices exist later in the window. + earliest_provider_price = provider_prices.values + .select { |p| p.price.present? && p.price.to_f > 0 } + .min_by(&:date) + + if earliest_provider_price + Rails.logger.info( + "#{security.ticker}: no provider price on or before #{start_date}; " \ + "advancing gapfill start to earliest valid provider date #{earliest_provider_price.date}" + ) + prev_price_value = earliest_provider_price.price + prev_currency = earliest_provider_price.currency || prev_currency + @fill_start_date = earliest_provider_price.date + advanced_first_price_on = earliest_provider_price.date + end + end + unless prev_price_value.present? Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{fill_start_date}") @@ -95,7 +126,26 @@ class Security::Price::Importer } end - upsert_rows(gapfilled_prices) + result = upsert_rows(gapfilled_prices) + + # Persist the advanced start date so subsequent syncs can clamp + # expected_count and short-circuit via all_prices_exist? instead of + # re-iterating the full (start_date..end_date) range every time. + # + # Update when the column is currently blank, OR when we've discovered + # an EARLIER date than the stored one — the latter covers the + # clear_cache-driven case where a provider has extended its backward + # coverage (e.g. Binance backfilling older BTCEUR history) and we + # want subsequent syncs to reflect the new earlier clamp. We never + # move the column forward from a previously-discovered earlier value, + # since that would silently hide older rows already in the DB. + if advanced_first_price_on.present? && + (security.first_provider_price_on.blank? || + advanced_first_price_on < security.first_provider_price_on) + security.update_column(:first_provider_price_on, advanced_first_price_on) + end + + result end private @@ -166,7 +216,16 @@ class Security::Price::Importer def all_prices_exist? return false if has_refetchable_provisional_prices? - db_prices.count == expected_count + + # Count only prices in the clamped range so pre-listing / pre-IPO gaps + # don't perpetually trip the "expected_count mismatch" re-sync. Query + # directly rather than via db_prices (which stays at the full range to + # preserve any user-entered rows pre-listing). + persisted_count = Security::Price + .where(security_id: security.id, date: clamped_start_date..end_date) + .count + + persisted_count == expected_count end def has_refetchable_provisional_prices? @@ -176,20 +235,38 @@ class Security::Price::Importer end def expected_count - (start_date..end_date).count + (clamped_start_date..end_date).count + end + + # Effective start date after clamping to the security's known first + # provider-available price date. Unlike start_date, this shrinks when the + # provider's history (e.g. Binance BTCEUR listed 2020-01-03, RDDT IPO + # 2024-03-21) begins after the user's original start_date. Falls through + # to start_date for any security that has never tripped the fallback. + def clamped_start_date + @clamped_start_date ||= begin + listed = security.first_provider_price_on + listed.present? && listed > start_date ? listed : start_date + end end # Skip over ranges that already exist unless clearing cache - # Also includes dates with refetchable provisional prices + # Also includes dates with refetchable provisional prices. + # + # Iterates from clamped_start_date (not start_date) so pre-listing / + # pre-IPO gaps don't perpetually trip "first missing date = start_date" + # and cause every incremental sync to re-fetch + re-upsert the full + # post-listing range. clear_cache bypasses the clamp so a user-triggered + # refresh can rediscover earlier provider history. def effective_start_date return start_date if clear_cache - refetchable_dates = Security::Price.where(security_id: security.id, date: start_date..end_date) + refetchable_dates = Security::Price.where(security_id: security.id, date: clamped_start_date..end_date) .refetchable_provisional(lookback_days: PROVISIONAL_LOOKBACK_DAYS) .pluck(:date) .to_set - (start_date..end_date).detect do |d| + (clamped_start_date..end_date).detect do |d| !db_prices.key?(d) || refetchable_dates.include?(d) end || end_date end diff --git a/app/models/security/resolver.rb b/app/models/security/resolver.rb index 426b6937b..4d0521b2d 100644 --- a/app/models/security/resolver.rb +++ b/app/models/security/resolver.rb @@ -86,7 +86,7 @@ class Security::Resolver 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 + ticker_matches && exchange_matches && country_matches?(s.country_code) else ticker_matches && exchange_matches end @@ -101,8 +101,10 @@ class Security::Resolver filtered_candidates = provider_search_result # If a country code is specified, we MUST find a match with the same code + # — but nil candidate country is treated as a wildcard (e.g. crypto from + # Binance, which isn't tied to a jurisdiction). if country_code.present? - filtered_candidates = filtered_candidates.select { |s| s.country_code&.upcase.to_s == country_code.upcase.to_s } + filtered_candidates = filtered_candidates.select { |s| country_matches?(s.country_code) } end # 1. Prefer exact ticker matches (MSTR before MSTRX when searching for "MSTR") @@ -161,6 +163,15 @@ class Security::Resolver security.update!(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil) end + # Candidate country matches when it equals the resolver's country OR when + # the provider didn't report a country at all (e.g. crypto from Binance). + # A nil candidate country is a legitimate "no jurisdiction" signal, not a + # missing field, so we trust the user's provider + exchange pick. + def country_matches?(candidate_country) + return true if candidate_country.blank? + candidate_country.upcase == country_code.upcase + end + def provider_search_result params = { exchange_operating_mic: exchange_operating_mic, diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index c0ba88e64..8d6adbfdb 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -3,10 +3,8 @@ <%= turbo_frame_tag dom_id(holding) do %>
- <% if holding.security.brandfetch_icon_url.present? %> - <%= image_tag holding.security.brandfetch_icon_url, class: "w-9 h-9 rounded-full", loading: "lazy" %> - <% elsif holding.security.logo_url.present? %> - <%= image_tag holding.security.logo_url, class: "w-9 h-9 rounded-full", loading: "lazy" %> + <% if (logo = holding.security.display_logo_url).present? %> + <%= image_tag logo, class: "w-9 h-9 rounded-full", loading: "lazy" %> <% else %> <%= render DS::FilledIcon.new(variant: :text, text: holding.name, size: "md", rounded: true) %> <% end %> diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index fb4dc3b66..37c7c86d4 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -6,10 +6,8 @@ <%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
- <% if @holding.security.brandfetch_icon_url.present? %> - <%= image_tag @holding.security.brandfetch_icon_url, loading: "lazy", class: "w-9 h-9 rounded-full" %> - <% elsif @holding.security.logo_url.present? %> - <%= image_tag @holding.security.logo_url, loading: "lazy", class: "w-9 h-9 rounded-full" %> + <% if (logo = @holding.security.display_logo_url).present? %> + <%= image_tag logo, loading: "lazy", class: "w-9 h-9 rounded-full" %> <% else %> <%= render DS::FilledIcon.new(variant: :text, text: @holding.name, size: "md", rounded: true) %> <% end %> @@ -28,6 +26,16 @@ ) %>
<% end %> + <% if (first_on = @holding.security.first_provider_price_on).present? && + (earliest_trade_date = @holding.trades.minimum(:date)) && + earliest_trade_date < first_on %> +
+ <%= render DS::Alert.new( + message: t(".truncated_history_warning", date: l(first_on, format: :long)), + variant: :warning + ) %> +
+ <% end %>
diff --git a/app/views/reports/_investment_performance.html.erb b/app/views/reports/_investment_performance.html.erb index 7434ab923..4f5e579d8 100644 --- a/app/views/reports/_investment_performance.html.erb +++ b/app/views/reports/_investment_performance.html.erb @@ -75,10 +75,8 @@ ">
- <% if holding.security.brandfetch_icon_url.present? %> - <%= holding.ticker %> - <% elsif holding.security.logo_url.present? %> - <%= holding.ticker %> + <% if (logo = holding.security.display_logo_url).present? %> + <%= holding.ticker %> <% else %>
<%= holding.ticker[0..1] %> diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 1ca2da305..147d082ab 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -50,6 +50,7 @@ ["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")], + ["binance_public", t(".providers.binance_public"), t(".binance_public_hint")], ].each do |value, label, hint| %>