Initial pass at Synth-based ticker selection #1392

Merged
Shpigford merged 15 commits from synth-selector into main 2024-10-30 21:23:45 +08:00
8 changed files with 36 additions and 158 deletions
Showing only changes of commit befb5beec2 - Show all commits

View File

@@ -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.

View File

@@ -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

View File

@@ -34,7 +34,13 @@ class Account::TradesController < ApplicationController
end
zachgoll commented 2024-10-30 08:40:17 +08:00 (Migrated from github.com)
Review

The only change with the provider here is we'll need to set the key via Setting.synth_api_key (as we're doing in Providable) for self hosting to work.

The diff below should make that work and the combobox display work (note: I renamed _tickers.turbo_stream.erb to _security.turbo_stream.erb)

diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb
index 8d31d3a..04f2048 100644
--- a/app/controllers/account/trades_controller.rb
+++ b/app/controllers/account/trades_controller.rb
@@ -37,8 +37,17 @@ class Account::TradesController < ApplicationController
     query = params[:q]
     return render json: [] if query.blank? || query.length < 2 || query.length > 100
 
-    synth_client = Provider::Synth.new(ENV["SYNTH_API_KEY"])
-    @securities = synth_client.search_securities(query:, dataset: "limited", country_code: Current.family.country).securities
+    @securities = Security.security_prices_provider.search_securities(query:, dataset: "limited", country_code: Current.family.country).securities.map do |security|
+      new_security = Security.new(
+        ticker: security[:symbol],
+        name: security[:name],
+        exchange_acronym: security[:exchange_acronym]
+      )
+
+      new_security.define_singleton_method(:logo_url) { security[:logo_url] }
+
+      new_security
+    end
   end
 
   private
diff --git a/app/models/security.rb b/app/models/security.rb
index 7192239..4558b85 100644
--- a/app/models/security.rb
+++ b/app/models/security.rb
@@ -1,4 +1,6 @@
 class Security < ApplicationRecord
+  include Providable
+
   before_save :upcase_ticker
 
   has_many :trades, dependent: :nullify, class_name: "Account::Trade"
@@ -17,7 +19,6 @@ class Security < ApplicationRecord
     "#{ticker} - #{name} (#{exchange_acronym})"
   end
 
-
   private
 
     def upcase_ticker
diff --git a/app/views/account/trades/_tickers.turbo_stream.erb b/app/views/account/trades/_tickers.turbo_stream.erb
deleted file mode 100644
-</div>
\ No newline at end of file
diff --git a/app/views/account/trades/securities.turbo_stream.erb b/app/views/account/trades/securities.turbo_stream.erb
index f7fcaf2..a3128c7 100644
--- a/app/views/account/trades/securities.turbo_stream.erb
+++ b/app/views/account/trades/securities.turbo_stream.erb
@@ -1,7 +1,2 @@
-<%= async_combobox_options @securities.map { |security| 
-      { 
-        display: security,
-        value: security[:symbol]
-      }
-    },
-    render_in: { partial: "account/trades/tickers" } %>
\ No newline at end of file
+<%= async_combobox_options @securities,
+    render_in: { partial: "account/trades/security" } %>
\ No newline at end of file
The only change with the provider here is we'll need to set the key via `Setting.synth_api_key` (as we're doing in `Providable`) for self hosting to work. The diff below should make that work _and_ the combobox display work (**note:** I renamed `_tickers.turbo_stream.erb` to `_security.turbo_stream.erb`) ```diff diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index 8d31d3a..04f2048 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -37,8 +37,17 @@ class Account::TradesController < ApplicationController query = params[:q] return render json: [] if query.blank? || query.length < 2 || query.length > 100 - synth_client = Provider::Synth.new(ENV["SYNTH_API_KEY"]) - @securities = synth_client.search_securities(query:, dataset: "limited", country_code: Current.family.country).securities + @securities = Security.security_prices_provider.search_securities(query:, dataset: "limited", country_code: Current.family.country).securities.map do |security| + new_security = Security.new( + ticker: security[:symbol], + name: security[:name], + exchange_acronym: security[:exchange_acronym] + ) + + new_security.define_singleton_method(:logo_url) { security[:logo_url] } + + new_security + end end private diff --git a/app/models/security.rb b/app/models/security.rb index 7192239..4558b85 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,4 +1,6 @@ class Security < ApplicationRecord + include Providable + before_save :upcase_ticker has_many :trades, dependent: :nullify, class_name: "Account::Trade" @@ -17,7 +19,6 @@ class Security < ApplicationRecord "#{ticker} - #{name} (#{exchange_acronym})" end - private def upcase_ticker diff --git a/app/views/account/trades/_tickers.turbo_stream.erb b/app/views/account/trades/_tickers.turbo_stream.erb deleted file mode 100644 -</div> \ No newline at end of file diff --git a/app/views/account/trades/securities.turbo_stream.erb b/app/views/account/trades/securities.turbo_stream.erb index f7fcaf2..a3128c7 100644 --- a/app/views/account/trades/securities.turbo_stream.erb +++ b/app/views/account/trades/securities.turbo_stream.erb @@ -1,7 +1,2 @@ -<%= async_combobox_options @securities.map { |security| - { - display: security, - value: security[:symbol] - } - }, - render_in: { partial: "account/trades/tickers" } %> \ No newline at end of file +<%= async_combobox_options @securities, + render_in: { partial: "account/trades/security" } %> \ No newline at end of file ```
zachgoll commented 2024-10-30 08:42:08 +08:00 (Migrated from github.com)
Review

And then this is the new _security.turbo_stream.erb:

<div class="flex items-center">
  <%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
  <div class="flex flex-col">
    <span class="text-sm font-medium">
      <%= security.name.presence || security.ticker %>
    </span>
    <span class="text-xs text-gray-500">
      <%= "#{security.ticker} (#{security.exchange_acronym})" %>
    </span>
  </div>
</div>
And then this is the new `_security.turbo_stream.erb`: ```erb <div class="flex items-center"> <%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %> <div class="flex flex-col"> <span class="text-sm font-medium"> <%= security.name.presence || security.ticker %> </span> <span class="text-xs text-gray-500"> <%= "#{security.ticker} (#{security.exchange_acronym})" %> </span> </div> </div> ```
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

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,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"

View File

@@ -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?

View File

@@ -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 %>

View File

@@ -1,3 +1,2 @@
<%= async_combobox_options @securities,
render_in: { partial: "account/trades/tickers" },
next_page: @pagy.next %>
render_in: { partial: "account/trades/tickers" } %>