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

Initial pass at Synth-based ticker selection (#1392)

* Initial pass at Synth-based ticker selection

* Update _tickers.turbo_stream.erb

* Functional combobox display

* A few cleanup steps

* Linter

* Prevent long strings

* Another step towards functional combobox

* Deprecated files

* Custom Combobox implementation

* Lint

* Test suite fixes

* Lint

* Make direct use of mic codes

* Update splits

* Update trades_test.rb
This commit is contained in:
Josh Pigford 2024-10-30 09:23:44 -04:00 committed by GitHub
parent 490f44589e
commit cd91e66618
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 118 additions and 232 deletions

View file

@ -31,8 +31,9 @@ class Account::TradeBuilder < Account::EntryBuilder
end
def security
return Security.find(ticker) if ticker.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
Security.find_or_create_by(ticker: ticker)
ticker_symbol, exchange_mic = ticker.split("|")
Security.find_or_create_by(ticker: ticker_symbol, exchange_mic: exchange_mic)
end
def amount

View file

@ -1,120 +0,0 @@
class Provider::Marketstack
include Retryable
def initialize(api_key)
@api_key = api_key
end
def fetch_security_prices(ticker:, start_date:, end_date:)
prices = paginate("#{base_url}/eod", {
symbols: ticker,
date_from: start_date.to_s,
date_to: end_date.to_s
}) do |body|
body.dig("data").map do |price|
{
date: price["date"],
price: price["close"]&.to_f,
currency: "USD"
}
end
end
SecurityPriceResponse.new(
prices: prices,
success?: true,
raw_response: prices.to_json
)
rescue StandardError => error
SecurityPriceResponse.new(
success?: false,
error: error,
raw_response: error
)
end
def fetch_tickers(exchange_mic: nil)
url = exchange_mic ? "#{base_url}/tickers?exchange=#{exchange_mic}" : "#{base_url}/tickers"
tickers = paginate(url) do |body|
body.dig("data").map do |ticker|
{
name: ticker["name"],
symbol: ticker["symbol"],
exchange_mic: exchange_mic || ticker.dig("stock_exchange", "mic"),
exchange_acronym: ticker.dig("stock_exchange", "acronym"),
country_code: ticker.dig("stock_exchange", "country_code")
}
end
end
TickerResponse.new(
tickers: tickers,
success?: true,
raw_response: tickers.to_json
)
rescue StandardError => error
TickerResponse.new(
success?: false,
error: error,
raw_response: error
)
end
private
attr_reader :api_key
SecurityPriceResponse = Struct.new(:prices, :success?, :error, :raw_response, keyword_init: true)
TickerResponse = Struct.new(:tickers, :success?, :error, :raw_response, keyword_init: true)
def base_url
"https://api.marketstack.com/v1"
end
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.params["access_key"] = api_key
end
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch data from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
def fetch_page(url, page, params = {})
client.get(url) do |req|
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["offset"] = (page - 1) * 100 # Marketstack uses offset-based pagination
req.params["limit"] = 10000 # Maximum allowed by Marketstack
end
end
def paginate(url, params = {})
results = []
page = 1
total_results = Float::INFINITY
while results.length < total_results
response = fetch_page(url, page, params)
if response.success?
body = JSON.parse(response.body)
page_results = yield(body)
results.concat(page_results)
total_results = body.dig("pagination", "total")
page += 1
else
raise build_error(response)
end
break if results.length >= total_results
end
results
end
end

View file

@ -122,6 +122,31 @@ class Provider::Synth
raw_response: error
end
def search_securities(query:, dataset: "limited", country_code:)
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = query
req.params["dataset"] = dataset
req.params["country_code"] = country_code
end
parsed = JSON.parse(response.body)
securities = parsed.dig("data").map do |security|
{
symbol: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code")
}
end
SearchSecuritiesResponse.new \
securities: securities,
success?: true,
raw_response: response
end
private
attr_reader :api_key
@ -130,6 +155,7 @@ class Provider::Synth
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true
SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"

View file

@ -1,4 +1,5 @@
class Security < ApplicationRecord
include Providable
before_save :upcase_ticker
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
@ -7,25 +8,6 @@ class Security < ApplicationRecord
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
scope :search, ->(query) {
return none if query.blank? || query.length < 2
# Clean and normalize the search terms
sanitized_query = query.split.map do |term|
cleaned_term = term.gsub(/[^a-zA-Z0-9]/, " ").strip
next if cleaned_term.blank?
cleaned_term
end.compact.join(" | ")
return none if sanitized_query.blank?
sanitized_query = ActiveRecord::Base.connection.quote(sanitized_query)
where("search_vector @@ to_tsquery('simple', #{sanitized_query}) AND exchange_mic IS NOT NULL")
.select("securities.*, ts_rank_cd(search_vector, to_tsquery('simple', #{sanitized_query})) AS rank")
.reorder("rank DESC")
}
def current_price
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
return nil if @current_price.nil?
@ -33,7 +15,7 @@ class Security < ApplicationRecord
end
def to_combobox_display
"#{ticker} - #{name} (#{exchange_acronym})"
"#{ticker} (#{exchange_acronym})"
end

View file

@ -1,45 +0,0 @@
class Security::Importer
def initialize(provider, stock_exchange = nil)
@provider = provider
@stock_exchange = stock_exchange
end
def import
securities = @provider.fetch_tickers(exchange_mic: @stock_exchange)&.tickers
# Deduplicate securities based on ticker and exchange_mic
securities_to_create = securities
.map do |security|
{
name: security[:name],
ticker: security[:symbol],
country_code: security[:country_code],
exchange_mic: security[:exchange_mic],
exchange_acronym: security[:exchange_acronym]
}
end
.compact
.uniq { |security| [ security[:ticker], security[:exchange_mic] ] }
# First update any existing securities that only have a ticker
Security.where(exchange_mic: nil)
.where(ticker: securities_to_create.map { |s| s[:ticker] })
.update_all(
securities_to_create.map do |security|
{
name: security[:name],
country_code: security[:country_code],
exchange_mic: security[:exchange_mic],
exchange_acronym: security[:exchange_acronym]
}
end.first
)
# Then create/update any remaining securities
Security.upsert_all(
securities_to_create,
unique_by: [ :ticker, :exchange_mic ],
update_only: [ :name, :country_code, :exchange_acronym ]
) unless securities_to_create.empty?
end
end

View file

@ -0,0 +1,20 @@
class Security::SynthComboboxOption
include ActiveModel::Model
include Providable
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic
class << self
def find_in_synth(query)
security_prices_provider.search_securities(query:, dataset: "limited", country_code: Current.family.country).securities.map { |attrs| new(**attrs) }
end
end
def id
"#{symbol}|#{exchange_mic}" # submitted by combobox as value
end
def to_combobox_display
"#{symbol} - #{name} (#{exchange_acronym})" # shown in combobox input when selected
end
end