mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user