mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
Expand financial providers (#1407)
* Initial implementation * Tiingo fixes * Adds 2 providers, remove 2 * Add extra checks * FIX a big hotwire race condition // Fix hotwire_combobox race condition: when typing quickly, a slow response for // an early query (e.g. "A") can overwrite the correct results for the final query // (e.g. "AAPL"). We abort the previous in-flight request whenever a new one fires, // so stale Turbo Stream responses never reach the DOM. * pipelock * Update price_test.rb * Reviews * i8n * fixes * fixes * Update tiingo.rb * fixes * Improvements * Big revamp * optimisations * Update 20260408151837_add_offline_reason_to_securities.rb * Add missing tests, fixes * small rank tests * FIX tests * Update show.html.erb * Update resolver.rb * Update usd_converter.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update holdings_controller.rb * Update _yahoo_finance_settings.html.erb
This commit is contained in:
@@ -11,7 +11,7 @@ class Security::HealthCheckerTest < ActiveSupport::TestCase
|
||||
Security.delete_all
|
||||
|
||||
@provider = mock
|
||||
Security.stubs(:provider).returns(@provider)
|
||||
Security.any_instance.stubs(:price_data_provider).returns(@provider)
|
||||
|
||||
# Brand new, no health check has been run yet
|
||||
@new_security = Security.create!(
|
||||
|
||||
@@ -6,9 +6,8 @@ class Security::PriceTest < ActiveSupport::TestCase
|
||||
|
||||
setup do
|
||||
@provider = mock
|
||||
Security.stubs(:provider).returns(@provider)
|
||||
|
||||
@security = securities(:aapl)
|
||||
@security.stubs(:price_data_provider).returns(@provider)
|
||||
end
|
||||
|
||||
test "finds single security price in DB" do
|
||||
|
||||
295
test/models/security/provided_test.rb
Normal file
295
test/models/security/provided_test.rb
Normal file
@@ -0,0 +1,295 @@
|
||||
require "test_helper"
|
||||
|
||||
class Security::ProvidedTest < ActiveSupport::TestCase
|
||||
include ProviderTestHelper
|
||||
|
||||
setup do
|
||||
@security = securities(:aapl)
|
||||
end
|
||||
|
||||
# --- search_provider ---
|
||||
|
||||
test "search_provider returns results from multiple providers" do
|
||||
provider_a = mock("provider_a")
|
||||
provider_b = mock("provider_b")
|
||||
|
||||
result_a = Provider::SecurityConcept::Security.new(
|
||||
symbol: "AAPL", name: "Apple Inc", logo_url: nil,
|
||||
exchange_operating_mic: "XNAS", country_code: "US", currency: "USD"
|
||||
)
|
||||
result_b = Provider::SecurityConcept::Security.new(
|
||||
symbol: "AAPL", name: "Apple Inc", logo_url: nil,
|
||||
exchange_operating_mic: "XNAS", country_code: "US", currency: "USD"
|
||||
)
|
||||
|
||||
provider_a.stubs(:class).returns(Provider::TwelveData)
|
||||
provider_b.stubs(:class).returns(Provider::YahooFinance)
|
||||
|
||||
provider_a.expects(:search_securities).with("AAPL").returns(
|
||||
provider_success_response([ result_a ])
|
||||
)
|
||||
provider_b.expects(:search_securities).with("AAPL").returns(
|
||||
provider_success_response([ result_b ])
|
||||
)
|
||||
|
||||
Security.stubs(:providers).returns([ provider_a, provider_b ])
|
||||
|
||||
results = Security.search_provider("AAPL")
|
||||
|
||||
# Same ticker+exchange but different providers → both appear (dedup includes provider key)
|
||||
assert_equal 2, results.size
|
||||
assert results.all? { |s| s.ticker == "AAPL" }
|
||||
providers_in_results = results.map(&:price_provider).sort
|
||||
assert_includes providers_in_results, "twelve_data"
|
||||
assert_includes providers_in_results, "yahoo_finance"
|
||||
end
|
||||
|
||||
test "search_provider deduplicates same ticker+exchange+provider" do
|
||||
provider = mock("provider")
|
||||
provider.stubs(:class).returns(Provider::TwelveData)
|
||||
|
||||
dup_result = Provider::SecurityConcept::Security.new(
|
||||
symbol: "MSFT", name: "Microsoft", logo_url: nil,
|
||||
exchange_operating_mic: "XNAS", country_code: "US", currency: "USD"
|
||||
)
|
||||
|
||||
provider.expects(:search_securities).with("MSFT").returns(
|
||||
provider_success_response([ dup_result, dup_result ])
|
||||
)
|
||||
|
||||
Security.stubs(:providers).returns([ provider ])
|
||||
|
||||
results = Security.search_provider("MSFT")
|
||||
assert_equal 1, results.size
|
||||
end
|
||||
|
||||
test "search_provider returns empty array for blank symbol" do
|
||||
assert_equal [], Security.search_provider("")
|
||||
assert_equal [], Security.search_provider(nil)
|
||||
end
|
||||
|
||||
test "search_provider returns empty array when no providers configured" do
|
||||
Security.stubs(:providers).returns([])
|
||||
assert_equal [], Security.search_provider("AAPL")
|
||||
end
|
||||
|
||||
test "search_provider keeps fast provider results when slow provider times out" do
|
||||
fast_provider = mock("fast_provider")
|
||||
slow_provider = mock("slow_provider")
|
||||
|
||||
fast_provider.stubs(:class).returns(Provider::TwelveData)
|
||||
slow_provider.stubs(:class).returns(Provider::YahooFinance)
|
||||
|
||||
fast_result = Provider::SecurityConcept::Security.new(
|
||||
symbol: "SPY", name: "SPDR S&P 500", logo_url: nil,
|
||||
exchange_operating_mic: "XNAS", country_code: "US", currency: "USD"
|
||||
)
|
||||
|
||||
fast_provider.expects(:search_securities).with("SPY").returns(
|
||||
provider_success_response([ fast_result ])
|
||||
)
|
||||
slow_provider.expects(:search_securities).with("SPY").returns(
|
||||
provider_success_response([])
|
||||
)
|
||||
|
||||
Security.stubs(:providers).returns([ fast_provider, slow_provider ])
|
||||
|
||||
results = Security.search_provider("SPY")
|
||||
|
||||
assert results.size >= 1, "Fast provider results should be returned even if slow provider returns nothing"
|
||||
assert_equal "SPY", results.first.ticker
|
||||
end
|
||||
|
||||
test "search_provider handles provider error gracefully" do
|
||||
good_provider = mock("good_provider")
|
||||
bad_provider = mock("bad_provider")
|
||||
|
||||
good_provider.stubs(:class).returns(Provider::TwelveData)
|
||||
bad_provider.stubs(:class).returns(Provider::YahooFinance)
|
||||
|
||||
good_result = Provider::SecurityConcept::Security.new(
|
||||
symbol: "GOOG", name: "Alphabet", logo_url: nil,
|
||||
exchange_operating_mic: "XNAS", country_code: "US", currency: "USD"
|
||||
)
|
||||
|
||||
good_provider.expects(:search_securities).with("GOOG").returns(
|
||||
provider_success_response([ good_result ])
|
||||
)
|
||||
bad_provider.expects(:search_securities).with("GOOG").raises(StandardError, "API down")
|
||||
|
||||
Security.stubs(:providers).returns([ good_provider, bad_provider ])
|
||||
|
||||
results = Security.search_provider("GOOG")
|
||||
|
||||
assert_equal 1, results.size
|
||||
assert_equal "GOOG", results.first.ticker
|
||||
end
|
||||
|
||||
# --- price_data_provider ---
|
||||
|
||||
test "price_data_provider returns assigned provider" do
|
||||
provider = mock("tiingo_provider")
|
||||
Security.stubs(:provider_for).with("tiingo").returns(provider)
|
||||
|
||||
@security.update!(price_provider: "tiingo")
|
||||
|
||||
assert_equal provider, @security.price_data_provider
|
||||
end
|
||||
|
||||
test "price_data_provider returns nil when assigned provider is disabled" do
|
||||
Security.stubs(:provider_for).with("tiingo").returns(nil)
|
||||
|
||||
@security.update!(price_provider: "tiingo")
|
||||
|
||||
assert_nil @security.price_data_provider
|
||||
end
|
||||
|
||||
test "price_data_provider falls back to first provider when none assigned" do
|
||||
fallback_provider = mock("fallback")
|
||||
Security.stubs(:providers).returns([ fallback_provider ])
|
||||
|
||||
@security.update!(price_provider: nil)
|
||||
|
||||
assert_equal fallback_provider, @security.price_data_provider
|
||||
end
|
||||
|
||||
# --- provider_status ---
|
||||
|
||||
test "provider_status returns provider_unavailable when assigned provider disabled" do
|
||||
Security.stubs(:provider_for).with("tiingo").returns(nil)
|
||||
|
||||
@security.update!(price_provider: "tiingo")
|
||||
|
||||
assert_equal :provider_unavailable, @security.provider_status
|
||||
end
|
||||
|
||||
test "provider_status returns ok for healthy security" do
|
||||
provider = mock("provider")
|
||||
Security.stubs(:provider_for).with("twelve_data").returns(provider)
|
||||
|
||||
@security.update!(price_provider: "twelve_data", offline: false, failed_fetch_count: 0)
|
||||
|
||||
assert_equal :ok, @security.provider_status
|
||||
end
|
||||
|
||||
# --- rank_search_results ---
|
||||
|
||||
# Helper to build unsaved Security objects for ranking tests
|
||||
def build_result(ticker:, name: nil, country_code: nil, exchange_operating_mic: nil)
|
||||
Security.new(
|
||||
ticker: ticker,
|
||||
name: name || ticker,
|
||||
country_code: country_code,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
end
|
||||
|
||||
def rank(results, query, country_code = nil)
|
||||
Security.send(:rank_search_results, results, query, country_code)
|
||||
end
|
||||
|
||||
test "ranking: AAPL exact match ranks above AAPL-prefixed and unrelated" do
|
||||
results = [
|
||||
build_result(ticker: "AAPLX", name: "Some AAPL Fund"),
|
||||
build_result(ticker: "AAPL", name: "Apple Inc", country_code: "US", exchange_operating_mic: "XNAS"),
|
||||
build_result(ticker: "AAPL", name: "Apple Inc", country_code: "GB", exchange_operating_mic: "XLON"),
|
||||
build_result(ticker: "AAPLD", name: "AAPL Dividend ETF")
|
||||
]
|
||||
|
||||
ranked = rank(results, "AAPL", "US")
|
||||
|
||||
# Exact matches first, US preferred over GB
|
||||
assert_equal "AAPL", ranked[0].ticker
|
||||
assert_equal "US", ranked[0].country_code
|
||||
assert_equal "AAPL", ranked[1].ticker
|
||||
assert_equal "GB", ranked[1].country_code
|
||||
# Prefix matches after
|
||||
assert ranked[2..].all? { |s| s.ticker.start_with?("AAPL") && s.ticker != "AAPL" }
|
||||
end
|
||||
|
||||
test "ranking: Apple name search surfaces Apple Inc above unrelated" do
|
||||
results = [
|
||||
build_result(ticker: "PINEAPPLE", name: "Pineapple Corp"),
|
||||
build_result(ticker: "AAPL", name: "Apple Inc", country_code: "US"),
|
||||
build_result(ticker: "APLE", name: "Apple Hospitality REIT"),
|
||||
build_result(ticker: "APPL", name: "Appell Petroleum")
|
||||
]
|
||||
|
||||
ranked = rank(results, "Apple", "US")
|
||||
|
||||
# No ticker matches "APPLE", so all fall to name-contains or worse.
|
||||
# "Apple Inc" and "Apple Hospitality" and "Pineapple" contain "APPLE" in name.
|
||||
# "Appell Petroleum" does not contain "APPLE".
|
||||
# Among name matches, alphabetical ticker breaks ties.
|
||||
name_matches = ranked.select { |s| s.name.upcase.include?("APPLE") }
|
||||
non_matches = ranked.reject { |s| s.name.upcase.include?("APPLE") }
|
||||
assert name_matches.size >= 2
|
||||
assert_equal non_matches, ranked.last(non_matches.size)
|
||||
end
|
||||
|
||||
test "ranking: SPX exact match first, then SPX-prefixed" do
|
||||
results = [
|
||||
build_result(ticker: "SPXL", name: "Direxion Daily S&P 500 Bull 3X"),
|
||||
build_result(ticker: "SPXS", name: "Direxion Daily S&P 500 Bear 3X"),
|
||||
build_result(ticker: "SPX", name: "S&P 500 Index", country_code: "US"),
|
||||
build_result(ticker: "SPXU", name: "ProShares UltraPro Short S&P 500")
|
||||
]
|
||||
|
||||
ranked = rank(results, "SPX", "US")
|
||||
|
||||
assert_equal "SPX", ranked[0].ticker, "Exact match should be first"
|
||||
assert ranked[1..].all? { |s| s.ticker.start_with?("SPX") }
|
||||
end
|
||||
|
||||
test "ranking: VTTI exact match first regardless of country" do
|
||||
results = [
|
||||
build_result(ticker: "VTI", name: "Vanguard Total Stock Market ETF", country_code: "US"),
|
||||
build_result(ticker: "VTTI", name: "VTTI Energy Partners", country_code: "US"),
|
||||
build_result(ticker: "VTTIX", name: "Vanguard Target 2060 Fund")
|
||||
]
|
||||
|
||||
ranked = rank(results, "VTTI", "US")
|
||||
|
||||
assert_equal "VTTI", ranked[0].ticker, "Exact match should be first"
|
||||
assert_equal "VTTIX", ranked[1].ticker, "Prefix match second"
|
||||
assert_equal "VTI", ranked[2].ticker, "Non-matching ticker last"
|
||||
end
|
||||
|
||||
test "ranking: iShares S&P multi-word query is contiguous substring match" do
|
||||
results = [
|
||||
build_result(ticker: "IVV", name: "iShares S&P 500 ETF", country_code: "US"),
|
||||
build_result(ticker: "CSPX", name: "iShares Core S&P 500 UCITS ETF", country_code: "GB"),
|
||||
build_result(ticker: "IJH", name: "iShares S&P Mid-Cap ETF", country_code: "US"),
|
||||
build_result(ticker: "UNRELATED", name: "Something Else Corp")
|
||||
]
|
||||
|
||||
ranked = rank(results, "iShares S&P", "US")
|
||||
|
||||
# Only names containing the exact substring "iShares S&P" match tier 2.
|
||||
# "iShares Core S&P" does NOT match (word "Core" breaks contiguity).
|
||||
contiguous_matches = ranked.select { |s| s.name.upcase.include?("ISHARES S&P") }
|
||||
assert_equal 2, contiguous_matches.size, "Only IVV and IJH contain the exact substring"
|
||||
# US contiguous matches should come first
|
||||
assert_equal "IJH", ranked[0].ticker # US, name match, alphabetically before IVV? No...
|
||||
assert_includes [ "IVV", "IJH" ], ranked[0].ticker
|
||||
assert_includes [ "IVV", "IJH" ], ranked[1].ticker
|
||||
# Non-contiguous and unrelated should be last
|
||||
assert_equal "UNRELATED", ranked.last.ticker
|
||||
end
|
||||
|
||||
test "ranking: tesla name search finds TSLA" do
|
||||
results = [
|
||||
build_result(ticker: "TSLA", name: "Tesla Inc", country_code: "US"),
|
||||
build_result(ticker: "TSLA", name: "Tesla Inc", country_code: "DE"),
|
||||
build_result(ticker: "TL0", name: "Tesla Inc", country_code: "DE", exchange_operating_mic: "XETR"),
|
||||
build_result(ticker: "TELSA", name: "Telsa Mining Ltd")
|
||||
]
|
||||
|
||||
ranked = rank(results, "tesla", "US")
|
||||
|
||||
# No ticker matches "TESLA", so all go to name matching
|
||||
# "Tesla Inc" contains "TESLA" → tier 2, US preferred
|
||||
assert_equal "TSLA", ranked[0].ticker
|
||||
assert_equal "US", ranked[0].country_code, "US Tesla should rank first for US user"
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,6 @@
|
||||
require "test_helper"
|
||||
|
||||
class Security::ResolverTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
Security.stubs(:provider).returns(@provider)
|
||||
end
|
||||
|
||||
test "resolves DB security" do
|
||||
# Given an existing security in the DB that exactly matches the lookup params
|
||||
db_security = Security.create!(ticker: "TSLA", exchange_operating_mic: "XNAS", country_code: "US")
|
||||
@@ -75,4 +70,73 @@ class Security::ResolverTest < ActiveSupport::TestCase
|
||||
assert_raises(ArgumentError) { Security::Resolver.new(nil).resolve }
|
||||
assert_raises(ArgumentError) { Security::Resolver.new("").resolve }
|
||||
end
|
||||
|
||||
test "persists explicit price_provider on DB match" do
|
||||
db_security = Security.create!(ticker: "CSPX", exchange_operating_mic: "XLON", country_code: "GB")
|
||||
|
||||
Security.expects(:search_provider).never
|
||||
Setting.stubs(:enabled_securities_providers).returns([ "tiingo" ])
|
||||
|
||||
resolved = Security::Resolver.new(
|
||||
"CSPX",
|
||||
exchange_operating_mic: "XLON",
|
||||
country_code: "GB",
|
||||
price_provider: "tiingo"
|
||||
).resolve
|
||||
|
||||
assert_equal db_security, resolved
|
||||
assert_equal "tiingo", resolved.reload.price_provider
|
||||
end
|
||||
|
||||
test "persists price_provider on provider match" do
|
||||
match = Security.new(ticker: "VWCE", exchange_operating_mic: "XETR", country_code: "DE", price_provider: "eodhd")
|
||||
|
||||
Security.expects(:search_provider)
|
||||
.with("VWCE", exchange_operating_mic: "XETR")
|
||||
.returns([ match ])
|
||||
|
||||
Setting.stubs(:enabled_securities_providers).returns([ "eodhd" ])
|
||||
|
||||
resolved = Security::Resolver.new(
|
||||
"VWCE",
|
||||
exchange_operating_mic: "XETR",
|
||||
price_provider: "eodhd"
|
||||
).resolve
|
||||
|
||||
assert resolved.persisted?
|
||||
assert_equal "eodhd", resolved.price_provider
|
||||
end
|
||||
|
||||
test "rejects unknown price_provider" do
|
||||
db_security = Security.create!(ticker: "AAPL2", exchange_operating_mic: "XNAS", country_code: "US")
|
||||
|
||||
Security.expects(:search_provider).never
|
||||
|
||||
resolved = Security::Resolver.new(
|
||||
"AAPL2",
|
||||
exchange_operating_mic: "XNAS",
|
||||
country_code: "US",
|
||||
price_provider: "fake_provider"
|
||||
).resolve
|
||||
|
||||
assert_equal db_security, resolved
|
||||
assert_nil resolved.reload.price_provider, "Unknown providers should be rejected"
|
||||
end
|
||||
|
||||
test "rejects disabled price_provider" do
|
||||
db_security = Security.create!(ticker: "GOOG2", exchange_operating_mic: "XNAS", country_code: "US")
|
||||
|
||||
Security.expects(:search_provider).never
|
||||
Setting.stubs(:enabled_securities_providers).returns([ "twelve_data" ])
|
||||
|
||||
resolved = Security::Resolver.new(
|
||||
"GOOG2",
|
||||
exchange_operating_mic: "XNAS",
|
||||
country_code: "US",
|
||||
price_provider: "tiingo"
|
||||
).resolve
|
||||
|
||||
assert_equal db_security, resolved
|
||||
assert_nil resolved.reload.price_provider, "Disabled providers should be rejected"
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user