From 453a54e5e6a51d87b41b236b59fab79a07ea40cb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 1 Aug 2024 19:43:23 -0400 Subject: [PATCH] Add security prices provider (Synth integration) (#1039) * User tickers as primary lookup symbol instead of isin * Add security price provider * Fetch security prices in bulk to improve sync performance * Fetch prices in bulk, better mocking for tests --- .env.test.example | 17 +++ .gitignore | 1 + Gemfile | 2 +- app/models/account/entry.rb | 2 +- app/models/account/holding.rb | 2 +- app/models/account/holding/syncer.rb | 36 ++++- app/models/concerns/providable.rb | 21 ++- app/models/demo/generator.rb | 22 +-- app/models/provider/synth.rb | 59 +++++++- app/models/security.rb | 9 +- app/models/security/price.rb | 32 +++++ app/models/security/price/provided.rb | 55 +++++++ app/views/account/holdings/_holding.html.erb | 2 +- app/views/account/holdings/show.html.erb | 2 +- ..._change_primary_identifier_for_security.rb | 7 + db/schema.rb | 9 +- test/fixtures/securities.yml | 6 +- test/fixtures/security/prices.yml | 4 +- .../exchange_rate_provider_interface_test.rb | 11 +- .../security_price_provider_interface_test.rb | 20 +++ test/models/account/balance/syncer_test.rb | 11 +- test/models/account/entry_test.rb | 2 +- test/models/account/holding/syncer_test.rb | 59 +++++--- test/models/account/holding_test.rb | 2 +- test/models/account_test.rb | 4 +- test/models/exchange_rate_test.rb | 8 +- test/models/provider/synth_test.rb | 14 +- test/models/security/price_test.rb | 97 ++++++++++++- test/support/account/entries_test_helper.rb | 2 +- test/support/securities_test_helper.rb | 13 +- test/test_helper.rb | 12 +- .../exchange_rate.yml} | 24 ++-- test/vcr_cassettes/synth/security_prices.yml | 135 ++++++++++++++++++ 33 files changed, 584 insertions(+), 118 deletions(-) create mode 100644 .env.test.example create mode 100644 app/models/security/price/provided.rb create mode 100644 db/migrate/20240731191344_change_primary_identifier_for_security.rb create mode 100644 test/interfaces/security_price_provider_interface_test.rb rename test/vcr_cassettes/{synth_exchange_rate.yml => synth/exchange_rate.yml} (67%) create mode 100644 test/vcr_cassettes/synth/security_prices.yml diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 00000000..e5133c42 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,17 @@ +# ================ +# Data Providers +# --------------------------------------------------------------------------------- +# Uncomment and fill in live keys when you need to generate a VCR cassette fixture +# ================ + +# SYNTH_API_KEY= + +# ================ +# Miscellaneous +# ================ + +# Set to true if you want SimpleCov reports generated +COVERAGE=false + +# Set to true to run test suite serially +DISABLE_PARALLELIZATION=false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 19cd8f63..395b7217 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /.env* !/.env*.erb !.env.example +!.env.test.example # Ignore all logfiles and tempfiles. /log/* diff --git a/Gemfile b/Gemfile index 52b57177..196511d6 100644 --- a/Gemfile +++ b/Gemfile @@ -52,10 +52,10 @@ group :development, :test do gem "rubocop-rails-omakase", require: false gem "i18n-tasks" gem "erb_lint" + gem "dotenv-rails" end group :development do - gem "dotenv-rails" gem "hotwire-livereload" gem "letter_opener" gem "ruby-lsp-rails" diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index a72bb1ec..d031de96 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -204,7 +204,7 @@ class Account::Entry < ApplicationRecord current_qty = account.holding_qty(account_trade.security) if current_qty < account_trade.qty.abs - errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.symbol} because you only own #{current_qty} shares") + errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.ticker} because you only own #{current_qty} shares") end end end diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index 775a3b7c..512caddb 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -13,7 +13,7 @@ class Account::Holding < ApplicationRecord scope :for, ->(security) { where(security_id: security).order(:date) } delegate :name, to: :security - delegate :symbol, to: :security + delegate :ticker, to: :security def weight return nil unless amount diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb index 1494c8e0..e3479e88 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/account/holding/syncer.rb @@ -32,16 +32,42 @@ class Account::Holding::Syncer .order(:date) end + def get_cached_price(ticker, date) + return nil unless security_prices.key?(ticker) + + price = security_prices[ticker].find { |p| p.date == date } + price ? price[:price] : nil + end + + def security_prices + @security_prices ||= begin + prices = {} + ticker_start_dates = {} + + sync_entries.each do |entry| + unless ticker_start_dates[entry.account_trade.security.ticker] + ticker_start_dates[entry.account_trade.security.ticker] = entry.date + end + end + + ticker_start_dates.each do |ticker, date| + prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current) + end + + prices + end + end + def build_holdings_for_date(date) trades = sync_entries.select { |trade| trade.date == date } @portfolio = generate_next_portfolio(@portfolio, trades) - @portfolio.map do |isin, holding| + @portfolio.map do |ticker, holding| trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] } trade_price = trade&.account_trade&.price - price = Security::Price.find_by(date: date, isin: isin)&.price || trade_price + price = get_cached_price(ticker, date) || trade_price account.holdings.build \ date: date, @@ -58,10 +84,10 @@ class Account::Holding::Syncer trade = entry.account_trade price = trade.price - prior_qty = prior_portfolio.dig(trade.security.isin, :qty) || 0 + prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0 new_qty = prior_qty + trade.qty - new_portfolio[trade.security.isin] = { + new_portfolio[trade.security.ticker] = { qty: new_qty, price: price, amount: new_qty * price, @@ -86,7 +112,7 @@ class Account::Holding::Syncer prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day) prior_day_holdings.each do |holding| - @portfolio[holding.security.isin] = { + @portfolio[holding.security.ticker] = { qty: holding.qty, price: holding.price, amount: holding.amount, diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb index 3f3b1aa7..b9be68b0 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -6,18 +6,25 @@ module Providable extend ActiveSupport::Concern class_methods do - def exchange_rates_provider - api_key = ENV["SYNTH_API_KEY"] + def security_prices_provider + synth_provider + end - if api_key.present? - Provider::Synth.new api_key - else - nil - end + def exchange_rates_provider + synth_provider end def git_repository_provider Provider::Github.new end + + private + + def synth_provider + @synth_provider ||= begin + api_key = ENV["SYNTH_API_KEY"] + api_key.present? ? Provider::Synth.new(api_key) : nil + end + end end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 8806a03c..12528416 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -167,12 +167,12 @@ class Demo::Generator def load_securities! # Create an unknown security to simulate edge cases - Security.create! isin: "unknown", symbol: "UNKNOWN", name: "Unknown Demo Stock" + Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock" securities = [ - { isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 }, - { isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 }, - { isin: "US5949181045", symbol: "MSFT", name: "Microsoft Corporation", reference_price: 455 } + { ticker: "AAPL", name: "Apple Inc.", reference_price: 210 }, + { ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 }, + { ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 } ] securities.each do |security_attributes| @@ -184,7 +184,7 @@ class Demo::Generator low_price = reference - 20 high_price = reference + 20 Security::Price.create! \ - isin: security.isin, + ticker: security.ticker, date: date, price: Faker::Number.positive(from: low_price, to: high_price) end @@ -201,10 +201,10 @@ class Demo::Generator currency: "USD", institution: family.institutions.find_or_create_by(name: "Robinhood") - aapl = Security.find_by(symbol: "AAPL") - tm = Security.find_by(symbol: "TM") - msft = Security.find_by(symbol: "MSFT") - unknown = Security.find_by(symbol: "UNKNOWN") + aapl = Security.find_by(ticker: "AAPL") + tm = Security.find_by(ticker: "TM") + msft = Security.find_by(ticker: "MSFT") + unknown = Security.find_by(ticker: "UNKNOWN") # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown) @@ -220,14 +220,14 @@ class Demo::Generator date = Faker::Number.positive(to: 730).days.ago.to_date security = trade[:security] qty = trade[:qty] - price = Security::Price.find_by(isin: security.isin, date: date)&.price || 1 + price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1 name_prefix = qty < 0 ? "Sell " : "Buy " account.entries.create! \ date: date, amount: qty * price, currency: "USD", - name: name_prefix + "#{qty} shares of #{security.symbol}", + name: name_prefix + "#{qty} shares of #{security.ticker}", entryable: Account::Trade.new(qty: qty, price: price, security: security) end end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index c432d63d..05502d28 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -5,6 +5,27 @@ class Provider::Synth @api_key = api_key end + def fetch_security_prices(ticker:, start_date:, end_date:) + prices = paginate( + "#{base_url}/tickers/#{ticker}/open-close", + start_date: start_date, + end_date: end_date + ) do |body| + body.dig("prices").map do |price| + { + date: price.dig("date"), + price: price.dig("close")&.to_f || price.dig("open")&.to_f, + currency: "USD" + } + end + end + + SecurityPriceResponse.new \ + prices: prices, + success?: true, + raw_response: prices.to_json + end + def fetch_exchange_rate(from:, to:, date:) retrying Provider::Base.known_transient_errors do |on_last_attempt| response = Faraday.get("#{base_url}/rates/historical") do |req| @@ -33,9 +54,11 @@ class Provider::Synth end private + attr_reader :api_key ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true + SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true def base_url "https://api.synthfinance.com" @@ -43,9 +66,43 @@ class Provider::Synth def build_error(response) Provider::Base::ProviderError.new(<<~ERROR) - Failed to fetch exchange rate from #{self.class} + Failed to fetch data from #{self.class} Status: #{response.status} Body: #{response.body.inspect} ERROR end + + def fetch_page(url, page, params = {}) + Faraday.get(url) do |req| + req.headers["Authorization"] = "Bearer #{api_key}" + params.each { |k, v| req.params[k.to_s] = v.to_s } + req.params["page"] = page + end + end + + def paginate(url, params = {}) + results = [] + page = 1 + current_page = 0 + total_pages = 1 + + while current_page < total_pages + response = fetch_page(url, page, params) + + if response.success? + body = JSON.parse(response.body) + page_results = yield(body) + results.concat(page_results) + + current_page = body.dig("paging", "current_page") + total_pages = body.dig("paging", "total_pages") + + page += 1 + else + raise build_error(response) + end + end + + results + end end diff --git a/app/models/security.rb b/app/models/security.rb index 106925bf..03d07bcf 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,14 +1,13 @@ class Security < ApplicationRecord - before_save :normalize_identifiers + before_save :upcase_ticker has_many :trades, dependent: :nullify, class_name: "Account::Trade" - validates :isin, presence: true, uniqueness: { case_sensitive: false } + validates :ticker, presence: true, uniqueness: { case_sensitive: false } private - def normalize_identifiers - self.isin = isin.upcase - self.symbol = symbol.upcase + def upcase_ticker + self.ticker = ticker.upcase end end diff --git a/app/models/security/price.rb b/app/models/security/price.rb index aebbe237..0bdfc843 100644 --- a/app/models/security/price.rb +++ b/app/models/security/price.rb @@ -1,2 +1,34 @@ class Security::Price < ApplicationRecord + include Provided + + before_save :upcase_ticker + + validates :ticker, presence: true, uniqueness: { scope: :date, case_sensitive: false } + + class << self + def find_price(ticker:, date:, cache: true) + result = find_by(ticker:, date:) + + result || fetch_price_from_provider(ticker:, date:, cache:) + end + + def find_prices(ticker:, start_date:, end_date: Date.current, cache: true) + prices = where(ticker:, date: start_date..end_date).to_a + all_dates = (start_date..end_date).to_a.to_set + existing_dates = prices.map(&:date).to_set + missing_dates = (all_dates - existing_dates).sort + + if missing_dates.any? + prices += fetch_prices_from_provider(ticker:, start_date: missing_dates.first, end_date: missing_dates.last, cache:) + end + + prices + end + end + + private + + def upcase_ticker + self.ticker = ticker.upcase + end end diff --git a/app/models/security/price/provided.rb b/app/models/security/price/provided.rb new file mode 100644 index 00000000..1523cba8 --- /dev/null +++ b/app/models/security/price/provided.rb @@ -0,0 +1,55 @@ +module Security::Price::Provided + extend ActiveSupport::Concern + + include Providable + + class_methods do + private + + def fetch_price_from_provider(ticker:, date:, cache: false) + return nil unless security_prices_provider.present? + + response = security_prices_provider.fetch_security_prices \ + ticker: ticker, + start_date: date, + end_date: date + + if response.success? && response.prices.size > 0 + price = Security::Price.new \ + ticker: ticker, + date: response.prices.first[:date], + price: response.prices.first[:price], + currency: response.prices.first[:currency] + + price.save! if cache + price + else + nil + end + end + + def fetch_prices_from_provider(ticker:, start_date:, end_date:, cache: false) + return [] unless security_prices_provider.present? + + response = security_prices_provider.fetch_security_prices \ + ticker: ticker, + start_date: start_date, + end_date: end_date + + if response.success? + response.prices.map do |price| + new_price = Security::Price.new \ + ticker: ticker, + date: price[:date], + price: price[:price], + currency: price[:currency] + + new_price.save! if cache + new_price + end + else + [] + end + end + end +end diff --git a/app/views/account/holdings/_holding.html.erb b/app/views/account/holdings/_holding.html.erb index 62878810..90ad1d54 100644 --- a/app/views/account/holdings/_holding.html.erb +++ b/app/views/account/holdings/_holding.html.erb @@ -6,7 +6,7 @@ <%= render "shared/circle_logo", name: holding.name %>
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %> - <%= tag.p holding.symbol, class: "text-gray-500 text-xs uppercase" %> + <%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
diff --git a/app/views/account/holdings/show.html.erb b/app/views/account/holdings/show.html.erb index 07ae6798..36412913 100644 --- a/app/views/account/holdings/show.html.erb +++ b/app/views/account/holdings/show.html.erb @@ -3,7 +3,7 @@
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %> - <%= tag.p @holding.symbol.upcase, class: "text-sm text-gray-500" %> + <%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
<%= render "shared/circle_logo", name: @holding.name %> diff --git a/db/migrate/20240731191344_change_primary_identifier_for_security.rb b/db/migrate/20240731191344_change_primary_identifier_for_security.rb new file mode 100644 index 00000000..19e22602 --- /dev/null +++ b/db/migrate/20240731191344_change_primary_identifier_for_security.rb @@ -0,0 +1,7 @@ +class ChangePrimaryIdentifierForSecurity < ActiveRecord::Migration[7.2] + def change + rename_column :securities, :symbol, :ticker + remove_column :securities, :isin, :string + rename_column :security_prices, :isin, :ticker + end +end diff --git a/db/schema.rb b/db/schema.rb index 96301c2d..a80382f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do +ActiveRecord::Schema[7.2].define(version: 2024_07_31_191344) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -118,7 +118,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do t.boolean "is_active", default: true, null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["institution_id"], name: "index_accounts_on_institution_id" @@ -347,15 +347,14 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do end create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "isin", null: false - t.string "symbol" + t.string "ticker" t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "isin" + t.string "ticker" t.date "date" t.decimal "price", precision: 19, scale: 4 t.string "currency", default: "USD" diff --git a/test/fixtures/securities.yml b/test/fixtures/securities.yml index 790b4631..20c676a5 100644 --- a/test/fixtures/securities.yml +++ b/test/fixtures/securities.yml @@ -1,9 +1,7 @@ aapl: - isin: US0378331005 - symbol: aapl + ticker: AAPL name: Apple msft: - isin: US5949181045 - symbol: msft + ticker: MSFT name: Microsoft diff --git a/test/fixtures/security/prices.yml b/test/fixtures/security/prices.yml index b78fe85a..f49de36c 100644 --- a/test/fixtures/security/prices.yml +++ b/test/fixtures/security/prices.yml @@ -1,11 +1,11 @@ one: - isin: US0378331005 # AAPL + ticker: AAPL date: <%= Date.current %> price: 215 currency: USD two: - isin: US0378331005 # AAPL + ticker: AAPL date: <%= 1.day.ago.to_date %> price: 214 currency: USD diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index ae288641..df851688 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -8,8 +8,8 @@ module ExchangeRateProviderInterfaceTest end test "exchange rate provider response contract" do - accounting_for_http_calls do - response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current + VCR.use_cassette "synth/exchange_rate" do + response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") assert_respond_to response, :rate assert_respond_to response, :success? @@ -17,11 +17,4 @@ module ExchangeRateProviderInterfaceTest assert_respond_to response, :raw_response end end - - private - def accounting_for_http_calls - VCR.use_cassette "synth_exchange_rate" do - yield - end - end end diff --git a/test/interfaces/security_price_provider_interface_test.rb b/test/interfaces/security_price_provider_interface_test.rb new file mode 100644 index 00000000..97f2e9ad --- /dev/null +++ b/test/interfaces/security_price_provider_interface_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +module SecurityPriceProviderInterfaceTest + extend ActiveSupport::Testing::Declarative + + test "security price provider interface" do + assert_respond_to @subject, :fetch_security_prices + end + + test "security price provider response contract" do + VCR.use_cassette "synth/security_prices" do + response = @subject.fetch_security_prices ticker: "AAPL", start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01") + + assert_respond_to response, :prices + assert_respond_to response, :success? + assert_respond_to response, :error + assert_respond_to response, :raw_response + end + end +end diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb index 6f01aabc..b6048231 100644 --- a/test/models/account/balance/syncer_test.rb +++ b/test/models/account/balance/syncer_test.rb @@ -93,8 +93,10 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase syncer = Account::Balance::Syncer.new(@account) - assert_raises Money::ConversionError do - syncer.run + with_env_overrides SYNTH_API_KEY: nil do + assert_raises Money::ConversionError do + syncer.run + end end end @@ -104,7 +106,10 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase @account.update! currency: "EUR" syncer = Account::Balance::Syncer.new(@account) - syncer.run + + with_env_overrides SYNTH_API_KEY: nil do + syncer.run + end assert_equal 1, syncer.warnings.count end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index a6387936..e1bfa6da 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -113,6 +113,6 @@ class Account::EntryTest < ActiveSupport::TestCase entryable: Account::Trade.new(qty: -10, price: 200, security: security) end - assert_match /cannot sell 10.0 shares of aapl because you only own 0.0 shares/, error.message + assert_match /cannot sell 10.0 shares of AAPL because you only own 0.0 shares/, error.message end end diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb index 14961b9b..5eaa45ef 100644 --- a/test/models/account/holding/syncer_test.rb +++ b/test/models/account/holding/syncer_test.rb @@ -33,11 +33,29 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN expected = [ - { symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date }, - { symbol: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date }, - { symbol: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current }, - { symbol: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date }, - { symbol: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current } + { ticker: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date }, + { ticker: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date }, + { ticker: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current }, + { ticker: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date }, + { ticker: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current } + ] + + run_sync_for(@account) + + assert_holdings(expected) + end + + test "generates holdings with prices" do + provider = mock + Security::Price.stubs(:security_prices_provider).returns(provider) + + provider.expects(:fetch_security_prices).never + + amzn = create_security("AMZN", prices: [ { date: Date.current, price: 215 } ]) + create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215) + + expected = [ + { ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: Date.current } ] run_sync_for(@account) @@ -46,21 +64,26 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase end test "generates all holdings even when missing security prices" do - aapl = create_security("AMZN", prices: [ - { date: 1.day.ago.to_date, price: 215 } - ]) + amzn = create_security("AMZN", prices: []) - create_trade(aapl, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210) + create_trade(amzn, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210) # 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price # 1 day ago — finds daily price, uses it # Today — no daily price, no entry, so price and amount are `nil` expected = [ - { symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date }, - { symbol: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date }, - { symbol: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current } + { ticker: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date }, + { ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date }, + { ticker: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current } ] + Security::Price.expects(:find_prices) + .with(start_date: 2.days.ago.to_date, end_date: Date.current, ticker: "AMZN") + .once + .returns([ + Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215) + ]) + run_sync_for(@account) assert_holdings(expected) @@ -71,17 +94,17 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase def assert_holdings(expected_holdings) holdings = @account.holdings.includes(:security).to_a expected_holdings.each do |expected_holding| - actual_holding = holdings.find { |holding| holding.security.symbol == expected_holding[:symbol] && holding.date == expected_holding[:date] } + actual_holding = holdings.find { |holding| holding.security.ticker == expected_holding[:ticker] && holding.date == expected_holding[:date] } date = expected_holding[:date] expected_price = expected_holding[:price] expected_qty = expected_holding[:qty] expected_amount = expected_holding[:amount] - symbol = expected_holding[:symbol] + ticker = expected_holding[:ticker] - assert actual_holding, "expected #{symbol} holding on date: #{date}" - assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{symbol} on date: #{date}" - assert_equal expected_holding[:amount], actual_holding.amount, "expected #{expected_amount} amount for holding #{symbol} on date: #{date}" - assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{symbol} on date: #{date}" + assert actual_holding, "expected #{ticker} holding on date: #{date}" + assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}" + assert_equal expected_holding[:amount], actual_holding.amount, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}" + assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{ticker} on date: #{date}" end end diff --git a/test/models/account/holding_test.rb b/test/models/account/holding_test.rb index 291fb762..e7ba1866 100644 --- a/test/models/account/holding_test.rb +++ b/test/models/account/holding_test.rb @@ -58,7 +58,7 @@ class Account::HoldingTest < ActiveSupport::TestCase end def create_holding(security, date, qty) - price = Security::Price.find_by(date: date, isin: security.isin).price + price = Security::Price.find_by(date: date, ticker: security.ticker).price @account.holdings.create! \ date: date, diff --git a/test/models/account_test.rb b/test/models/account_test.rb index bc1fe8ed..3da01860 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -76,7 +76,9 @@ class AccountTest < ActiveSupport::TestCase end test "generates empty series if no balances and no exchange rate" do - assert_equal 0, @account.series(currency: "NZD").values.count + with_env_overrides SYNTH_API_KEY: nil do + assert_equal 0, @account.series(currency: "NZD").values.count + end end test "calculates shares owned of holding for date" do diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index c2398be9..41b0f22b 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -12,7 +12,7 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.unstub(:exchange_rates_provider) with_env_overrides SYNTH_API_KEY: nil do - assert_nil ExchangeRate.exchange_rates_provider + assert_not ExchangeRate.exchange_rates_provider end end @@ -21,7 +21,7 @@ class ExchangeRateTest < ActiveSupport::TestCase rate = exchange_rates(:one) - assert_equal exchange_rates(:one), ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date) + assert_equal rate, ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date) end test "finds single rate from provider and caches to DB" do @@ -38,14 +38,14 @@ class ExchangeRateTest < ActiveSupport::TestCase test "nil if rate is not found in DB and provider throws an error" do @provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false)) - assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) + assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) end test "nil if rate is not found in DB and provider is disabled" do ExchangeRate.unstub(:exchange_rates_provider) with_env_overrides SYNTH_API_KEY: nil do - assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) + assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) end end diff --git a/test/models/provider/synth_test.rb b/test/models/provider/synth_test.rb index 5559e7cb..d487e426 100644 --- a/test/models/provider/synth_test.rb +++ b/test/models/provider/synth_test.rb @@ -2,10 +2,18 @@ require "test_helper" require "ostruct" class Provider::SynthTest < ActiveSupport::TestCase - include ExchangeRateProviderInterfaceTest + include ExchangeRateProviderInterfaceTest, SecurityPriceProviderInterfaceTest setup do - @subject = @synth = Provider::Synth.new("fookey") + @subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"]) + end + + test "fetches paginated securities prices" do + VCR.use_cassette("synth/security_prices") do + response = @synth.fetch_security_prices ticker: "AAPL", start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01") + + assert 213, response.size + end end test "retries then provides failed response" do @@ -13,7 +21,7 @@ class Provider::SynthTest < ActiveSupport::TestCase response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current - assert_match "Failed to fetch exchange rate from Provider::Synth", response.error.message + assert_match "Failed to fetch data from Provider::Synth", response.error.message end test "retrying, then raising on network error" do diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index a86705dc..b2ae23a1 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -1,7 +1,98 @@ require "test_helper" +require "ostruct" class Security::PriceTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + setup do + @provider = mock + + Security::Price.stubs(:security_prices_provider).returns(@provider) + end + + test "security price provider nil if no api key provided" do + Security::Price.unstub(:security_prices_provider) + + with_env_overrides SYNTH_API_KEY: nil do + assert_not Security::Price.security_prices_provider + end + end + + test "finds single security price in DB" do + @provider.expects(:fetch_security_prices).never + + price = security_prices(:one) + + assert_equal price, Security::Price.find_price(ticker: price.ticker, date: price.date) + end + + test "caches prices to DB" do + expected_price = 314.34 + @provider.expects(:fetch_security_prices) + .once + .returns( + OpenStruct.new( + success?: true, + prices: [ { date: Date.current, price: expected_price } ] + ) + ) + + fetched_rate = Security::Price.find_price(ticker: "NVDA", date: Date.current, cache: true) + refetched_rate = Security::Price.find_price(ticker: "NVDA", date: Date.current, cache: true) + + assert_equal expected_price, fetched_rate.price + assert_equal expected_price, refetched_rate.price + end + + test "returns nil if no price found in DB or from provider" do + @provider.expects(:fetch_security_prices) + .with(ticker: "NVDA", start_date: Date.current, end_date: Date.current) + .once + .returns(OpenStruct.new(success?: false)) + + assert_not Security::Price.find_price(ticker: "NVDA", date: Date.current) + end + + test "returns nil if price not found in DB and provider disabled" do + Security::Price.unstub(:security_prices_provider) + + with_env_overrides SYNTH_API_KEY: nil do + assert_not Security::Price.find_price(ticker: "NVDA", date: Date.current) + end + end + + test "fetches multiple dates at once" do + @provider.expects(:fetch_security_prices).never + + price1 = security_prices(:one) # AAPL today + price2 = security_prices(:two) # AAPL yesterday + + fetched_prices = Security::Price.find_prices(start_date: 1.day.ago.to_date, end_date: Date.current, ticker: "AAPL").sort_by(&:date) + + assert_equal price1, fetched_prices[1] + assert_equal price2, fetched_prices[0] + end + + test "caches multiple prices to DB" do + missing_price = 213.21 + @provider.expects(:fetch_security_prices) + .with(ticker: "AAPL", start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date) + .returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price } ])) + .once + + price1 = security_prices(:one) # AAPL today + price2 = security_prices(:two) # AAPL yesterday + + fetched_prices = Security::Price.find_prices(ticker: "AAPL", start_date: 2.days.ago.to_date, end_date: Date.current, cache: true) + refetched_prices = Security::Price.find_prices(ticker: "AAPL", start_date: 2.days.ago.to_date, end_date: Date.current, cache: true) + + assert_equal [ missing_price, price2.price, price1.price ], fetched_prices.sort_by(&:date).map(&:price) + assert_equal [ missing_price, price2.price, price1.price ], refetched_prices.sort_by(&:date).map(&:price) + end + + test "returns empty array if no prices found in DB or from provider" do + Security::Price.unstub(:security_prices_provider) + + with_env_overrides SYNTH_API_KEY: nil do + assert_equal [], Security::Price.find_prices(ticker: "NVDA", start_date: 10.days.ago.to_date, end_date: Date.current) + end + end end diff --git a/test/support/account/entries_test_helper.rb b/test/support/account/entries_test_helper.rb index 926c0fd7..ac68574d 100644 --- a/test/support/account/entries_test_helper.rb +++ b/test/support/account/entries_test_helper.rb @@ -29,7 +29,7 @@ module Account::EntriesTestHelper end def create_trade(security, account:, qty:, date:, price: nil) - trade_price = price || Security::Price.find_by!(isin: security.isin, date: date).price + trade_price = price || Security::Price.find_by!(ticker: security.ticker, date: date).price trade = Account::Trade.new \ qty: qty, diff --git a/test/support/securities_test_helper.rb b/test/support/securities_test_helper.rb index bf823e86..b1ca5afc 100644 --- a/test/support/securities_test_helper.rb +++ b/test/support/securities_test_helper.rb @@ -1,16 +1,9 @@ module SecuritiesTestHelper - def create_security(symbol, prices:) - isin_codes = { - "AMZN" => "US0231351067", - "NVDA" => "US67066G1040" - } - - isin = isin_codes[symbol] - + def create_security(ticker, prices:) prices.each do |price| - Security::Price.create! isin: isin, date: price[:date], price: price[:price] + Security::Price.create! ticker: ticker, date: price[:date], price: price[:price] end - Security.create! isin: isin, symbol: symbol + Security.create! ticker: ticker end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 74c587f5..c2be16af 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,13 +1,12 @@ -if ENV["COVERAGE"] +if ENV["COVERAGE"] == "true" require "simplecov" SimpleCov.start "rails" do enable_coverage :branch end end -# Test ENV setup: -# By default, all features should be disabled -# Use the `with_env_overrides` helper to enable features for individual tests +require_relative "../config/environment" + ENV["SELF_HOSTING_ENABLED"] = "false" ENV["UPGRADES_ENABLED"] = "false" ENV["RAILS_ENV"] ||= "test" @@ -16,7 +15,6 @@ ENV["RAILS_ENV"] ||= "test" # https://github.com/ged/ruby-pg/issues/538#issuecomment-1591629049 ENV["PGGSSENCMODE"] = "disable" -require_relative "../config/environment" require "rails/test_help" require "minitest/mock" require "minitest/autorun" @@ -33,10 +31,10 @@ end module ActiveSupport class TestCase # Run tests in parallel with specified workers - parallelize(workers: :number_of_processors) unless ENV["DISABLE_PARALLELIZATION"] + parallelize(workers: :number_of_processors) unless ENV["DISABLE_PARALLELIZATION"] == "true" # https://github.com/simplecov-ruby/simplecov/issues/718#issuecomment-538201587 - if ENV["COVERAGE"] + if ENV["COVERAGE"] == "true" parallelize_setup do |worker| SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}" end diff --git a/test/vcr_cassettes/synth_exchange_rate.yml b/test/vcr_cassettes/synth/exchange_rate.yml similarity index 67% rename from test/vcr_cassettes/synth_exchange_rate.yml rename to test/vcr_cassettes/synth/exchange_rate.yml index 314ca123..40fb9995 100644 --- a/test/vcr_cassettes/synth_exchange_rate.yml +++ b/test/vcr_cassettes/synth/exchange_rate.yml @@ -2,13 +2,13 @@ http_interactions: - request: method: get - uri: https://api.synthfinance.com/rates/historical?date=<%= Date.current.to_s %>&from=USD&to=MXN + uri: https://api.synthfinance.com/rates/historical?date=2024-08-01&from=USD&to=MXN body: encoding: US-ASCII string: '' headers: User-Agent: - - Faraday v2.9.0 + - Faraday v2.10.0 Authorization: - Bearer Accept-Encoding: @@ -21,21 +21,21 @@ http_interactions: message: OK headers: Date: - - Wed, 27 Mar 2024 02:54:11 GMT + - Thu, 01 Aug 2024 17:20:28 GMT Content-Type: - application/json; charset=utf-8 - Content-Length: - - '138' + Transfer-Encoding: + - chunked Connection: - keep-alive Cf-Ray: - - 86ac182ad9ec7ce5-LAX + - 8ac77fbcc9d013ae-CMH Cf-Cache-Status: - DYNAMIC Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"46780d3f34043bb3bc799b1efae62418" + - W/"668c8ac287a5ff6d6a705c35c69823b1" Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -43,7 +43,7 @@ http_interactions: Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 3ca97b82-f963-43a3 + - ff56c2fe-6252-4b2c X-Content-Type-Options: - nosniff X-Frame-Options: @@ -53,9 +53,9 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 64731a8c-4cad-4e42-81c9-60b0d3634a0f + - 61992b01-969b-4af5-8119-9b17e385da07 X-Runtime: - - '0.021432' + - '0.369358' X-Xss-Protection: - '0' Server: @@ -64,6 +64,6 @@ http_interactions: - h3=":443"; ma=86400 body: encoding: ASCII-8BIT - string: '{"data":{"date":"<%= Date.current.to_s %>","source":"USD","rates":{"MXN":16.64663}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":976}}' - recorded_at: Wed, 27 Mar 2024 02:54:11 GMT + string: '{"data":{"date":"2024-08-01","source":"USD","rates":{"MXN":18.645877}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":248999}}' + recorded_at: Thu, 01 Aug 2024 17:20:28 GMT recorded_with: VCR 6.2.0 diff --git a/test/vcr_cassettes/synth/security_prices.yml b/test/vcr_cassettes/synth/security_prices.yml new file mode 100644 index 00000000..6cdf1fe6 --- /dev/null +++ b/test/vcr_cassettes/synth/security_prices.yml @@ -0,0 +1,135 @@ +--- +http_interactions: + - request: + method: get + uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&page=1&start_date=2024-01-01 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Faraday v2.10.0 + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 01 Aug 2024 17:21:42 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 8ac781877cbb13ae-CMH + Cf-Cache-Status: + - DYNAMIC + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"c1f8d4686b33c94fa18354b54c960f43" + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - e6566c15-53f8-44b0 + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 14434e85-b1d4-4c36-a69a-efd14c562649 + X-Runtime: + - '1.397922' + X-Xss-Protection: + - '0' + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"ticker":"AAPL","prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":81964874},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414460},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71878670},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62371161},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144470},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841809},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46192908},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128408},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40477782},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65076641},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317433},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":77722754},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68887985},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60131852},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355590},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631316},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822126},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44587111},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145622},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55836970},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467803},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885408},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102527680},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69654320},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490759},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53438955},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962046},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155216},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781934},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529529},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54617917},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434496},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49752465},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53574453},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41496371},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52284192},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":44926677},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867421},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318851},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48943139},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682597},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73450582},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81505451},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":94702355},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68568907},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71763761},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76267041},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139473},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59813522},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488692},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913507},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121752699},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75606556},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215244},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423102},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181270},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71146138},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288328},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388449},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60263665},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65671690},{"date":"2024-04-01","open":171.19,"close":170.03,"high":171.25,"low":169.48,"volume":46240500},{"date":"2024-04-02","open":169.08,"close":168.84,"high":169.34,"low":168.23,"volume":49297581},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691715},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53682486},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42104826},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425513},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451209},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49691936},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91053075},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101282386},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":70733115},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":71583932},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":48503680},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":40735511},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":66084170},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":46488244},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":46956672},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":47007455},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":48858902},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44014087},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":66891905},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":64066593},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":48416441},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":91402452},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":160948084},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":75883763},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":74139796},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":43762264},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":47493785},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":48525869},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":68586935},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":50551025},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":67561123},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":51938566},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":39819440},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":43637717},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":41192656},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":33510741},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":48553611}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":248998}}' + recorded_at: Thu, 01 Aug 2024 17:21:42 GMT + - request: + method: get + uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&page=2&start_date=2024-01-01 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Faraday v2.10.0 + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 01 Aug 2024 17:21:44 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 8ac78191cc326a4c-CMH + Cf-Cache-Status: + - DYNAMIC + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"88e17df1f20118595ae291a4a025291f" + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - e8700161-b44e-49ce + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 6072beb1-2e89-4ce0-95e0-72d436adc033 + X-Runtime: + - '1.296361' + X-Xss-Protection: + - '0' + Server: + - cloudflare + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"ticker":"AAPL","prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":35429737},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":51021752},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":51934816},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":48211467},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":71937580},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":48702790},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":46573003},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":53100041},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":39591471},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":52508446},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":95034362},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":169677009},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":197067068},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":96562134},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":69150814},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":92964543},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":78534656},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":83863022},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":204018186},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":76303387},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":54266550},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":64531178},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":48631748},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":80927625},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":59475152},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":57112299},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":36707517},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":58287571},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":57456163},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":47531745},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":61539280},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":63197762},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":51621443},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":60513737},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":38468546},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":55878906},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":64346030},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":48020038},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":44958139},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":37919040},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":59687424},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":50451768},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":39827645},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":35153729},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":40681625},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":48422974},{"date":"2024-08-01","open":224.37,"high":224.81,"low":217.78,"volume":25116548}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":248997}}' + recorded_at: Thu, 01 Aug 2024 17:21:44 GMT +recorded_with: VCR 6.2.0