1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Use resolver throughout codebase

This commit is contained in:
Zach Gollwitzer 2025-05-22 11:48:08 -04:00
parent 30cf6b3320
commit 4a72b854d7
9 changed files with 51 additions and 62 deletions

View file

@ -1,5 +1,5 @@
class SecurityHealthCheckJob < ApplicationJob class SecurityHealthCheckJob < ApplicationJob
queue_as :default queue_as :scheduled
def perform def perform
Security::HealthChecker.new.perform Security::HealthChecker.new.perform

View file

@ -23,7 +23,7 @@ class MarketDataImporter
end end
# Import all securities that aren't marked as "offline" (i.e. they're available from the provider) # Import all securities that aren't marked as "offline" (i.e. they're available from the provider)
Security.where.not(offline: true).find_each do |security| Security.online.find_each do |security|
security.import_provider_prices( security.import_provider_prices(
start_date: get_first_required_price_date(security), start_date: get_first_required_price_date(security),
end_date: end_date, end_date: end_date,

View file

@ -9,6 +9,8 @@ class Security < ApplicationRecord
validates :ticker, presence: true validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
scope :online, -> { where(offline: false) }
def current_price def current_price
@current_price ||= find_or_fetch_price @current_price ||= find_or_fetch_price
return nil if @current_price.nil? return nil if @current_price.nil?

View file

@ -12,7 +12,12 @@ module Security::Provided
def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)
return [] if provider.nil? || symbol.blank? return [] if provider.nil? || symbol.blank?
response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic) params = {
country_code: country_code,
exchange_operating_mic: exchange_operating_mic
}.compact_blank
response = provider.search_securities(symbol, **params)
if response.success? if response.success?
response.data.map do |provider_security| response.data.map do |provider_security|

View file

@ -108,11 +108,12 @@ class Security::Resolver
end end
def provider_search_result def provider_search_result
@provider_search_result ||= Security.search_provider( params = {
symbol,
exchange_operating_mic: exchange_operating_mic, exchange_operating_mic: exchange_operating_mic,
country_code: country_code country_code: country_code
) }.compact_blank
@provider_search_result ||= Security.search_provider(symbol, **params)
end end
# Non-exhaustive list of common country codes for help in choosing "close" matches # Non-exhaustive list of common country codes for help in choosing "close" matches

View file

@ -1,6 +1,8 @@
class TradeBuilder class TradeBuilder
include ActiveModel::Model include ActiveModel::Model
Error = Class.new(StandardError)
attr_accessor :account, :date, :amount, :currency, :qty, attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :manual_ticker, :type, :transfer_account_id :price, :ticker, :manual_ticker, :type, :transfer_account_id
@ -129,6 +131,10 @@ class TradeBuilder
def security def security
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
unless ticker_symbol.present?
raise Error, "Ticker symbol is required to create a trade"
end
Security.find_or_create_by!( Security.find_or_create_by!(
ticker: ticker_symbol, ticker: ticker_symbol,
exchange_operating_mic: exchange_operating_mic exchange_operating_mic: exchange_operating_mic

View file

@ -76,42 +76,25 @@ class TradeImport < Import
end end
private private
def find_or_create_security(ticker:, exchange_operating_mic:) def find_or_create_security(ticker: nil, exchange_operating_mic: nil)
# Normalize empty string to nil for consistency return nil unless ticker.present?
exchange_operating_mic = nil if exchange_operating_mic.blank?
# First try to find an exact match in our DB, or if no exchange_operating_mic is provided, find by ticker only # Avoids resolving the same security over and over again (resolver potentially makes network calls)
internal_security = if exchange_operating_mic.present? @security_cache ||= {}
Security.find_by(ticker:, exchange_operating_mic:)
else
Security.find_by(ticker:)
end
return internal_security if internal_security.present? cache_key = [ ticker, exchange_operating_mic ].compact.join(":")
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic security = @security_cache[cache_key]
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) unless Security.provider.present?
# Cache provider responses so that when we're looping through rows and importing, return security if security.present?
# we only hit our provider for the unique combinations of ticker / exchange_operating_mic
cache_key = [ ticker, exchange_operating_mic ]
@provider_securities_cache ||= {}
provider_security = @provider_securities_cache[cache_key] ||= begin security = Security::Resolver.new(
Security.search_provider(
ticker, ticker,
exchange_operating_mic: exchange_operating_mic exchange_operating_mic: exchange_operating_mic.presence
).first ).resolve
end
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) if provider_security.nil? @security_cache[cache_key] = security
Security.find_or_create_by!(ticker: provider_security[:ticker]&.upcase, exchange_operating_mic: provider_security[:exchange_operating_mic]&.upcase) do |security| security
security.name = provider_security[:name]
security.country_code = provider_security[:country_code]
security.logo_url = provider_security[:logo_url]
security.exchange_acronym = provider_security[:exchange_acronym]
security.exchange_mic = provider_security[:exchange_mic]
end
end end
end end

View file

@ -45,7 +45,7 @@ class Security::ResolverTest < ActiveSupport::TestCase
# Return in reverse-priority order to prove the sorter works # Return in reverse-priority order to prove the sorter works
Security.expects(:search_provider) Security.expects(:search_provider)
.with("TEST", exchange_operating_mic: "XNAS", country_code: nil) .with("TEST", exchange_operating_mic: "XNAS")
.returns([ other, preferred ]) .returns([ other, preferred ])
assert_difference "Security.count", 1 do assert_difference "Security.count", 1 do

View file

@ -11,23 +11,24 @@ class TradeImportTest < ActiveSupport::TestCase
end end
test "imports trades and accounts" do test "imports trades and accounts" do
# Create an existing AAPL security with no exchange_operating_mic aapl_resolver = mock
aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: nil) googl_resolver = mock
# We should only hit the provider for GOOGL since AAPL already exists Security::Resolver.expects(:new)
Security.expects(:search_provider).with( .with("AAPL", exchange_operating_mic: nil)
"GOOGL", .returns(aapl_resolver)
exchange_operating_mic: "XNAS" .once
).returns([
Security.new( Security::Resolver.expects(:new)
ticker: "GOOGL", .with("GOOGL", exchange_operating_mic: "XNAS")
name: "Google Inc.", .returns(googl_resolver)
country_code: "US", .once
exchange_mic: "XNGS",
exchange_operating_mic: "XNAS", aapl = securities(:aapl)
exchange_acronym: "NGS" googl = Security.create!(ticker: "GOOGL", exchange_operating_mic: "XNAS")
)
]).once aapl_resolver.stubs(:resolve).returns(aapl)
googl_resolver.stubs(:resolve).returns(googl)
import = <<~CSV import = <<~CSV
date,ticker,qty,price,currency,account,name,exchange_operating_mic date,ticker,qty,price,currency,account,name,exchange_operating_mic
@ -55,19 +56,10 @@ class TradeImportTest < ActiveSupport::TestCase
assert_difference -> { Entry.count } => 2, assert_difference -> { Entry.count } => 2,
-> { Trade.count } => 2, -> { Trade.count } => 2,
-> { Security.count } => 1,
-> { Account.count } => 1 do -> { Account.count } => 1 do
@import.publish @import.publish
end end
assert_equal "complete", @import.status assert_equal "complete", @import.status
# Verify the securities were created/updated correctly
aapl.reload
assert_nil aapl.exchange_operating_mic
googl = Security.find_by(ticker: "GOOGL")
assert_equal "XNAS", googl.exchange_operating_mic
assert_equal "XNGS", googl.exchange_mic
end end
end end