Files
sure/test/models/security_test.rb
Artem Danilov 9de9a23ce2 feat(prices): add T-Invest (T-Bank) securities + brand-logo provider (#2408)
Adds Provider::TinkoffInvest, a token-based securities provider built on the
public T-Invest REST gateway (invest-public-api.tinkoff.ru/rest). It serves
prices for Russian instruments (shares, ETF/БПИФ, bonds) and, crucially, brand
logos via the T-Invest CDN — the authoritative logo source for MOEX
instruments, which ISS (MoexPublic) does not provide.

- Registry: register `tinkoff_invest` under the :securities concept; token via
  ENV TINKOFF_INVEST_API_KEY or encrypted Setting.tinkoff_invest_api_key.
- Logos independent of the price provider: Security#import_brand_logo consults
  T-Invest for a logo whenever a token is configured (after the price-provider
  metadata fetch, so it never short-circuits website_url backfill). Gated on
  token presence, not the securities checklist.
- display_logo_url: with no website domain, a stored provider logo (T-Invest)
  now beats the ticker-only Brandfetch lettermark; when a domain exists,
  Brandfetch still wins (unchanged).
- MoexPublic no longer reports moex.com as the issuer website — it's the
  exchange, not the issuer, and would make Brandfetch render the exchange logo
  for every instrument and shadow the real brand logo.
- Prices: GetCandles (daily, paged) + GetLastPrices; Quotation units+nano/1e9;
  bonds priced as percent-of-par x nominal (missing nominal raises, not 0).
- Settings: encrypted token field (always shown) + provider checkbox + en locale.
- Tests for search/info/logo-url/prices/bond/incomplete-candle and display logic.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 17:07:09 +02:00

191 lines
6.5 KiB
Ruby

require "test_helper"
class SecurityTest < ActiveSupport::TestCase
# Below has 3 example scenarios:
# 1. Original ticker
# 2. Duplicate ticker on a different exchange (different market price)
# 3. "Offline" version of the same ticker (for users not connected to a provider)
test "can have duplicate tickers if exchange is different" do
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
duplicate = Security.create!(ticker: "TEST", exchange_operating_mic: "CBOE")
offline = Security.create!(ticker: "TEST", exchange_operating_mic: nil)
assert original.valid?
assert duplicate.valid?
assert offline.valid?
end
test "cannot have duplicate tickers if exchange is the same" do
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
duplicate = Security.new(ticker: "TEST", exchange_operating_mic: "XNAS")
assert_not duplicate.valid?
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
end
test "cannot have duplicate tickers if exchange is nil" do
original = Security.create!(ticker: "TEST", exchange_operating_mic: nil)
duplicate = Security.new(ticker: "TEST", exchange_operating_mic: nil)
assert_not duplicate.valid?
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
end
test "casing is ignored when checking for duplicates" do
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
duplicate = Security.new(ticker: "tEst", exchange_operating_mic: "xNaS")
assert_not duplicate.valid?
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
end
test "cash_for lazily creates a per-account synthetic cash security" do
account = accounts(:investment)
cash = Security.cash_for(account)
assert cash.persisted?
assert cash.cash?
assert cash.offline?
assert_equal "Cash", cash.name
assert_includes cash.ticker, account.id.upcase
end
test "cash_for returns the same security on repeated calls" do
account = accounts(:investment)
first = Security.cash_for(account)
second = Security.cash_for(account)
assert_equal first.id, second.id
end
test "standard scope excludes cash securities" do
account = accounts(:investment)
Security.cash_for(account)
standard_tickers = Security.standard.pluck(:ticker)
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 "crypto_base_asset strips the display-currency suffix" do
%w[USD EUR JPY BRL TRY].each do |quote|
sec = Security.new(ticker: "BTC#{quote}", exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC)
assert_equal "BTC", sec.crypto_base_asset, "expected BTC#{quote} -> BTC"
end
end
test "crypto_base_asset returns nil for non-crypto securities" do
sec = Security.new(ticker: "AAPL", exchange_operating_mic: "XNAS")
assert_nil sec.crypto_base_asset
end
test "brandfetch_crypto_url uses the /crypto/ route and current size setting" do
Setting.stubs(:brand_fetch_client_id).returns("test-client-id")
Setting.stubs(:brand_fetch_logo_size).returns(120)
assert_equal(
"https://cdn.brandfetch.io/crypto/BTC/icon/fallback/lettermark/w/120/h/120?c=test-client-id",
Security.brandfetch_crypto_url("BTC")
)
end
test "brandfetch_crypto_url returns nil when Brandfetch is not configured" do
Setting.stubs(:brand_fetch_client_id).returns(nil)
assert_nil Security.brandfetch_crypto_url("BTC")
end
test "display_logo_url for crypto returns the /crypto/{base} Brandfetch URL" do
Setting.stubs(:brand_fetch_client_id).returns("test-client-id")
Setting.stubs(:brand_fetch_logo_size).returns(120)
sec = Security.new(
ticker: "BTCUSD",
exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC
)
assert_equal(
"https://cdn.brandfetch.io/crypto/BTC/icon/fallback/lettermark/w/120/h/120?c=test-client-id",
sec.display_logo_url
)
end
test "display_logo_url for crypto falls back to stored logo_url when Brandfetch is disabled" do
Setting.stubs(:brand_fetch_client_id).returns(nil)
sec = Security.new(
ticker: "BTCUSD",
exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC,
logo_url: "https://example.com/btc.png"
)
assert_equal "https://example.com/btc.png", sec.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
test "display_logo_url for non-crypto with no website prefers stored logo over brandfetch lettermark" do
Setting.stubs(:brand_fetch_client_id).returns("test-client-id")
Setting.stubs(:brand_fetch_logo_size).returns(120)
sec = Security.new(
ticker: "SBER",
exchange_operating_mic: "MISX",
logo_url: "https://invest-brands.cdn-tinkoff.ru/SBERx160.png"
)
assert_equal "https://invest-brands.cdn-tinkoff.ru/SBERx160.png", sec.display_logo_url
end
test "before_save writes the /crypto/{base} URL to logo_url for new crypto securities" do
Setting.stubs(:brand_fetch_client_id).returns("test-client-id")
Setting.stubs(:brand_fetch_logo_size).returns(120)
sec = Security.create!(
ticker: "BTCUSD",
exchange_operating_mic: Provider::BinancePublic::BINANCE_MIC
)
assert_equal(
"https://cdn.brandfetch.io/crypto/BTC/icon/fallback/lettermark/w/120/h/120?c=test-client-id",
sec.logo_url
)
end
end