Initial pass at Synth-based ticker selection
This commit is contained in:
@@ -11,14 +11,10 @@
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# Exchange Rate & US Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches US stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
# Exchange Rate & Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# Non-US Stock Pricing API
|
||||
# This is used to fetch non-US stock prices. We use Marketstack.com for this and while they offer a free tier, it is quite limited. You'll almost certainly need their Basic plan, which is $9.99 per month.
|
||||
MARKETSTACK_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
|
||||
@@ -3,6 +3,3 @@ SELF_HOSTED=false
|
||||
|
||||
# Enable Synth market data (careful, this will use your API credits)
|
||||
SYNTH_API_KEY=yourapikeyhere
|
||||
|
||||
# Enable Marketstack market data (careful, this will use your API credits)
|
||||
MARKETSTACK_API_KEY=yourapikeyhere
|
||||
|
||||
@@ -34,7 +34,13 @@ class Account::TradesController < ApplicationController
|
||||
end
|
||||
|
||||
def securities
|
||||
@pagy, @securities = pagy(Security.order(:name).search(params[:q]), limit: 20)
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2
|
||||
|
||||
synth_client = Provider::Synth.new(ENV["SYNTH_API_KEY"])
|
||||
@securities = synth_client.search_securities(query:, dataset: "limited", country_code: Current.family.country).securities
|
||||
|
||||
Rails.logger.info("SECURITIES: #{@securities.inspect}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
@@ -122,6 +122,30 @@ 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")
|
||||
}
|
||||
end
|
||||
|
||||
SearchSecuritiesResponse.new \
|
||||
securities: securities,
|
||||
success?: true,
|
||||
raw_response: response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key
|
||||
@@ -130,6 +154,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"
|
||||
|
||||
@@ -7,25 +7,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?
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
<div class="flex items-center">
|
||||
<%= image_tag("https://logo.synthfinance.com/ticker/#{tickers&.ticker}", class: "rounded-full h-8 w-8 inline-block mr-2") %>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium"><%= tickers&.name.presence || tickers&.ticker %></span>
|
||||
<span class="text-xs text-gray-500"><%= "#{tickers&.ticker} (#{tickers&.exchange_acronym})" %></span>
|
||||
</div>
|
||||
</div>
|
||||
<%= tickers.inspect %>
|
||||
@@ -1,3 +1,2 @@
|
||||
<%= async_combobox_options @securities,
|
||||
render_in: { partial: "account/trades/tickers" },
|
||||
next_page: @pagy.next %>
|
||||
render_in: { partial: "account/trades/tickers" } %>
|
||||
Reference in New Issue
Block a user