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
This commit is contained in:
soky srm
2026-04-10 15:43:22 +02:00
committed by GitHub
parent 6551aaee0f
commit 0aca297e9c
23 changed files with 2091 additions and 25 deletions

View File

@@ -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 <img> 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -3,10 +3,8 @@
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<% 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 %>

View File

@@ -6,10 +6,8 @@
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
</div>
<div class="flex items-center gap-3">
<% 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 @@
) %>
</div>
<% end %>
<% if (first_on = @holding.security.first_provider_price_on).present? &&
(earliest_trade_date = @holding.trades.minimum(:date)) &&
earliest_trade_date < first_on %>
<div class="px-3 pt-2">
<%= render DS::Alert.new(
message: t(".truncated_history_warning", date: l(first_on, format: :long)),
variant: :warning
) %>
</div>
<% end %>
<dl class="space-y-3 px-3 py-2">
<div data-controller="holding-security-remap">
<div class="flex items-center justify-between text-sm">

View File

@@ -75,10 +75,8 @@
<tr class="<%= idx < investment_metrics[:top_holdings].size - 1 ? "border-b border-divider" : "" %>">
<td class="py-3 px-4 lg:px-6">
<div class="flex items-center gap-3">
<% if holding.security.brandfetch_icon_url.present? %>
<img src="<%= holding.security.brandfetch_icon_url %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% elsif holding.security.logo_url.present? %>
<img src="<%= Setting.transform_brand_fetch_url(holding.security.logo_url) %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% if (logo = holding.security.display_logo_url).present? %>
<img src="<%= Setting.transform_brand_fetch_url(logo) %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% else %>
<div class="w-8 h-8 rounded-full bg-container-inset flex items-center justify-center text-xs font-medium text-secondary">
<%= holding.ticker[0..1] %>

View File

@@ -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| %>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox"