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

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

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"

View File

@@ -295,6 +295,11 @@ XLIM:
name: Lima
country: PE
# Crypto exchanges
BNCX:
name: Binance
country: AE
# Middle East & Africa
XTAE:
name: Tel Aviv

View File

@@ -72,6 +72,7 @@ en:
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."
truncated_history_warning: "Price history is only available from %{date} onwards. Earlier dates have no data from the selected provider — this can happen when the asset was listed after your trade date, or when the provider only offers a limited historical window on its current plan."
switch_provider_label: Switch provider
switch_provider_description: "%{provider} is disabled. Search for this security from another enabled provider."
switch_provider_button: Switch

View File

@@ -11,3 +11,4 @@ en:
eodhd: EODHD
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance

View File

@@ -43,6 +43,7 @@ en:
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
binance_public_hint: free, no API key -- crypto only (BTC, ETH, etc.)
providers:
twelve_data: Twelve Data
yahoo_finance: Yahoo Finance
@@ -50,6 +51,7 @@ en:
eodhd: EODHD
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
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.

View File

@@ -0,0 +1,5 @@
class AddFirstProviderPriceOnToSecurities < ActiveRecord::Migration[7.2]
def change
add_column :securities, :first_provider_price_on, :date
end
end

3
db/schema.rb generated
View File

@@ -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_04_08_151837) do
ActiveRecord::Schema[7.2].define(version: 2026_04_10_114435) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -1217,6 +1217,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_08_151837) do
t.string "kind", default: "standard", null: false
t.string "price_provider"
t.string "offline_reason"
t.date "first_provider_price_on"
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"

View File

@@ -0,0 +1,493 @@
# Adding a New Securities Price Provider
This guide covers every step needed to add a new securities price provider (like Tiingo, EODHD, etc.) to the application.
## Architecture Overview
```text
User searches ticker in combobox
→ SecuritiesController#index
→ Security.search_provider (queries all enabled providers concurrently)
→ Provider::YourProvider#search_securities
→ Returns results with provider key attached
→ User selects one → price_provider stored on Security record
Account sync / price fetch
→ Security#price_data_provider (looks up provider by security.price_provider)
→ Provider::YourProvider#fetch_security_prices
→ Security::Price::Importer gap-fills and upserts into DB
```
Key files:
- Provider class: `app/models/provider/your_provider.rb`
- Registry: `app/models/provider/registry.rb`
- Settings: `app/models/setting.rb`
- Provider resolution: `app/models/security/provided.rb`
- Price import: `app/models/security/price/importer.rb`
- Market data sync: `app/models/account/market_data_importer.rb`
## Step 1: Create the Provider Class
Create `app/models/provider/your_provider.rb`:
```ruby
class Provider::YourProvider < Provider
include SecurityConcept
# Include if your provider has rate limits
include RateLimitable
# Custom error classes
Error = Class.new(Provider::Error)
RateLimitError = Class.new(Error)
# Rate limiting (only if you included RateLimitable)
MIN_REQUEST_INTERVAL = 1.0 # seconds between requests
def initialize(api_key)
@api_key = api_key # pipelock:ignore
end
# --- Required Methods ---
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
with_provider_response do
response = client.get("#{base_url}/search", params: { q: symbol })
parsed = JSON.parse(response.body)
parsed.map do |result|
SecurityConcept::Security.new(
symbol: result["ticker"],
name: result["name"],
logo_url: result["logo"],
exchange_operating_mic: map_exchange_to_mic(result["exchange"]),
country_code: result["country"],
currency: result["currency"]
)
end
end
end
def fetch_security_info(symbol:, exchange_operating_mic:)
with_provider_response do
response = client.get("#{base_url}/info/#{symbol}")
parsed = JSON.parse(response.body)
SecurityConcept::SecurityInfo.new(
symbol: parsed["ticker"],
name: parsed["name"],
links: parsed["website"],
logo_url: parsed["logo"],
description: parsed["description"],
kind: parsed["type"], # e.g. "common stock", "etf"
exchange_operating_mic: exchange_operating_mic
)
end
end
def fetch_security_price(symbol:, exchange_operating_mic:, date:)
with_provider_response do
response = client.get("#{base_url}/price/#{symbol}", params: { date: date.to_s })
parsed = JSON.parse(response.body)
SecurityConcept::Price.new(
symbol: symbol,
date: Date.parse(parsed["date"]),
price: parsed["close"].to_f,
currency: parsed["currency"],
exchange_operating_mic: exchange_operating_mic
)
end
end
def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)
with_provider_response do
response = client.get("#{base_url}/prices/#{symbol}", params: {
start: start_date.to_s,
end: end_date.to_s
})
parsed = JSON.parse(response.body)
parsed.map do |row|
SecurityConcept::Price.new(
symbol: symbol,
date: Date.parse(row["date"]),
price: row["close"].to_f,
currency: row["currency"],
exchange_operating_mic: exchange_operating_mic
)
end
end
end
# Optional: limit how far back the importer fetches history.
# nil = unlimited. Free tiers often have limits.
def max_history_days
365
end
# Optional: health check for admin UI
def healthy?
with_provider_response do
response = client.get("#{base_url}/status")
JSON.parse(response.body)["status"] == "ok"
end
end
# Optional: usage stats for admin UI
def usage
with_provider_response do
Provider::UsageData.new(
used: daily_request_count,
limit: MAX_REQUESTS_PER_DAY,
utilization: (daily_request_count.to_f / MAX_REQUESTS_PER_DAY * 100).round(1),
plan: "Free"
)
end
end
private
def base_url
"https://api.yourprovider.com/v1"
end
def client
@client ||= Faraday.new do |conn|
conn.headers["Authorization"] = "Bearer #{@api_key}"
conn.response :raise_error
end
end
# Map provider's exchange names to ISO 10383 MIC codes.
# This is critical — the app uses MIC codes everywhere.
def map_exchange_to_mic(exchange_name)
{
"NASDAQ" => "XNAS",
"NYSE" => "XNYS",
"LSE" => "XLON",
"XETRA" => "XETR",
"AMS" => "XAMS"
# Add all exchanges your provider returns
}[exchange_name]
end
end
```
### Data Structures (defined in `SecurityConcept`)
All methods must return these exact types (wrapped in `with_provider_response`):
```ruby
SecurityConcept::Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :currency)
SecurityConcept::SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
SecurityConcept::Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)
```
### Error Handling
All public methods must be wrapped in `with_provider_response`:
```ruby
def some_method
with_provider_response do
# Your logic. Raise on errors.
# The block return value becomes response.data
end
end
```
Callers always receive a `Provider::Response`:
```ruby
response = provider.fetch_security_price(...)
response.success? # true/false
response.data # the return value from the block
response.error # Provider::Error instance (on failure)
```
### Rate Limiting
If your provider has rate limits, include `RateLimitable` and use cache-based atomic counters:
```ruby
include RateLimitable
MIN_REQUEST_INTERVAL = 1.5 # seconds between requests
MAX_REQUESTS_PER_DAY = 20
private
def throttle_request
super # enforces MIN_REQUEST_INTERVAL
enforce_daily_limit!
end
def enforce_daily_limit!
cache_key = "your_provider:daily:#{Date.current}"
count = Rails.cache.increment(cache_key, 1, expires_in: 1.day, initial: 0)
raise RateLimitError, "Daily limit reached" if count > MAX_REQUESTS_PER_DAY
end
```
### Exchange Mapping
Every provider has its own naming for exchanges. You must map them to ISO 10383 MIC codes. Define bidirectional maps:
```ruby
# Provider exchange name → MIC code (for parsing search results)
PROVIDER_TO_MIC = { "NASDAQ" => "XNAS", ... }.freeze
# MIC code → Provider exchange name (for building API requests)
MIC_TO_PROVIDER = PROVIDER_TO_MIC.invert.freeze
```
See `config/exchanges.yml` for the full list of MIC codes and their display names.
### Currency Handling
Some providers don't return currency in every response. Common pattern: cache currency from search results and reuse later:
```ruby
def search_securities(symbol, **opts)
with_provider_response do
results = api_call(...)
results.each do |r|
Rails.cache.write("your_provider:currency:#{r[:ticker].upcase}", r[:currency], expires_in: 24.hours)
end
# ...
end
end
def fetch_security_prices(symbol:, **)
with_provider_response do
# ...
currency = Rails.cache.read("your_provider:currency:#{symbol.upcase}") || fallback_currency(exchange)
# ...
end
end
```
## Step 2: Register in the Provider Registry
Edit `app/models/provider/registry.rb`:
**Add to `available_providers`** (around line 144):
```ruby
def available_providers
case concept
when :exchange_rates
%i[twelve_data yahoo_finance]
when :securities
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi your_provider]
# ...
end
end
```
**Add the factory method** (private section, around line 85):
```ruby
def your_provider
api_key = ENV["YOUR_PROVIDER_API_KEY"].presence || Setting.your_provider_api_key # pipelock:ignore
return nil unless api_key.present?
Provider::YourProvider.new(api_key)
end
```
If your provider needs no API key (like Yahoo Finance or MFAPI):
```ruby
def your_provider
Provider::YourProvider.new
end
```
## Step 3: Add Settings
Edit `app/models/setting.rb`:
**Add the API key field** (around line 40):
```ruby
field :your_provider_api_key, type: :string, default: ENV["YOUR_PROVIDER_API_KEY"]
```
**Add to encrypted fields** (in `EncryptedSettingFields`, around line 55):
```ruby
ENCRYPTED_FIELDS = %i[
twelve_data_api_key
tiingo_api_key
eodhd_api_key
alpha_vantage_api_key
your_provider_api_key # ← add here
openai_access_token
external_assistant_token
]
```
## Step 4: Add to the Settings UI
### Hostings Controller
Edit `app/controllers/settings/hostings_controller.rb`:
**In `show`** — add a flag to control visibility:
```ruby
@show_your_provider_settings = enabled_securities.include?("your_provider")
```
**In `update`** — handle the API key:
```ruby
update_encrypted_setting(:your_provider_api_key)
```
### Hostings View
Edit the settings view to add your provider's checkbox and API key field. Follow the existing pattern for Tiingo/EODHD (checkbox in the provider selection list, API key input shown when enabled).
## Step 5: Add Translations
Edit `config/locales/views/settings/hostings/en.yml`:
**Add provider name** (under `provider_selection.providers`):
```yaml
providers:
twelve_data: "Twelve Data"
yahoo_finance: "Yahoo Finance"
tiingo: "Tiingo"
eodhd: "EODHD"
alpha_vantage: "Alpha Vantage"
mfapi: "MFAPI.in"
your_provider: "Your Provider" # ← add here
```
**Add hint text** (under `provider_selection`):
```yaml
your_provider_hint: "requires API key, N calls/day limit"
```
**Add settings section** (for the API key input):
```yaml
your_provider_settings:
title: "Your Provider"
description: "Get your API key from https://yourprovider.com/dashboard"
label: "API Key"
env_configured_message: "The YOUR_PROVIDER_API_KEY environment variable is set."
```
Also add a display name in `config/locales/en.yml` under `securities.providers`:
```yaml
securities:
providers:
your_provider: "Your Provider"
```
This is used in the combobox dropdown to show which provider each search result comes from.
## Step 6: Test
### Manual Testing
```ruby
# In rails console:
provider = Provider::YourProvider.new("your_api_key")
# Search
response = provider.search_securities("AAPL")
response.success? # => true
response.data # => [SecurityConcept::Security(...), ...]
# Price
response = provider.fetch_security_price(symbol: "AAPL", exchange_operating_mic: "XNAS", date: Date.current)
response.data.price # => 150.25
response.data.currency # => "USD"
# Historical prices
response = provider.fetch_security_prices(symbol: "AAPL", exchange_operating_mic: "XNAS", start_date: 30.days.ago.to_date, end_date: Date.current)
response.data.size # => ~30
```
### Enable and Search
```ruby
# Enable the provider
Setting.securities_providers = "your_provider"
# Search via the app's multi-provider system
results = Security.search_provider("AAPL")
results.map { |s| [s.ticker, s.price_provider] }
# => [["AAPL", "your_provider"]]
# Create a security with your provider
security = Security::Resolver.new("AAPL", exchange_operating_mic: "XNAS", price_provider: "your_provider").resolve
security.price_provider # => "your_provider"
# Import prices
security.import_provider_prices(start_date: 30.days.ago.to_date, end_date: Date.current)
security.prices.count
```
## How It All Connects
### Search Flow
When a user types in the securities combobox:
1. `SecuritiesController#index` calls `Security.search_provider(query)` (`app/models/security/provided.rb`)
2. `search_provider` queries **all enabled providers concurrently** using `Concurrent::Promises` with an 8-second timeout per provider
3. Results are deduplicated (key: `ticker|exchange|provider`) and ranked by relevance
4. Each result's `ComboboxOption#id` is `"TICKER|EXCHANGE|PROVIDER"` (e.g., `"AAPL|XNAS|your_provider"`)
5. When the user selects one, `price_provider` is stored on the Security record
### Price Fetch Flow
When prices are needed:
1. `Security#price_data_provider` looks up the provider by `security.price_provider`
2. If the assigned provider is unavailable (disabled in settings), returns `nil` — the security is skipped, not silently switched
3. If no provider assigned, falls back to the first enabled provider
4. `Security::Price::Importer` calls `fetch_security_prices` and gap-fills missing dates using LOCF (last observation carried forward)
5. Prices are upserted in batches of 200
### Provider Resolution Priority
```text
security.price_provider present?
├── YES → Security.provider_for(price_provider)
│ ├── Provider enabled & configured → use it
│ └── Provider unavailable → return nil (skip security)
└── NO → Security.providers.first (first enabled provider)
```
## Checklist
- [ ] Provider class at `app/models/provider/your_provider.rb`
- [ ] Inherits from `Provider`
- [ ] Includes `SecurityConcept`
- [ ] Implements `search_securities`, `fetch_security_info`, `fetch_security_price`, `fetch_security_prices`
- [ ] Returns correct `Data.define` types
- [ ] All methods wrapped in `with_provider_response`
- [ ] Exchange names mapped to MIC codes
- [ ] Currency handling (cached or mapped)
- [ ] Rate limiting if applicable
- [ ] Registry entry in `app/models/provider/registry.rb`
- [ ] Added to `available_providers` for `:securities`
- [ ] Private factory method with ENV + Setting fallback
- [ ] Setting field in `app/models/setting.rb` (if API key needed)
- [ ] `field :your_provider_api_key`
- [ ] Added to `ENCRYPTED_FIELDS`
- [ ] Settings UI in hostings controller/view
- [ ] Translations in `config/locales/`
- [ ] Provider name in `provider_selection.providers`
- [ ] Provider name in `securities.providers` (for combobox display)
- [ ] Hint text and settings section
- [ ] Tested: search, single price, historical prices, info

View File

@@ -0,0 +1,608 @@
require "test_helper"
class Provider::BinancePublicTest < ActiveSupport::TestCase
setup do
@provider = Provider::BinancePublic.new
@provider.stubs(:throttle_request)
# Logo cache is keyed per base asset and persists across tests; clear it
# so verified_logo_url tests don't see each other's results.
Rails.cache.delete_matched("binance_public:logo:*")
end
# ================================
# Search
# ================================
test "search_securities returns one result per supported quote" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("BTC")
assert response.success?
tickers = response.data.map(&:symbol)
assert_includes tickers, "BTCUSD"
assert_includes tickers, "BTCEUR"
assert_includes tickers, "BTCJPY"
assert_includes tickers, "BTCBRL"
assert_includes tickers, "BTCTRY"
refute_includes tickers, "BTCGBP", "GBP has zero Binance pairs and should never surface"
end
test "search_securities maps USDT pair to USD currency" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("BTC")
usd_row = response.data.find { |s| s.symbol == "BTCUSD" }
assert_equal "USD", usd_row.currency
assert_equal "BNCX", usd_row.exchange_operating_mic
assert_nil usd_row.country_code, "Crypto is jurisdictionless — country must be nil so non-AE families resolve"
assert_equal "BTC", usd_row.name
end
test "search_securities preserves native EUR pair currency" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("BTC")
eur_row = response.data.find { |s| s.symbol == "BTCEUR" }
assert_equal "EUR", eur_row.currency
assert_equal "BNCX", eur_row.exchange_operating_mic
end
test "search_securities is case insensitive" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
upper = @provider.search_securities("ETH").data
lower = @provider.search_securities("eth").data
assert_equal upper.map(&:symbol).sort, lower.map(&:symbol).sort
end
test "search_securities skips unsupported quote assets like BNB" do
info = [
info_row("BTC", "USDT"),
info_row("BTC", "BNB"),
info_row("BTC", "BTC")
]
@provider.stubs(:exchange_info_symbols).returns(info)
response = @provider.search_securities("BTC")
assert_equal [ "BTCUSD" ], response.data.map(&:symbol)
end
test "search_securities returns empty array when query does not match" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("NONEXISTENTCOIN")
assert response.success?
assert_empty response.data
end
test "search_securities ranks exact matches first" do
info = [
info_row("BTCB", "USDT"), # contains "BTC"
info_row("BTC", "USDT"), # exact match
info_row("WBTC", "USDT") # contains "BTC"
]
@provider.stubs(:exchange_info_symbols).returns(info)
tickers = @provider.search_securities("BTC").data.map(&:name)
assert_equal "BTC", tickers.first
end
test "search_securities matches when user types the full display ticker (BTCEUR)" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("BTCEUR")
assert response.success?
tickers = response.data.map(&:symbol)
assert_includes tickers, "BTCEUR"
# Should NOT return every BTC pair — narrow query, narrow result set.
refute_includes tickers, "BTCJPY"
refute_includes tickers, "BTCBRL"
refute_includes tickers, "BTCTRY"
end
test "search_securities matches BTCUSD against the raw BTCUSDT pair" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("BTCUSD")
assert response.success?
tickers = response.data.map(&:symbol)
# "BTCUSD" is a prefix of Binance's raw "BTCUSDT" — that single USDT-backed
# USD variant is what should come back (we store it as BTCUSD for the user).
assert_equal [ "BTCUSD" ], tickers
end
test "search_securities ranks exact symbol match above base prefix match" do
info = [
info_row("BTC", "USDT"), # base="BTC", symbol="BTCUSDT"
info_row("BTC", "EUR"), # base="BTC", symbol="BTCEUR" <- exact symbol match
info_row("BTCB", "EUR") # base="BTCB", symbol="BTCBEUR"
]
@provider.stubs(:exchange_info_symbols).returns(info)
response = @provider.search_securities("BTCEUR")
assert_equal [ "BTCEUR" ], response.data.map(&:symbol)
end
test "search_securities ignores delisted pairs" do
info = [
info_row("BTC", "USDT", status: "TRADING"),
info_row("LUNA", "USDT", status: "BREAK")
]
# exchange_info_symbols already filters by TRADING status, but double-check
# that delisted symbols don't leak through the path that fetches them.
@provider.stubs(:exchange_info_symbols).returns(info.select { |s| s["status"] == "TRADING" })
tickers = @provider.search_securities("LUNA").data.map(&:symbol)
assert_empty tickers
end
# ================================
# Ticker parsing
# ================================
test "parse_ticker maps USD suffix to USDT pair" do
parsed = @provider.send(:parse_ticker, "BTCUSD")
assert_equal "BTCUSDT", parsed[:binance_pair]
assert_equal "BTC", parsed[:base]
assert_equal "USD", parsed[:display_currency]
end
test "parse_ticker keeps EUR suffix as-is" do
parsed = @provider.send(:parse_ticker, "ETHEUR")
assert_equal "ETHEUR", parsed[:binance_pair]
assert_equal "ETH", parsed[:base]
assert_equal "EUR", parsed[:display_currency]
end
test "parse_ticker returns nil for unsupported suffix" do
assert_nil @provider.send(:parse_ticker, "BTCBNB")
assert_nil @provider.send(:parse_ticker, "GIBBERISH")
end
# ================================
# Single price
# ================================
test "fetch_security_price returns Price for a single day" do
mock_client_returning_klines([
kline_row("2026-01-15", "42000.50")
])
response = @provider.fetch_security_price(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
date: Date.parse("2026-01-15")
)
assert response.success?
assert_equal Date.parse("2026-01-15"), response.data.date
assert_in_delta 42000.50, response.data.price
assert_equal "USD", response.data.currency
assert_equal "BNCX", response.data.exchange_operating_mic
end
test "fetch_security_price raises InvalidSecurityPriceError for empty response" do
mock_client_returning_klines([])
response = @provider.fetch_security_price(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
date: Date.parse("2026-01-15")
)
assert_not response.success?
assert_instance_of Provider::BinancePublic::InvalidSecurityPriceError, response.error
end
test "fetch_security_price fails for unsupported ticker" do
response = @provider.fetch_security_price(
symbol: "NOPE",
exchange_operating_mic: "BNCX",
date: Date.current
)
assert_not response.success?
assert_instance_of Provider::BinancePublic::InvalidSecurityPriceError, response.error
end
# ================================
# Historical prices
# ================================
test "fetch_security_prices returns rows across a small range" do
rows = (0..4).map { |i| kline_row(Date.parse("2026-01-01") + i.days, (40000 + i).to_s) }
mock_client_returning_klines(rows)
response = @provider.fetch_security_prices(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2026-01-01"),
end_date: Date.parse("2026-01-05")
)
assert response.success?
assert_equal 5, response.data.size
assert_equal Date.parse("2026-01-01"), response.data.first.date
assert_equal Date.parse("2026-01-05"), response.data.last.date
assert response.data.all? { |p| p.currency == "USD" }
end
test "fetch_security_prices filters out zero-close rows" do
rows = [
kline_row("2026-01-01", "40000"),
kline_row("2026-01-02", "0"),
kline_row("2026-01-03", "41000")
]
mock_client_returning_klines(rows)
response = @provider.fetch_security_prices(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2026-01-01"),
end_date: Date.parse("2026-01-03")
)
assert_equal 2, response.data.size
end
test "fetch_security_prices paginates when range exceeds KLINE_MAX_LIMIT" do
first_batch = Array.new(1000) { |i| kline_row(Date.parse("2022-01-01") + i.days, "40000") }
second_batch = Array.new(200) { |i| kline_row(Date.parse("2024-09-27") + i.days, "42000") }
mock_response_1 = mock
mock_response_1.stubs(:body).returns(first_batch.to_json)
mock_response_2 = mock
mock_response_2.stubs(:body).returns(second_batch.to_json)
mock_client = mock
mock_client.expects(:get).twice.returns(mock_response_1).then.returns(mock_response_2)
@provider.stubs(:client).returns(mock_client)
response = @provider.fetch_security_prices(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2022-01-01"),
end_date: Date.parse("2025-04-14")
)
assert response.success?
assert_equal 1200, response.data.size
end
test "fetch_security_prices does NOT terminate on a short (straddle) batch" do
# Regression: a window that straddles the pair's listing date returns
# fewer than KLINE_MAX_LIMIT rows but more valid data exists in subsequent
# windows. The old `break if batch.size < KLINE_MAX_LIMIT` dropped that
# tail. Mock: first call = 638 rows (straddle), second call = 800 rows
# (mid-history), third call = 300 rows (final tail).
first_batch = Array.new(638) { |i| kline_row(Date.parse("2020-01-03") + i.days, "7000") }
second_batch = Array.new(800) { |i| kline_row(Date.parse("2021-10-02") + i.days, "40000") }
third_batch = Array.new(300) { |i| kline_row(Date.parse("2024-06-28") + i.days, "62000") }
mock_response_1 = mock
mock_response_1.stubs(:body).returns(first_batch.to_json)
mock_response_2 = mock
mock_response_2.stubs(:body).returns(second_batch.to_json)
mock_response_3 = mock
mock_response_3.stubs(:body).returns(third_batch.to_json)
mock_client = mock
mock_client.expects(:get).times(3)
.returns(mock_response_1).then
.returns(mock_response_2).then
.returns(mock_response_3)
@provider.stubs(:client).returns(mock_client)
response = @provider.fetch_security_prices(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2019-01-05"),
end_date: Date.parse("2026-04-10")
)
assert response.success?
assert_equal 1738, response.data.size
end
test "fetch_security_prices skips pre-listing empty windows and collects later data" do
# Regression for the BTCEUR bug: asking for a range starting before the
# pair's listing date used to return zero prices because the first empty
# window tripped `break if batch.blank?`.
empty_batch = []
real_batch = (0..4).map { |i| kline_row(Date.parse("2020-01-03") + i.days, "6568") }
mock_response_empty = mock
mock_response_empty.stubs(:body).returns(empty_batch.to_json)
mock_response_real = mock
mock_response_real.stubs(:body).returns(real_batch.to_json)
mock_client = mock
mock_client.expects(:get).twice
.returns(mock_response_empty).then
.returns(mock_response_real)
@provider.stubs(:client).returns(mock_client)
response = @provider.fetch_security_prices(
symbol: "BTCEUR",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2017-01-01"),
end_date: Date.parse("2020-01-07")
)
assert response.success?
assert_equal 5, response.data.size
assert_equal Date.parse("2020-01-03"), response.data.first.date
assert response.data.all? { |p| p.currency == "EUR" }
end
test "fetch_security_prices terminates on empty window once data has been seen" do
# Post-delisting / end-of-history scenario: first window returns data,
# second window returns empty → stop to avoid wasting calls.
first_batch = (0..2).map { |i| kline_row(Date.parse("2017-08-17") + i.days, "4500") }
empty_batch = []
mock_response_1 = mock
mock_response_1.stubs(:body).returns(first_batch.to_json)
mock_response_2 = mock
mock_response_2.stubs(:body).returns(empty_batch.to_json)
mock_client = mock
mock_client.expects(:get).twice
.returns(mock_response_1).then
.returns(mock_response_2)
@provider.stubs(:client).returns(mock_client)
response = @provider.fetch_security_prices(
symbol: "BTCUSD",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2017-08-17"),
end_date: Date.parse("2024-09-24")
)
assert response.success?
assert_equal 3, response.data.size
end
test "fetch_security_prices uses native quote currency for EUR pair" do
rows = [ kline_row("2026-01-15", "38000.12") ]
mock_client_returning_klines(rows)
response = @provider.fetch_security_prices(
symbol: "BTCEUR",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2026-01-15"),
end_date: Date.parse("2026-01-15")
)
assert_equal "EUR", response.data.first.currency
end
test "fetch_security_prices returns empty array for unsupported ticker wrapped as error" do
response = @provider.fetch_security_prices(
symbol: "NOPE",
exchange_operating_mic: "BNCX",
start_date: Date.current - 5,
end_date: Date.current
)
assert_not response.success?
assert_instance_of Provider::BinancePublic::InvalidSecurityPriceError, response.error
end
# ================================
# Info
# ================================
test "fetch_security_info returns crypto kind" do
stub_logo_head_success
response = @provider.fetch_security_info(symbol: "BTCUSD", exchange_operating_mic: "BNCX")
assert response.success?
assert_equal "BTC", response.data.name
assert_equal "crypto", response.data.kind
assert_match(/binance\.com/, response.data.links)
end
test "fetch_security_info returns the CDN logo URL when the HEAD succeeds" do
stub_logo_head_success
response = @provider.fetch_security_info(symbol: "ETHEUR", exchange_operating_mic: "BNCX")
assert_equal "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img/ETH.png", response.data.logo_url
end
test "fetch_security_info returns nil logo_url on a HEAD 403" do
stub_logo_head_raising(Faraday::ForbiddenError.new("403"))
response = @provider.fetch_security_info(symbol: "NOPECOINUSD", exchange_operating_mic: "BNCX")
# Nil (rather than a baked-in fallback URL) lets Security#display_logo_url
# substitute a Brandfetch binance.com URL at render time — a path that
# depends on runtime config and can't live on this provider.
assert_nil response.data.logo_url
end
test "fetch_security_info returns nil logo_url when the CDN HEAD times out" do
stub_logo_head_raising(Faraday::TimeoutError.new("timeout"))
response = @provider.fetch_security_info(symbol: "BTCUSD", exchange_operating_mic: "BNCX")
assert_nil response.data.logo_url
end
# ================================
# Quote currency coverage
# ================================
test "parse_ticker rejects GBP (unsupported)" do
assert_nil @provider.send(:parse_ticker, "BTCGBP")
end
test "parse_ticker maps JPY pair" do
parsed = @provider.send(:parse_ticker, "BTCJPY")
assert_equal "BTCJPY", parsed[:binance_pair]
assert_equal "BTC", parsed[:base]
assert_equal "JPY", parsed[:display_currency]
end
test "parse_ticker maps BRL pair" do
parsed = @provider.send(:parse_ticker, "ETHBRL")
assert_equal "ETHBRL", parsed[:binance_pair]
assert_equal "ETH", parsed[:base]
assert_equal "BRL", parsed[:display_currency]
end
test "fetch_security_prices returns JPY currency for a BTCJPY range" do
rows = [ kline_row("2026-01-15", "10800000") ]
mock_client_returning_klines(rows)
response = @provider.fetch_security_prices(
symbol: "BTCJPY",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2026-01-15"),
end_date: Date.parse("2026-01-15")
)
assert_equal "JPY", response.data.first.currency
assert_in_delta 10_800_000.0, response.data.first.price
end
test "fetch_security_prices returns BRL currency for a BTCBRL range" do
rows = [ kline_row("2026-01-15", "350000") ]
mock_client_returning_klines(rows)
response = @provider.fetch_security_prices(
symbol: "BTCBRL",
exchange_operating_mic: "BNCX",
start_date: Date.parse("2026-01-15"),
end_date: Date.parse("2026-01-15")
)
assert_equal "BRL", response.data.first.currency
end
# ================================
# Logo URL plumbing
# ================================
test "search_securities sets the optimistic CDN logo URL on every result" do
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
response = @provider.search_securities("BTC")
assert response.data.all? { |s| s.logo_url == "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img/BTC.png" }
end
test "verified_logo_url caches the happy-path result per base asset" do
with_memory_cache do
mock_logo_client = mock
mock_logo_client.expects(:head).once.returns(mock)
@provider.stubs(:logo_client).returns(mock_logo_client)
url1 = @provider.send(:verified_logo_url, "BTC")
url2 = @provider.send(:verified_logo_url, "BTC")
assert_equal "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img/BTC.png", url1
assert_equal url1, url2
end
end
test "verified_logo_url caches the nil fallback per base asset" do
with_memory_cache do
mock_logo_client = mock
mock_logo_client.expects(:head).once.raises(Faraday::ForbiddenError.new("403"))
@provider.stubs(:logo_client).returns(mock_logo_client)
url1 = @provider.send(:verified_logo_url, "NEVERCOIN")
url2 = @provider.send(:verified_logo_url, "NEVERCOIN")
assert_nil url1
assert_nil url2
end
end
# ================================
# Helpers
# ================================
private
def sample_exchange_info
[
info_row("BTC", "USDT"),
info_row("BTC", "EUR"),
info_row("BTC", "JPY"),
info_row("BTC", "BRL"),
info_row("BTC", "TRY"),
info_row("ETH", "USDT"),
info_row("ETH", "EUR"),
info_row("ETH", "JPY"),
info_row("SOL", "USDT"),
info_row("BNB", "USDT")
]
end
def info_row(base, quote, status: "TRADING")
{
"symbol" => "#{base}#{quote}",
"baseAsset" => base,
"quoteAsset" => quote,
"status" => status
}
end
# Mimics Binance /api/v3/klines row format.
# Index 0 = open time (ms), index 4 = close price (string)
def kline_row(date, close)
date = Date.parse(date) if date.is_a?(String)
open_time_ms = Time.utc(date.year, date.month, date.day).to_i * 1000
[
open_time_ms, # 0: Open time
"0", # 1: Open
"0", # 2: High
"0", # 3: Low
close.to_s, # 4: Close
"0", # 5: Volume
open_time_ms + (24 * 60 * 60 * 1000 - 1), # 6: Close time
"0", 0, "0", "0", "0"
]
end
def mock_client_returning_klines(rows)
mock_response = mock
mock_response.stubs(:body).returns(rows.to_json)
mock_client = mock
mock_client.stubs(:get).returns(mock_response)
@provider.stubs(:client).returns(mock_client)
end
def stub_logo_head_success
mock_logo_client = mock
mock_logo_client.stubs(:head).returns(mock)
@provider.stubs(:logo_client).returns(mock_logo_client)
end
def stub_logo_head_raising(error)
mock_logo_client = mock
mock_logo_client.stubs(:head).raises(error)
@provider.stubs(:logo_client).returns(mock_logo_client)
end
# Rails.cache in the test env is a NullStore by default, so Rails.cache.fetch
# re-runs the block every time. Swap in a real MemoryStore so cache-hit
# assertions are meaningful, then restore the original.
def with_memory_cache
original = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
yield
ensure
Rails.cache = original
end
end

View File

@@ -100,6 +100,77 @@ class Provider::TwelveDataTest < ActiveSupport::TestCase
assert_instance_of Provider::TwelveData::Error, result.error
end
# ================================
# Crypto Filter Tests
# ================================
test "search_securities excludes Digital Currency rows" do
body = {
"data" => [
{
"symbol" => "ETH",
"instrument_name" => "Grayscale Ethereum Trust ETF",
"mic_code" => "ARCX",
"instrument_type" => "ETF",
"country" => "United States",
"currency" => "USD"
},
{
"symbol" => "ETH/EUR",
"instrument_name" => "Ethereum Euro",
"mic_code" => "DIGITAL_CURRENCY",
"instrument_type" => "Digital Currency",
"country" => "",
"currency" => ""
},
{
"symbol" => "BTC/USD",
"instrument_name" => "Bitcoin US Dollar",
"mic_code" => "DIGITAL_CURRENCY",
"instrument_type" => "Digital Currency",
"country" => "",
"currency" => ""
}
]
}.to_json
mock_response = mock
mock_response.stubs(:body).returns(body)
@provider.stubs(:throttle_request)
@provider.stubs(:client).returns(mock_client = mock)
mock_client.stubs(:get).returns(mock_response)
result = @provider.search_securities("ETH")
assert result.success?
assert_equal 1, result.data.size
assert_equal "ETH", result.data.first.symbol
refute result.data.any? { |s| s.symbol.include?("/") }
end
test "search_securities excludes crypto even with mixed-case instrument_type" do
body = {
"data" => [
{
"symbol" => "BTC/EUR",
"instrument_name" => "Bitcoin Euro",
"mic_code" => "",
"instrument_type" => "digital currency",
"currency" => ""
}
]
}.to_json
mock_response = mock
mock_response.stubs(:body).returns(body)
@provider.stubs(:throttle_request)
@provider.stubs(:client).returns(mock_client = mock)
mock_client.stubs(:get).returns(mock_response)
result = @provider.search_securities("BTC")
assert_empty result.data
end
# ================================
# Throttle Tests
# ================================

View File

@@ -81,6 +81,245 @@ class Security::Price::ImporterTest < ActiveSupport::TestCase
).import_provider_prices
end
test "writes post-listing prices when holding predates provider history" do
# Regression: a 2018-06-15 trade for a pair the provider only has from
# 2020-01-03 onwards (e.g. BTCEUR on Binance) used to hit the
# `return 0` bail in start_price_value and write zero rows for the
# entire range. The fallback now advances fill_start_date to the
# earliest provider date and writes all post-listing prices.
Security::Price.delete_all
start_date = Date.parse("2018-06-15")
listing = Date.parse("2020-01-03")
end_date = listing + 2.days
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: listing, price: 6568, currency: "EUR"),
OpenStruct.new(security: @security, date: listing + 1.day, price: 6700, currency: "EUR"),
OpenStruct.new(security: @security, date: listing + 2.days, price: 6800, currency: "EUR")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(start_date), end_date: end_date)
.returns(provider_response)
upserted = Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: end_date
).import_provider_prices
db_prices = Security::Price.where(security: @security).order(:date)
# Post-listing rows are all written
assert_equal 3, db_prices.count
assert_equal [ listing, listing + 1.day, listing + 2.days ], db_prices.map(&:date)
assert_equal [ 6568, 6700, 6800 ], db_prices.map { |p| p.price.to_i }
assert db_prices.all? { |p| p.currency == "EUR" }
# Pre-listing gap is intentionally empty — no rows written before the
# earliest available provider price.
assert_equal 0, Security::Price.where(security: @security).where("date < ?", listing).count
assert_equal 3, upserted
# The earliest-available date is persisted on the Security so the next
# sync can short-circuit via all_prices_exist? instead of re-iterating
# the full (start_date..end_date) range every run.
assert_equal listing, @security.reload.first_provider_price_on
end
test "pre-listing fallback picks earliest VALID provider row, skipping nil/zero leaders" do
# Regression: if the provider returns a row with a nil/zero price as its
# earliest entry (e.g. a listing-day or halted-day placeholder), the
# fallback used to bail with MissingStartPriceError and drop every later
# valid row. It must now skip past invalid leaders and anchor on the
# earliest positive-price row instead.
Security::Price.delete_all
start_date = Date.parse("2018-06-15")
listing = Date.parse("2020-01-03")
end_date = listing + 3.days
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: listing, price: 0, currency: "EUR"),
OpenStruct.new(security: @security, date: listing + 1.day, price: nil, currency: "EUR"),
OpenStruct.new(security: @security, date: listing + 2.days, price: 6700, currency: "EUR"),
OpenStruct.new(security: @security, date: listing + 3.days, price: 6800, currency: "EUR")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(start_date), end_date: end_date)
.returns(provider_response)
upserted = Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: end_date
).import_provider_prices
# 2 valid provider rows + LOCF for each of the 2 invalid dates before them
# would ALSO get skipped entirely since fill_start_date advances past them
# (honest gap before earliest VALID date).
db_prices = Security::Price.where(security: @security).order(:date)
assert_equal 2, db_prices.count
assert_equal [ listing + 2.days, listing + 3.days ], db_prices.map(&:date)
assert_equal [ 6700, 6800 ], db_prices.map { |p| p.price.to_i }
assert_equal 2, upserted
assert_equal listing + 2.days, @security.reload.first_provider_price_on
end
test "first_provider_price_on is moved earlier when provider extends backward coverage" do
# Regression: a previous sync captured first_provider_price_on = 2024-10-01
# (e.g. provider only had limited history then). The provider has now
# backfilled earlier data. A clear_cache sync should detect the new
# earlier date and update the column so subsequent non-clear_cache
# syncs use the correct wider clamp.
Security::Price.delete_all
@security.update!(first_provider_price_on: Date.parse("2024-10-01"))
start_date = Date.parse("2018-06-15")
earlier = Date.parse("2020-01-03")
end_date = earlier + 2.days
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: earlier, price: 6568, currency: "EUR"),
OpenStruct.new(security: @security, date: earlier + 1.day, price: 6700, currency: "EUR"),
OpenStruct.new(security: @security, date: earlier + 2.days, price: 6800, currency: "EUR")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(start_date), end_date: end_date)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: end_date,
clear_cache: true
).import_provider_prices
assert_equal earlier, @security.reload.first_provider_price_on
end
test "first_provider_price_on is NOT moved forward when provider shrinks coverage" do
# Provider previously had data back to 2020-01-03, which we captured.
# A later clear_cache sync discovers the provider can now only serve
# from 2022-06-01 (e.g. free tier shrunk). We must NOT move the column
# forward, since that would silently hide older rows already in the DB.
Security::Price.delete_all
@security.update!(first_provider_price_on: Date.parse("2020-01-03"))
start_date = Date.parse("2018-06-15")
later = Date.parse("2022-06-01")
end_date = later + 2.days
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: later, price: 20000, currency: "EUR"),
OpenStruct.new(security: @security, date: later + 1.day, price: 20500, currency: "EUR"),
OpenStruct.new(security: @security, date: later + 2.days, price: 21000, currency: "EUR")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(start_date), end_date: end_date)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: end_date,
clear_cache: true
).import_provider_prices
# Column stays at the earlier stored value — shrink is ignored.
assert_equal Date.parse("2020-01-03"), @security.reload.first_provider_price_on
end
test "incremental sync on pre-listing holding does NOT re-fetch pre-listing window" do
# Regression: when first_provider_price_on was set but a new day was
# missing (typical daily sync), effective_start_date iterated from the
# original (unclamped) start_date and immediately found the pre-listing
# date missing. That caused the provider to be called with the full
# pre-listing start_date every sync AND the gap-fill loop to re-upsert
# every row from listing..end_date. The clamp must shrink the iteration
# window to (first_provider_price_on..end_date).
Security::Price.delete_all
travel_to Date.parse("2024-06-01") do
listing = Date.parse("2024-05-25")
start_date = Date.parse("2018-06-15")
end_date = Date.current
@security.update!(first_provider_price_on: listing)
(listing..(end_date - 1.day)).each do |d|
Security::Price.create!(security: @security, date: d, price: 6568, currency: "EUR")
end
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: end_date, price: 70_000, currency: "EUR")
])
# After fix: provider is called with start_date = clamped_start_date - 7 days,
# NOT with the original pre-listing start_date - 7 days (= 2018-06-08).
@provider.expects(:fetch_security_prices)
.with(
symbol: @security.ticker,
exchange_operating_mic: @security.exchange_operating_mic,
start_date: end_date - Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days,
end_date: end_date
)
.returns(provider_response)
upserted = Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: end_date
).import_provider_prices
# Only today's row is upserted — not the full (listing..end_date) range.
assert_equal 1, upserted
end
end
test "skips re-sync for pre-listing holding once first_provider_price_on is set" do
# Previous sync already advanced the clamp and wrote all post-listing
# prices. The next sync should see all_prices_exist? return true (because
# expected_count is clamped to [first_provider_price_on, end_date]) and
# never call the provider.
Security::Price.delete_all
listing = Date.parse("2020-01-03")
end_date = listing + 2.days
@security.update!(first_provider_price_on: listing)
(listing..end_date).each do |date|
Security::Price.create!(security: @security, date: date, price: 6568, currency: "EUR")
end
@provider.expects(:fetch_security_prices).never
result = Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: Date.parse("2018-06-15"),
end_date: end_date
).import_provider_prices
assert_equal 0, result
end
test "full upsert if clear_cache is true" do
Security::Price.delete_all

View File

@@ -123,6 +123,75 @@ class Security::ResolverTest < ActiveSupport::TestCase
assert_nil resolved.reload.price_provider, "Unknown providers should be rejected"
end
test "resolves Binance crypto match for a non-AE family" do
# Regression: BinancePublic search results carry country_code="AE" (the ISO
# 10383 MIC country), but the transactions controller passes the family's
# country (e.g. "US"). The resolver used to require an exact country match
# for both exact and close paths, so non-AE families would fall through to
# offline_security for every Binance pick — the user saw their BTCUSD
# holding resolve to an offline security that never fetched prices.
binance_match = Security.new(
ticker: "BTCUSD",
exchange_operating_mic: "BNCX",
country_code: nil,
price_provider: "binance_public"
)
Security.expects(:search_provider)
.with("BTCUSD", exchange_operating_mic: "BNCX", country_code: "US")
.returns([ binance_match ])
Setting.stubs(:enabled_securities_providers).returns([ "binance_public" ])
resolved = Security::Resolver.new(
"BTCUSD",
exchange_operating_mic: "BNCX",
country_code: "US",
price_provider: "binance_public"
).resolve
assert resolved.persisted?
refute resolved.offline, "Binance security must not fall through to offline_security"
assert_equal "BTCUSD", resolved.ticker
assert_equal "BNCX", resolved.exchange_operating_mic
assert_equal "binance_public", resolved.price_provider
end
test "resolved provider match is persisted with nil name/logo_url" do
# Documents that find_or_create_provider_match! intentionally copies only
# ticker, MIC, country_code, and price_provider from the match — not name
# or logo_url. This means Security#import_provider_details always has
# blank metadata on first resolution and does NOT short-circuit at
# `return if self.name.present? && ...`, so fetch_security_info runs as
# expected on the first sync. Regression guard: if someone adds name/logo
# copying to the resolver, the Binance logo-fallback path would become
# dead code on first sync.
match = Security.new(
ticker: "BTCUSD",
exchange_operating_mic: "BNCX",
country_code: nil,
name: "BTC",
logo_url: "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img/BTC.png",
price_provider: "binance_public"
)
Security.expects(:search_provider)
.with("BTCUSD", exchange_operating_mic: "BNCX", country_code: "US")
.returns([ match ])
Setting.stubs(:enabled_securities_providers).returns([ "binance_public" ])
resolved = Security::Resolver.new(
"BTCUSD",
exchange_operating_mic: "BNCX",
country_code: "US",
price_provider: "binance_public"
).resolve
assert_nil resolved.reload.name, "Resolver must not copy name from the search match"
assert_nil resolved.logo_url, "Resolver must not copy logo_url from the search match"
end
test "rejects disabled price_provider" do
db_security = Security.create!(ticker: "GOOG2", exchange_operating_mic: "XNAS", country_code: "US")

View File

@@ -39,6 +39,50 @@ class SecurityTest < ActiveSupport::TestCase
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
end
test "first_provider_price_on resets when price_provider changes" do
sec = Security.create!(
ticker: "TEST",
exchange_operating_mic: "XNAS",
price_provider: "twelve_data",
first_provider_price_on: Date.parse("2020-01-03")
)
sec.update!(price_provider: "yahoo_finance")
assert_nil sec.reload.first_provider_price_on
end
test "first_provider_price_on is preserved when unrelated fields change" do
sec = Security.create!(
ticker: "TEST",
exchange_operating_mic: "XNAS",
price_provider: "twelve_data",
first_provider_price_on: Date.parse("2020-01-03"),
offline: false
)
sec.update!(offline: true, failed_fetch_count: 3)
assert_equal Date.parse("2020-01-03"), sec.reload.first_provider_price_on
end
test "first_provider_price_on respects explicit assignment alongside provider change" do
sec = Security.create!(
ticker: "TEST",
exchange_operating_mic: "XNAS",
price_provider: "twelve_data",
first_provider_price_on: Date.parse("2020-01-03")
)
# Caller changes both in the same save — honor the explicit value.
sec.update!(
price_provider: "yahoo_finance",
first_provider_price_on: Date.parse("2024-03-21")
)
assert_equal Date.parse("2024-03-21"), sec.reload.first_provider_price_on
end
test "cash_for lazily creates a per-account synthetic cash security" do
account = accounts(:investment)
@@ -68,4 +112,62 @@ class SecurityTest < ActiveSupport::TestCase
assert_not_includes standard_tickers, "CASH-#{account.id.upcase}"
end
test "crypto? is true for Binance MIC and false otherwise" do
crypto = Security.new(ticker: "BTCUSD", exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC)
equity = Security.new(ticker: "AAPL", exchange_operating_mic: "XNAS")
offline = Security.new(ticker: "ACME", exchange_operating_mic: nil)
assert crypto.crypto?
assert_not equity.crypto?
assert_not offline.crypto?
end
test "display_logo_url for crypto prefers logo_url and falls back to brandfetch with binance.com" do
Setting.stubs(:brand_fetch_client_id).returns("test-client-id")
Setting.stubs(:brand_fetch_logo_size).returns(120)
with_logo = Security.new(
ticker: "BTCUSD",
exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC,
logo_url: "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img/BTC.png"
)
assert_equal "https://cdn.jsdelivr.net/gh/lindomar-oliveira/binance-data-plus/assets/img/BTC.png",
with_logo.display_logo_url
without_logo = Security.new(
ticker: "NOPECOIN",
exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC,
logo_url: nil
)
assert_equal "https://cdn.brandfetch.io/binance.com/icon/fallback/lettermark/w/120/h/120?c=test-client-id",
without_logo.display_logo_url
end
test "display_logo_url for non-crypto prefers brandfetch over stored logo_url" do
Setting.stubs(:brand_fetch_client_id).returns("test-client-id")
Setting.stubs(:brand_fetch_logo_size).returns(120)
sec = Security.new(
ticker: "AAPL",
exchange_operating_mic: "XNAS",
logo_url: "https://example.com/aapl.png",
website_url: "https://www.apple.com"
)
url = sec.display_logo_url
assert_includes url, "cdn.brandfetch.io/apple.com"
end
test "display_logo_url for non-crypto falls back to logo_url when brandfetch is disabled" do
Setting.stubs(:brand_fetch_client_id).returns(nil)
sec = Security.new(
ticker: "AAPL",
exchange_operating_mic: "XNAS",
logo_url: "https://example.com/aapl.png"
)
assert_equal "https://example.com/aapl.png", sec.display_logo_url
end
end