diff --git a/app/jobs/sync_market_data_job.rb b/app/jobs/sync_market_data_job.rb index 074cda9f..db34a41a 100644 --- a/app/jobs/sync_market_data_job.rb +++ b/app/jobs/sync_market_data_job.rb @@ -1,7 +1,20 @@ +# This job runs daily at market close. See config/schedule.yml for details. +# +# The primary purpose of this job is to: +# 1. Determine what exchange rate pairs, security prices, and other market data all of our users need to view historical account balance data +# 2. For each needed rate/price, fetch from our data provider and upsert to our database +# +# Each individual account sync will still fetch any missing market data that isn't yet synced, but by running +# this job daily, we significantly reduce overlapping account syncs that both need the same market data (e.g. common security like `AAPL`) +# class SyncMarketDataJob < ApplicationJob queue_as :scheduled - def perform - MarketDataSyncer.new.sync_all + def perform(opts) + opts = opts.symbolize_keys + mode = opts.fetch(:mode, :full) + clear_cache = opts.fetch(:clear_cache, false) + + MarketDataSyncer.new(mode: mode, clear_cache: clear_cache).sync end end diff --git a/app/models/account.rb b/app/models/account.rb index 8c74b83e..13734071 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Chartable, Linkable, Convertible, Enrichable + include Syncable, Monetizable, Chartable, Linkable, Enrichable validates :name, :balance, :currency, presence: true diff --git a/app/models/account/convertible.rb b/app/models/account/convertible.rb deleted file mode 100644 index fde6fa10..00000000 --- a/app/models/account/convertible.rb +++ /dev/null @@ -1,27 +0,0 @@ -module Account::Convertible - extend ActiveSupport::Concern - - def sync_required_exchange_rates - unless requires_exchange_rates? - Rails.logger.info("No exchange rate sync needed for account #{id}") - return - end - - affected_row_count = ExchangeRate.sync_provider_rates( - from: currency, - to: target_currency, - start_date: start_date, - ) - - Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}") - end - - private - def target_currency - family.currency - end - - def requires_exchange_rates? - currency != target_currency - end -end diff --git a/app/models/account/market_data_syncer.rb b/app/models/account/market_data_syncer.rb new file mode 100644 index 00000000..b223d229 --- /dev/null +++ b/app/models/account/market_data_syncer.rb @@ -0,0 +1,82 @@ +class Account::MarketDataSyncer + attr_reader :account + + def initialize(account) + @account = account + end + + def sync_market_data + sync_exchange_rates + sync_security_prices + end + + private + def sync_exchange_rates + return unless needs_exchange_rates? + return unless ExchangeRate.provider + + pair_dates = {} + + # 1. ENTRY-BASED PAIRS – currencies that differ from the account currency + account.entries + .where.not(currency: account.currency) + .group(:currency) + .minimum(:date) + .each do |source_currency, date| + key = [ source_currency, account.currency ] + pair_dates[key] = [ pair_dates[key], date ].compact.min + end + + # 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different) + if foreign_account? + key = [ account.currency, account.family.currency ] + pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min + end + + pair_dates.each do |(source, target), start_date| + ExchangeRate.sync_provider_rates( + from: source, + to: target, + start_date: start_date, + end_date: Date.current + ) + end + end + + def sync_security_prices + return unless Security.provider + + account_securities = account.trades.map(&:security).uniq + + return if account_securities.empty? + + account_securities.each do |security| + security.sync_provider_prices( + start_date: first_required_price_date(security), + end_date: Date.current + ) + + security.sync_provider_details + end + end + + # Calculates the first date we require a price for the given security scoped to this account + def first_required_price_date(security) + account.trades.with_entry + .where(security: security) + .where(entries: { account_id: account.id }) + .minimum("entries.date") + end + + def needs_exchange_rates? + has_multi_currency_entries? || foreign_account? + end + + def has_multi_currency_entries? + account.entries.where.not(currency: account.currency).exists? + end + + def foreign_account? + account.currency != account.family.currency + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 7e5fabcd..de63f5e8 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -7,6 +7,7 @@ class Account::Syncer def perform_sync(sync) Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") + sync_market_data sync_balances end @@ -19,4 +20,18 @@ class Account::Syncer strategy = account.linked? ? :reverse : :forward Balance::Syncer.new(account, strategy: strategy).sync_balances end + + # Syncs all the exchange rates + security prices this account needs to display historical chart data + # + # This is a *supplemental* sync. The daily market data sync should have already populated + # a majority or all of this data, so this is often a no-op. + # + # We rescue errors here because if this operation fails, we don't want to fail the entire sync since + # we have reasonable fallbacks for missing market data. + def sync_market_data + Account::MarketDataSyncer.new(account).sync_market_data + rescue => e + Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}") + Sentry.capture_exception(e) + end end diff --git a/app/models/balance/syncer.rb b/app/models/balance/syncer.rb index 362b87aa..890bb5f9 100644 --- a/app/models/balance/syncer.rb +++ b/app/models/balance/syncer.rb @@ -19,8 +19,6 @@ class Balance::Syncer if strategy == :forward update_account_info end - - account.sync_required_exchange_rates end end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index dbe87133..5a1b4c60 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -27,29 +27,21 @@ module ExchangeRate::Provided rate end - def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + # @return [Integer] The number of exchange rates synced + def sync_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false) unless provider.present? Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates") return 0 end - fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date) - - unless fetched_rates.success? - Rails.logger.error("Provider error for ExchangeRate.sync_provider_rates: #{fetched_rates.error}") - return 0 - end - - rates_data = fetched_rates.data.map do |rate| - { - from_currency: rate.from, - to_currency: rate.to, - date: rate.date, - rate: rate.rate - } - end - - ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date]) + ExchangeRate::Syncer.new( + exchange_rate_provider: provider, + from: from, + to: to, + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ).sync_provider_rates end end end diff --git a/app/models/exchange_rate/syncer.rb b/app/models/exchange_rate/syncer.rb new file mode 100644 index 00000000..1f73bc8e --- /dev/null +++ b/app/models/exchange_rate/syncer.rb @@ -0,0 +1,156 @@ +class ExchangeRate::Syncer + MissingExchangeRateError = Class.new(StandardError) + MissingStartRateError = Class.new(StandardError) + + def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false) + @exchange_rate_provider = exchange_rate_provider + @from = from + @to = to + @start_date = start_date + @end_date = normalize_end_date(end_date) + @clear_cache = clear_cache + end + + # Constructs a daily series of rates for the given currency pair for date range + def sync_provider_rates + if !clear_cache && all_rates_exist? + Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping") + return + end + + if clear_cache && provider_rates.empty? + Rails.logger.warn("Could not clear cache for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates") + return + end + + prev_rate_value = start_rate_value + + unless prev_rate_value.present? + error = MissingStartRateError.new("Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}") + Rails.logger.error(error.message) + Sentry.capture_exception(error) + return + end + + gapfilled_rates = effective_start_date.upto(end_date).map do |date| + db_rate_value = db_rates[date]&.rate + provider_rate_value = provider_rates[date]&.rate + + chosen_rate = if clear_cache + provider_rate_value || db_rate_value # overwrite when possible + else + db_rate_value || provider_rate_value # fill gaps + end + + # Gapfill with LOCF strategy (last observation carried forward) + if chosen_rate.nil? + chosen_rate = prev_rate_value + end + + prev_rate_value = chosen_rate + + { + from_currency: from, + to_currency: to, + date: date, + rate: chosen_rate + } + end + + upsert_rows(gapfilled_rates) + end + + private + attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache + + def upsert_rows(rows) + batch_size = 200 + + total_upsert_count = 0 + + rows.each_slice(batch_size) do |batch| + upserted_ids = ExchangeRate.upsert_all( + batch, + unique_by: %i[from_currency to_currency date], + returning: [ "id" ] + ) + + total_upsert_count += upserted_ids.count + end + + total_upsert_count + end + + # Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date + def start_rate_value + provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last + db_rate_value = db_rates[start_date]&.rate + provider_rate_value || db_rate_value + end + + # No need to fetch/upsert rates for dates that we already have in the DB + def effective_start_date + return start_date if clear_cache + + first_missing_date = nil + + start_date.upto(end_date) do |date| + unless db_rates.key?(date) + first_missing_date = date + break + end + end + + first_missing_date || end_date + end + + def provider_rates + @provider_rates ||= begin + # Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays) + provider_fetch_start_date = effective_start_date - 5.days + + provider_response = exchange_rate_provider.fetch_exchange_rates( + from: from, + to: to, + start_date: provider_fetch_start_date, + end_date: end_date + ) + + if provider_response.success? + provider_response.data.index_by(&:date) + else + message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}" + Rails.logger.warn(message) + Sentry.capture_exception(MissingExchangeRateError.new(message)) + {} + end + end + end + + def all_rates_exist? + db_count == expected_count + end + + def expected_count + (start_date..end_date).count + end + + def db_count + db_rates.count + end + + def db_rates + @db_rates ||= ExchangeRate.where(from_currency: from, to_currency: to, date: start_date..end_date) + .order(:date) + .to_a + .index_by(&:date) + end + + # Normalizes an end date so that it never exceeds today's date in the + # America/New_York timezone. If the caller passes a future date we clamp + # it to today so that upstream provider calls remain valid and predictable. + def normalize_end_date(requested_end_date) + today_est = Date.current.in_time_zone("America/New_York").to_date + [ requested_end_date, today_est ].min + end +end diff --git a/app/models/market_data_syncer.rb b/app/models/market_data_syncer.rb index d634cd45..70d60b75 100644 --- a/app/models/market_data_syncer.rb +++ b/app/models/market_data_syncer.rb @@ -1,196 +1,132 @@ class MarketDataSyncer - DEFAULT_HISTORY_DAYS = 30 - RATE_PROVIDER_NAME = :synth - PRICE_PROVIDER_NAME = :synth + # By default, our graphs show 1M as the view, so by fetching 31 days, + # we ensure we can always show an accurate default graph + SNAPSHOT_DAYS = 31 - MissingExchangeRateError = Class.new(StandardError) - InvalidExchangeRateDataError = Class.new(StandardError) - MissingSecurityPriceError = Class.new(StandardError) - InvalidSecurityPriceDataError = Class.new(StandardError) + InvalidModeError = Class.new(StandardError) - class << self - def for(family: nil, account: nil) - new(family: family, account: account) - end + def initialize(mode: :full, clear_cache: false) + @mode = set_mode!(mode) + @clear_cache = clear_cache end - # Syncer can optionally be scoped. Otherwise, it syncs all user data - def initialize(family: nil, account: nil) - @family = family - @account = account + def sync + sync_prices + sync_exchange_rates end - def sync_all(full_history: false) - sync_exchange_rates(full_history: full_history) - sync_prices(full_history: full_history) - end - - def sync_exchange_rates(full_history: false) - unless rate_provider - Rails.logger.warn("No rate provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync") + # Syncs historical security prices (and details) + def sync_prices + unless Security.provider + Rails.logger.warn("No provider configured for MarketDataSyncer.sync_prices, skipping sync") return end - # Finds distinct currency pairs - entry_pairs = entries_scope.joins(:account) - .where.not("entries.currency = accounts.currency") - .select("entries.currency as source, accounts.currency as target") - .distinct + Security.where.not(exchange_operating_mic: nil).find_each do |security| + security.sync_provider_prices( + start_date: get_first_required_price_date(security), + end_date: end_date, + clear_cache: clear_cache + ) - # All accounts in currency not equal to the family currency require exchange rates to show a normalized historical graph - account_pairs = accounts_scope.joins(:family) - .where.not("families.currency = accounts.currency") - .select("accounts.currency as source, families.currency as target") - .distinct - - pairs = (entry_pairs + account_pairs).uniq - - pairs.each do |pair| - sync_exchange_rate(from: pair.source, to: pair.target, full_history: full_history) + security.sync_provider_details(clear_cache: clear_cache) end end - def sync_prices(full_history: false) - unless price_provider - Rails.logger.warn("No price provider configured for MarketDataSyncer.sync_prices, skipping sync") - nil + def sync_exchange_rates + unless ExchangeRate.provider + Rails.logger.warn("No provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync") + return end - securities_scope.each do |security| - sync_security_price(security: security, full_history: full_history) + required_exchange_rate_pairs.each do |pair| + # pair is a Hash with keys :source, :target, and :start_date + start_date = snapshot? ? default_start_date : pair[:start_date] + + ExchangeRate.sync_provider_rates( + from: pair[:source], + to: pair[:target], + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ) end end private - attr_reader :family, :account + attr_reader :mode, :clear_cache - def accounts_scope - return Account.where(id: account.id) if account - return family.accounts if family - Account.all + def snapshot? + mode.to_sym == :snapshot end - def entries_scope - account&.entries || family&.entries || Entry.all - end + # Builds a unique list of currency pairs with the earliest date we need + # exchange rates for. + # + # Returns: Array of Hashes – [{ source:, target:, start_date: }, ...] + def required_exchange_rate_pairs + pair_dates = {} # { [source, target] => earliest_date } - def securities_scope - if account - account.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil }) - elsif family - family.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil }) - else - Security.where.not(exchange_operating_mic: nil) + # 1. ENTRY-BASED PAIRS – we need rates from the first entry date + Entry.joins(:account) + .where.not("entries.currency = accounts.currency") + .group("entries.currency", "accounts.currency") + .minimum("entries.date") + .each do |(source, target), date| + key = [ source, target ] + pair_dates[key] = [ pair_dates[key], date ].compact.min + end + + # 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date + account_first_entry_dates = Entry.group(:account_id).minimum(:date) + + Account.joins(:family) + .where.not("families.currency = accounts.currency") + .select("accounts.id, accounts.currency AS source, families.currency AS target") + .find_each do |account| + earliest_entry_date = account_first_entry_dates[account.id] + + chosen_date = [ earliest_entry_date, default_start_date ].compact.min + + key = [ account.source, account.target ] + pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min + end + + # Convert to array of hashes for ease of use + pair_dates.map do |(source, target), date| + { source: source, target: target, start_date: date } end end - def sync_security_price(security:, full_history:) - start_date = full_history ? find_oldest_required_price(security: security) : default_start_date + def get_first_required_price_date(security) + return default_start_date if snapshot? - Rails.logger.info("Syncing security price for: #{security.ticker}, start_date: #{start_date}, end_date: #{end_date}") - - fetched_prices = price_provider.fetch_security_prices( - security, - start_date: start_date, - end_date: end_date - ) - - unless fetched_prices.success? - error = MissingSecurityPriceError.new( - "#{PRICE_PROVIDER_NAME} could not fetch security price for: #{security.ticker} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_prices.error.message}" - ) - - Rails.logger.warn(error.message) - Sentry.capture_exception(error, level: :warning) - - return - end - - prices_for_upsert = fetched_prices.data.map do |price| - if price.security.nil? || price.date.nil? || price.price.nil? || price.currency.nil? - error = InvalidSecurityPriceDataError.new( - "#{PRICE_PROVIDER_NAME} returned invalid price data for security: #{security.ticker} on: #{price.date}. Price data: #{price.inspect}" - ) - - Rails.logger.warn(error.message) - Sentry.capture_exception(error, level: :warning) - - next - end - - { - security_id: price.security.id, - date: price.date, - price: price.price, - currency: price.currency - } - end.compact - - Security::Price.upsert_all( - prices_for_upsert, - unique_by: %i[security_id date currency] - ) + Trade.with_entry.where(security: security).minimum(:date) end - def sync_exchange_rate(from:, to:, full_history:) - start_date = full_history ? find_oldest_required_rate(from_currency: from) : default_start_date + # An approximation that grabs more than we likely need, but simplifies the logic + def get_first_required_exchange_rate_date(from_currency:) + return default_start_date if snapshot? - Rails.logger.info("Syncing exchange rate from: #{from}, to: #{to}, start_date: #{start_date}, end_date: #{end_date}") - - fetched_rates = rate_provider.fetch_exchange_rates( - from: from, - to: to, - start_date: start_date, - end_date: end_date - ) - - unless fetched_rates.success? - message = "#{RATE_PROVIDER_NAME} could not fetch exchange rate pair from: #{from} to: #{to} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_rates.error.message}" - Rails.logger.warn(message) - Sentry.capture_exception(MissingExchangeRateError.new(message)) - return - end - - rates_for_upsert = fetched_rates.data.map do |rate| - if rate.from.nil? || rate.to.nil? || rate.date.nil? || rate.rate.nil? - message = "#{RATE_PROVIDER_NAME} returned invalid rate data for pair from: #{from} to: #{to} on: #{rate.date}. Rate data: #{rate.inspect}" - Rails.logger.warn(message) - Sentry.capture_exception(InvalidExchangeRateDataError.new(message)) - next - end - - { - from_currency: rate.from, - to_currency: rate.to, - date: rate.date, - rate: rate.rate - } - end.compact - - ExchangeRate.upsert_all( - rates_for_upsert, - unique_by: %i[from_currency to_currency date] - ) - end - - def rate_provider - Provider::Registry.for_concept(:exchange_rates).get_provider(RATE_PROVIDER_NAME) - end - - def price_provider - Provider::Registry.for_concept(:securities).get_provider(PRICE_PROVIDER_NAME) - end - - def find_oldest_required_rate(from_currency:) - entries_scope.where(currency: from_currency).minimum(:date) || default_start_date + Entry.where(currency: from_currency).minimum(:date) end def default_start_date - DEFAULT_HISTORY_DAYS.days.ago.to_date + SNAPSHOT_DAYS.days.ago.to_date end # Since we're querying market data from a US-based API, end date should always be today (EST) def end_date Date.current.in_time_zone("America/New_York").to_date end + + def set_mode!(mode) + valid_modes = [ :full, :snapshot ] + + unless valid_modes.include?(mode.to_sym) + raise InvalidModeError, "Invalid mode for MarketDataSyncer, can only be :full or :snapshot, but was #{mode}" + end + + mode.to_sym + end end diff --git a/app/models/provider/security_concept.rb b/app/models/provider/security_concept.rb index 1fc915e7..d54b2011 100644 --- a/app/models/provider/security_concept.rb +++ b/app/models/provider/security_concept.rb @@ -2,22 +2,22 @@ module Provider::SecurityConcept extend ActiveSupport::Concern Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic) - SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind) - Price = Data.define(:security, :date, :price, :currency) + SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic) + Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic) def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) raise NotImplementedError, "Subclasses must implement #search_securities" end - def fetch_security_info(security) + def fetch_security_info(symbol:, exchange_operating_mic:) raise NotImplementedError, "Subclasses must implement #fetch_security_info" end - def fetch_security_price(security, date:) + def fetch_security_price(symbol:, exchange_operating_mic:, date:) raise NotImplementedError, "Subclasses must implement #fetch_security_price" end - def fetch_security_prices(security, start_date:, end_date:) + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_security_prices" end end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index ff75ff49..fee3a236 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -3,6 +3,8 @@ class Provider::Synth < Provider # Subclass so errors caught in this provider are raised as Provider::Synth::Error Error = Class.new(Provider::Error) + InvalidExchangeRateError = Class.new(Error) + InvalidSecurityPriceError = Class.new(Error) def initialize(api_key) @api_key = api_key @@ -48,7 +50,7 @@ class Provider::Synth < Provider rates = JSON.parse(response.body).dig("data", "rates") - Rate.new(date:, from:, to:, rate: rates.dig(to)) + Rate.new(date: date.to_date, from:, to:, rate: rates.dig(to)) end end @@ -65,8 +67,18 @@ class Provider::Synth < Provider end data.paginated.map do |rate| - Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to)) - end + date = rate.dig("date") + rate = rate.dig("rates", to) + + if date.nil? || rate.nil? + message = "#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}" + Rails.logger.warn(message) + Sentry.capture_exception(InvalidExchangeRateError.new(message), level: :warning) + next + end + + Rate.new(date: date.to_date, from:, to:, rate:) + end.compact end end @@ -97,65 +109,73 @@ class Provider::Synth < Provider end end - def fetch_security_info(security) + def fetch_security_info(symbol:, exchange_operating_mic:) with_provider_response do - response = client.get("#{base_url}/tickers/#{security.ticker}") do |req| - req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present? - req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present? + response = client.get("#{base_url}/tickers/#{symbol}") do |req| + req.params["operating_mic"] = exchange_operating_mic end data = JSON.parse(response.body).dig("data") SecurityInfo.new( - symbol: data.dig("ticker"), + symbol: symbol, name: data.dig("name"), links: data.dig("links"), logo_url: data.dig("logo_url"), description: data.dig("description"), - kind: data.dig("kind") + kind: data.dig("kind"), + exchange_operating_mic: exchange_operating_mic ) end end - def fetch_security_price(security, date:) + def fetch_security_price(symbol:, exchange_operating_mic:, date:) with_provider_response do - historical_data = fetch_security_prices(security, start_date: date, end_date: date) + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) - raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.empty? + raise ProviderError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.empty? historical_data.data.first end end - def fetch_security_prices(security, start_date:, end_date:) + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) with_provider_response do params = { start_date: start_date, - end_date: end_date + end_date: end_date, + operating_mic_code: exchange_operating_mic } - params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present? - data = paginate( - "#{base_url}/tickers/#{security.ticker}/open-close", + "#{base_url}/tickers/#{symbol}/open-close", params ) do |body| body.dig("prices") end currency = data.first_page.dig("currency") - country_code = data.first_page.dig("exchange", "country_code") - exchange_mic = data.first_page.dig("exchange", "mic_code") exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code") data.paginated.map do |price| + date = price.dig("date") + price = price.dig("close") || price.dig("open") + + if date.nil? || price.nil? + message = "#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}" + Rails.logger.warn(message) + Sentry.capture_exception(InvalidSecurityPriceError.new(message), level: :warning) + next + end + Price.new( - security: security, - date: price.dig("date"), - price: price.dig("close") || price.dig("open"), - currency: currency + symbol: symbol, + date: date.to_date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic ) - end + end.compact end end diff --git a/app/models/security/price/syncer.rb b/app/models/security/price/syncer.rb new file mode 100644 index 00000000..dbdf0831 --- /dev/null +++ b/app/models/security/price/syncer.rb @@ -0,0 +1,145 @@ +class Security::Price::Syncer + MissingSecurityPriceError = Class.new(StandardError) + MissingStartPriceError = Class.new(StandardError) + + def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false) + @security = security + @security_provider = security_provider + @start_date = start_date + @end_date = normalize_end_date(end_date) + @clear_cache = clear_cache + end + + # Constructs a daily series of prices for a single security over the date range. + # Returns the number of rows upserted. + def sync_provider_prices + if !clear_cache && all_prices_exist? + Rails.logger.info("No new prices to sync for #{security.ticker} between #{start_date} and #{end_date}, skipping") + return 0 + end + + if clear_cache && provider_prices.empty? + Rails.logger.warn("Could not clear cache for #{security.ticker} between #{start_date} and #{end_date} because provider returned no prices") + return 0 + end + + prev_price_value = start_price_value + + unless prev_price_value.present? + error = MissingStartPriceError.new("Could not find a start price for #{security.ticker} on or before #{start_date}") + Rails.logger.error(error.message) + Sentry.capture_exception(error) + return 0 + end + + gapfilled_prices = effective_start_date.upto(end_date).map do |date| + db_price_value = db_prices[date]&.price + provider_price_value = provider_prices[date]&.price + provider_currency = provider_prices[date]&.currency + + chosen_price = if clear_cache + provider_price_value || db_price_value # overwrite when possible + else + db_price_value || provider_price_value # fill gaps + end + + # Gap-fill using LOCF (last observation carried forward) + chosen_price ||= prev_price_value + prev_price_value = chosen_price + + { + security_id: security.id, + date: date, + price: chosen_price, + currency: provider_currency || prev_price_currency || db_price_currency || "USD" + } + end + + upsert_rows(gapfilled_prices) + end + + private + attr_reader :security, :security_provider, :start_date, :end_date, :clear_cache + + def provider_prices + @provider_prices ||= begin + provider_fetch_start_date = effective_start_date - 5.days + + response = security_provider.fetch_security_prices( + symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: provider_fetch_start_date, + end_date: end_date + ) + + if response.success? + response.data.index_by(&:date) + else + msg = "#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}" + Rails.logger.warn(msg) + Sentry.capture_exception(MissingSecurityPriceError.new(msg)) + {} + end + end + end + + def db_prices + @db_prices ||= Security::Price.where(security_id: security.id, date: start_date..end_date) + .order(:date) + .to_a + .index_by(&:date) + end + + def all_prices_exist? + db_prices.count == expected_count + end + + def expected_count + (start_date..end_date).count + end + + # Skip over ranges that already exist unless clearing cache + def effective_start_date + return start_date if clear_cache + + (start_date..end_date).detect { |d| !db_prices.key?(d) } || end_date + end + + def start_price_value + provider_price_value = provider_prices.select { |date, _| date <= start_date } + .max_by { |date, _| date } + &.last&.price + db_price_value = db_prices[start_date]&.price + provider_price_value || db_price_value + end + + def upsert_rows(rows) + batch_size = 200 + total_upsert_count = 0 + + rows.each_slice(batch_size) do |batch| + ids = Security::Price.upsert_all( + batch, + unique_by: %i[security_id date currency], + returning: [ "id" ] + ) + total_upsert_count += ids.count + end + + total_upsert_count + end + + def db_price_currency + db_prices.values.first&.currency + end + + def prev_price_currency + @prev_price_currency ||= provider_prices.values.first&.currency + end + + # Clamp to today (EST) so we never call our price API for a future date (our API is in EST/EDT timezone) + def normalize_end_date(requested_end_date) + today_est = Date.current.in_time_zone("America/New_York").to_date + [ requested_end_date, today_est ].min + end +end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 3d344f29..2214ccfa 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -49,6 +49,48 @@ module Security::Provided price end + def sync_provider_details(clear_cache: false) + unless provider.present? + Rails.logger.warn("No provider configured for Security.sync_provider_details") + return + end + + if self.name.present? && self.logo_url.present? && !clear_cache + return + end + + response = provider.fetch_security_info( + symbol: ticker, + exchange_operating_mic: exchange_operating_mic + ) + + if response.success? + update( + name: response.data.name, + logo_url: response.data.logo_url, + ) + else + err = StandardError.new("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") + Rails.logger.warn(err.message) + Sentry.capture_exception(err, level: :warning) + end + end + + def sync_provider_prices(start_date:, end_date:, clear_cache: false) + unless provider.present? + Rails.logger.warn("No provider configured for Security.sync_provider_prices") + return 0 + end + + Security::Price::Syncer.new( + security: self, + security_provider: provider, + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ).sync_provider_prices + end + private def provider self.class.provider diff --git a/app/models/stock_exchange.rb b/app/models/stock_exchange.rb deleted file mode 100644 index 93631426..00000000 --- a/app/models/stock_exchange.rb +++ /dev/null @@ -1,3 +0,0 @@ -class StockExchange < ApplicationRecord - scope :in_country, ->(country_code) { where(country_code: country_code) } -end diff --git a/app/models/trade_builder.rb b/app/models/trade_builder.rb index 5a2f9df1..cf9800e5 100644 --- a/app/models/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -129,13 +129,9 @@ class TradeBuilder def security ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] - security = Security.find_or_create_by!( + Security.find_or_create_by!( ticker: ticker_symbol, exchange_operating_mic: exchange_operating_mic ) - - FetchSecurityInfoJob.perform_later(security.id) - - security end end diff --git a/config/exchanges.yml b/config/exchanges.yml deleted file mode 100644 index 9b429d14..00000000 --- a/config/exchanges.yml +++ /dev/null @@ -1,1020 +0,0 @@ -- name: NASDAQ Stock Exchange - acronym: NASDAQ - mic: XNAS - country: USA - country_code: US - city: New York - website: www.nasdaq.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: New York Stock Exchange - acronym: NYSE - mic: XNYS - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: NYSE ARCA - acronym: NYSEARCA - mic: ARCX - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Markets - acronym: - mic: OTCM - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Buenos Aires Stock Exchange - acronym: BCBA - mic: XBUE - country: Argentina - country_code: AR - city: Buenos Aires - website: www.bcba.sba.com.ar - timezone: - timezone: America/Argentina/Buenos_Aires - abbr: -03 - abbr_dst: -03 - currency: - code: ARS - symbol: AR$ - name: Argentine Peso -- name: Bahrein Bourse - acronym: BSE - mic: XBAH - country: Bahrain - country_code: BH - city: Manama - website: www.bahrainbourse.com.bh - timezone: - timezone: Asia/Bahrain - abbr: +03 - abbr_dst: +03 - currency: - code: BHD - symbol: BD - name: Bahraini Dinar -- name: Euronext Brussels - acronym: Euronext - mic: XBRU - country: Belgium - country_code: BE - city: Brussels - website: www.euronext.com - timezone: - timezone: Europe/Brussels - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: B3 - Brasil Bolsa Balcão S.A - acronym: Bovespa - mic: BVMF - country: Brazil - country_code: BR - city: Sao Paulo - website: www.bmfbovespa.com.br - timezone: - timezone: America/Sao_Paulo - abbr: -03 - abbr_dst: -03 - currency: - code: BRL - symbol: R$ - name: Brazilian Real -- name: Toronto Stock Exchange - acronym: TSX - mic: XTSE - country: Canada - country_code: CA - city: Toronto - website: www.tse.com - timezone: - timezone: America/Toronto - abbr: EST - abbr_dst: EDT - currency: - code: CAD - symbol: CA$ - name: Canadian Dollar -- name: Canadian Securities Exchange - acronym: CNSX - mic: XCNQ - country: Canada - country_code: CA - city: Toronto - website: www.cnsx.ca - timezone: - timezone: America/Toronto - abbr: EST - abbr_dst: EDT - currency: - code: CAD - symbol: CA$ - name: Canadian Dollar -- name: Santiago Stock Exchange - acronym: BVS - mic: XSGO - country: Chile - country_code: CL - city: Santiago - website: www.bolsadesantiago.com - timezone: - timezone: America/Santiago - abbr: -03 - abbr_dst: -04 - currency: - code: CLP - symbol: CL$ - name: Chilean Peso -- name: Shanghai Stock Exchange - acronym: SSE - mic: XSHG - country: China - country_code: CN - city: Shanghai - website: www.sse.com.cn - timezone: - timezone: Asia/Shanghai - abbr: CST - abbr_dst: CST - currency: - code: CNY - symbol: CN¥ - name: Chinese Yuan -- name: Shenzhen Stock Exchange - acronym: SZSE - mic: XSHE - country: China - country_code: CN - city: Shenzhen - website: www.szse.cn - timezone: - timezone: Asia/Shanghai - abbr: CST - abbr_dst: CST - currency: - code: CNY - symbol: CN¥ - name: Chinese Yuan -- name: Bolsa de Valores de Colombia - acronym: BVC - mic: XBOG - country: Colombia - country_code: CO - city: Bogota - website: www.bvc.com.co - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: COP - symbol: CO$ - name: Colombian Peso -- name: Copenhagen Stock Exchange - acronym: OMXC - mic: XCSE - country: Denmark - country_code: DK - city: Copenhagen - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Copenhagen - abbr: CET - abbr_dst: CEST - currency: - code: DKK - symbol: Dkr - name: Danish Krone -- name: Eqyptian Exchange - acronym: EGX - mic: XCAI - country: Egypt - country_code: EG - city: Cairo - website: www.egyptse.com - timezone: - timezone: Africa/Cairo - abbr: EET - abbr_dst: EET - currency: - code: EGP - symbol: EGP - name: Egyptian Pound -- name: Tallinn Stock Exchange - acronym: OMXT - mic: XTAL - country: Estonia - country_code: EE - city: Tallinn - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Tallinn - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Helsinki Stock Exchange - acronym: OMXH - mic: XHEL - country: Finland - country_code: FI - city: Helsinki - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Helsinki - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Euronext Paris - acronym: Euronext - mic: XPAR - country: France - country_code: FR - city: Paris - website: www.euronext.com - timezone: - timezone: Europe/Paris - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Deutsche Börse - acronym: FSX - mic: XFRA - country: Germany - country_code: DE - city: Frankfurt - website: www.deutsche-boerse.com - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Börse Stuttgart - acronym: XSTU - mic: XSTU - country: Germany - country_code: DE - city: Stuttgart - website: www.boerse-stuttgart.de - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Deutsche Börse Xetra - acronym: XETR - mic: XETRA - country: Germany - country_code: DE - city: Frankfurt - website: - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Hong Kong Stock Exchange - acronym: HKEX - mic: XHKG - country: Hong Kong - country_code: HK - city: Hong Kong - website: www.hkex.com.hk - timezone: - timezone: Asia/Hong_Kong - abbr: HKT - abbr_dst: HKT - currency: - code: HKD - symbol: HK$ - name: Hong Kong Dollar -- name: Nasdaq Island - acronym: XICE - mic: XICE - country: Iceland - country_code: IS - city: Reykjavík - website: www.nasdaqomxnordic.com - timezone: - timezone: Atlantic/Reykjavik - abbr: GMT - abbr_dst: GMT - currency: - code: ISK - symbol: Ikr - name: Icelandic Króna -- name: Bombay Stock Exchange - acronym: MSE - mic: XBOM - country: India - country_code: IN - city: Mumbai - website: www.bseindia.com - timezone: - timezone: Asia/Kolkata - abbr: IST - abbr_dst: IST - currency: - code: INR - symbol: Rs - name: Indian Rupee -- name: National Stock Exchange India - acronym: NSE - mic: XNSE - country: India - country_code: IN - city: Mumbai - website: www.nseindia.com - timezone: - timezone: Asia/Kolkata - abbr: IST - abbr_dst: IST - currency: - code: INR - symbol: Rs - name: Indian Rupee -- name: Jakarta Stock Exchange - acronym: IDX - mic: XIDX - country: Indonesia - country_code: ID - city: Jakarta - website: www.idx.co.id - timezone: - timezone: Asia/Jakarta - abbr: WIB - abbr_dst: WIB - currency: - code: IDR - symbol: Rp - name: Indonesian Rupiah -- name: Tel Aviv Stock Exchange - acronym: TASE - mic: XTAE - country: Israel - country_code: IL - city: Tel Aviv - website: www.tase.co.il - timezone: - timezone: Asia/Jerusalem - abbr: IST - abbr_dst: IDT - currency: - code: ILS - symbol: ₪ - name: Israeli New Sheqel -- name: Borsa Italiana - acronym: MIL - mic: XMIL - country: Italy - country_code: IT - city: Milano - website: www.borsaitaliana.it - timezone: - timezone: Europe/Rome - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Nagoya Stock Exchange - acronym: NSE - mic: XNGO - country: Japan - country_code: JP - city: Nagoya - website: www.nse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Fukuoka Stock Exchange - acronym: XFKA - mic: XFKA - country: Japan - country_code: JP - city: Fukuoka - website: www.fse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Sapporo Stock Exchange - acronym: XSAP - mic: XSAP - country: Japan - country_code: JP - city: Sapporo - website: www.sse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Nasdaq Riga - acronym: OMXR - mic: XRIS - country: Latvia - country_code: LV - city: Riga - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Riga - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Nasdaq Vilnius - acronym: OMXV - mic: XLIT - country: Lithuania - country_code: LT - city: Vilnius - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Vilnius - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Malaysia Stock Exchange - acronym: MYX - mic: XKLS - country: Malaysia - country_code: MY - city: Kuala Lumpur - website: www.bursamalaysia.com - timezone: - timezone: Asia/Kuala_Lumpur - abbr: +08 - abbr_dst: +08 - currency: - code: MYR - symbol: RM - name: Malaysian Ringgit -- name: Mexican Stock Exchange - acronym: BMV - mic: XMEX - country: Mexico - country_code: MX - city: Mexico City - website: www.bmv.com.mx - timezone: - timezone: America/Mexico_City - abbr: CST - abbr_dst: CDT - currency: - code: MXN - symbol: MX$ - name: Mexican Peso -- name: Euronext Amsterdam - acronym: Euronext - mic: XAMS - country: Netherlands - country_code: NL - city: Amsterdam - website: www.euronext.com - timezone: - timezone: Europe/Amsterdam - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: New Zealand Stock Exchange - acronym: NZX - mic: XNZE - country: New Zealand - country_code: NZ - city: Wellington - website: www.nzx.com - timezone: - timezone: Pacific/Auckland - abbr: NZDT - abbr_dst: NZST - currency: - code: NZD - symbol: NZ$ - name: New Zealand Dollar -- name: Nigerian Stock Exchange - acronym: NSE - mic: XNSA - country: Nigeria - country_code: NG - city: Lagos - website: www.nse.com.ng - timezone: - timezone: Africa/Lagos - abbr: WAT - abbr_dst: WAT - currency: - code: NGN - symbol: ₦ - name: Nigerian Naira -- name: Oslo Stock Exchange - acronym: OSE - mic: XOSL - country: Norway - country_code: NO - city: Oslo - website: www.oslobors.no - timezone: - timezone: Europe/Oslo - abbr: CET - abbr_dst: CEST - currency: - code: NOK - symbol: Nkr - name: Norwegian Krone -- name: Bolsa de Valores de Lima - acronym: BVL - mic: XLIM - country: Peru - country_code: PE - city: Lima - website: www.bvl.com.pe - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: PEN - symbol: S/. - name: Peruvian Nuevo Sol -- name: Warsaw Stock Exchange - acronym: GPW - mic: XWAR - country: Poland - country_code: PL - city: Warsaw - website: www.gpw.pl - timezone: - timezone: Europe/Warsaw - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Euronext Lisbon - acronym: Euronext - mic: XLIS - country: Portugal - country_code: PT - city: Lisboa - website: www.euronext.com - timezone: - timezone: Europe/Lisbon - abbr: WET - abbr_dst: WEST - currency: - code: EUR - symbol: € - name: Euro -- name: Qatar Stock Exchange - acronym: QE - mic: DSMD - country: Qatar - country_code: QA - city: Doha - website: www.qatarexchange.qa - timezone: - timezone: Asia/Qatar - abbr: +03 - abbr_dst: +03 - currency: - code: QAR - symbol: QR - name: Qatari Rial -- name: Moscow Stock Exchange - acronym: MOEX - mic: MISX - country: Russia - country_code: RU - city: Moscow - website: www.moex.com - timezone: - timezone: Europe/Moscow - abbr: MSK - abbr_dst: MSK - currency: - code: RUB - symbol: RUB - name: Russian Ruble -- name: Saudi Stock Exchange - acronym: TADAWUL - mic: XSAU - country: Saudi Arabia - country_code: SA - city: Riyadh - website: www.tadawul.com.sa - timezone: - timezone: Asia/Riyadh - abbr: +03 - abbr_dst: +03 - currency: - code: SAR - symbol: SR - name: Saudi Riyal -- name: Belgrade Stock Exchange - acronym: BELEX - mic: XBEL - country: Serbia - country_code: RS - city: Belgrade - website: www.belex.rs - timezone: - timezone: Europe/Belgrade - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Singapore Stock Exchange - acronym: SGX - mic: XSES - country: Singapore - country_code: SG - city: Singapore - website: www.sgx.com - timezone: - timezone: Asia/Singapore - abbr: +08 - abbr_dst: +08 - currency: - code: SGD - symbol: S$ - name: Singapore Dollar -- name: Johannesburg Stock Exchange - acronym: JSE - mic: XJSE - country: South Africa - country_code: ZA - city: Johannesburg - website: www.jse.co.za - timezone: - timezone: Africa/Johannesburg - abbr: SAST - abbr_dst: SAST - currency: - code: ZAR - symbol: R - name: South African Rand -- name: Korean Stock Exchange - acronym: KRX - mic: XKRX - country: South Korea - country_code: KR - city: Seoul - website: http://eng.krx.co.kr - timezone: - timezone: Asia/Seoul - abbr: KST - abbr_dst: KST - currency: - code: KRW - symbol: ₩ - name: South Korean Won -- name: Bolsas y Mercados Españoles - acronym: BME - mic: BMEX - country: Spain - country_code: ES - city: Madrid - website: www.bolsasymercados.es - timezone: - timezone: Europe/Madrid - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Stockholm Stock Exchange - acronym: OMX - mic: XSTO - country: Sweden - country_code: SE - city: Stockholm - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Stockholm - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: SIX Swiss Exchange - acronym: SIX - mic: XSWX - country: Switzerland - country_code: CH - city: Zurich - website: www.six-swiss-exchange.com - timezone: - timezone: Europe/Zurich - abbr: CET - abbr_dst: CEST - currency: - code: CHF - symbol: CHF - name: Swiss Franc -- name: Taiwan Stock Exchange - acronym: TWSE - mic: XTAI - country: Taiwan - country_code: TW - city: Taipei - website: www.twse.com.tw/en/ - timezone: - timezone: Asia/Taipei - abbr: CST - abbr_dst: CST - currency: - code: TWD - symbol: NT$ - name: New Taiwan Dollar -- name: Stock Exchange of Thailand - acronym: SET - mic: XBKK - country: Thailand - country_code: TH - city: Bangkok - website: www.set.or.th - timezone: - timezone: Asia/Bangkok - abbr: +07 - abbr_dst: +07 - currency: - code: THB - symbol: ฿ - name: Thai Baht -- name: Istanbul Stock Exchange - acronym: BIST - mic: XIST - country: Turkey - country_code: TR - city: Istanbul - website: www.borsaistanbul.com - timezone: - timezone: Europe/Istanbul - abbr: +03 - abbr_dst: +03 - currency: - code: TRY - symbol: TL - name: Turkish Lira -- name: Dubai Financial Market - acronym: DFM - mic: XDFM - country: United Arab Emirates - country_code: AE - city: Dubai - website: www.dfm.co.ae - timezone: - timezone: Asia/Dubai - abbr: +04 - abbr_dst: +04 - currency: - code: AED - symbol: AED - name: United Arab Emirates Dirham -- name: London Stock Exchange - acronym: LSE - mic: XLON - country: United Kingdom - country_code: GB - city: London - website: www.londonstockexchange.com - timezone: - timezone: Europe/London - abbr: GMT - abbr_dst: BST - currency: - code: GBP - symbol: £ - name: British Pound Sterling -- name: Ho Chi Minh Stock Exchange - acronym: HOSE - mic: XSTC - country: Vietnam - country_code: VN - city: Ho Chi Minh City - website: www.hsx.vn - timezone: - timezone: Asia/Ho_Chi_Minh - abbr: +07 - abbr_dst: +07 - currency: - code: VND - symbol: ₫ - name: Vietnamese Dong -- name: American Stock Exchange - acronym: AMEX - mic: XASE - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Cboe BZX U.S. Equities Exchang - acronym: BATS - mic: XCBO - country: USA - country_code: US - city: Chicago - website: markets.cboe.com - timezone: - timezone: America/Chicago - abbr: CDT - abbr_dst: CDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: US Mutual Funds - acronym: NMFQS - mic: NMFQS - country: USA - country_code: US - city: New York - website: - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Bulletin Board - acronym: OTCBB - mic: OOTC - country: USA - country_code: US - city: Washington - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Grey Market - acronym: OTCGREY - mic: PSGM - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTCQB Marketplace - acronym: OTCQB - mic: OTCB - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTCQX Marketplace - acronym: OTCQX - mic: OTCQ - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC PINK current - acronym: PINK - mic: PINC - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Investors Exchange - acronym: IEX - mic: IEXG - country: USA - country_code: US - city: New York - website: www.iextrading.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar \ No newline at end of file diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 9040f864..70a6e476 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,11 +1,13 @@ require "sidekiq/web" -Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe")) +if Rails.env.production? + Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| + configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe")) + configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe")) - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) + end end Sidekiq::Cron.configure do |config| diff --git a/config/schedule.yml b/config/schedule.yml index 561e2327..8eb8ef0a 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -1,5 +1,8 @@ sync_market_data: - cron: "0 17 * * 1-5" # 5:00 PM EST (1 hour after market close) + cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) class: "SyncMarketDataJob" queue: "scheduled" description: "Syncs market data daily at 5:00 PM EST (1 hour after market close)" + args: + mode: "full" + clear_cache: false diff --git a/db/migrate/20250516180846_remove_stock_exchanges.rb b/db/migrate/20250516180846_remove_stock_exchanges.rb new file mode 100644 index 00000000..19c4a529 --- /dev/null +++ b/db/migrate/20250516180846_remove_stock_exchanges.rb @@ -0,0 +1,11 @@ +class RemoveStockExchanges < ActiveRecord::Migration[7.2] + def change + drop_table :stock_exchanges do |t| + t.string :name, null: false + t.string :acronym + t.string :mic, null: false + t.string :country, null: false + t.string :country_code, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f25cb20..5b9426c7 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: 2025_05_14_214242) do +ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -550,27 +550,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_14_214242) do t.index ["var"], name: "index_settings_on_var", unique: true end - create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "acronym" - t.string "mic", null: false - t.string "country", null: false - t.string "country_code", null: false - t.string "city" - t.string "website" - t.string "timezone_name" - t.string "timezone_abbr" - t.string "timezone_abbr_dst" - t.string "currency_code" - t.string "currency_symbol" - t.string "currency_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["country"], name: "index_stock_exchanges_on_country" - t.index ["country_code"], name: "index_stock_exchanges_on_country_code" - t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code" - end - create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "status", null: false diff --git a/test/fixtures/stock_exchanges.yml b/test/fixtures/stock_exchanges.yml deleted file mode 100644 index 872e5b27..00000000 --- a/test/fixtures/stock_exchanges.yml +++ /dev/null @@ -1,13 +0,0 @@ -nasdaq: - name: NASDAQ - mic: XNAS - acronym: NASDAQ - country: USA - country_code: US - -nyse: - name: New York Stock Exchange - mic: XNYS - acronym: NYSE - country: USA - country_code: US diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index 9293c4d9..3716f6fc 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -15,6 +15,7 @@ module ExchangeRateProviderInterfaceTest assert_equal "USD", rate.from assert_equal "GBP", rate.to + assert rate.date.is_a?(Date) assert_in_delta 0.78, rate.rate, 0.01 end end @@ -26,6 +27,7 @@ module ExchangeRateProviderInterfaceTest ) assert_equal 213, response.data.count # 213 days between 01.01.2024 and 31.07.2024 + assert response.data.first.date.is_a?(Date) end end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb index 44385ede..a994bb73 100644 --- a/test/interfaces/security_provider_interface_test.rb +++ b/test/interfaces/security_provider_interface_test.rb @@ -7,7 +7,7 @@ module SecurityProviderInterfaceTest aapl = securities(:aapl) VCR.use_cassette("#{vcr_key_prefix}/security_price") do - response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + response = @subject.fetch_security_price(symbol: aapl.ticker, exchange_operating_mic: aapl.exchange_operating_mic, date: Date.iso8601("2024-08-01")) assert response.success? assert response.data.present? @@ -19,12 +19,14 @@ module SecurityProviderInterfaceTest VCR.use_cassette("#{vcr_key_prefix}/security_prices") do response = @subject.fetch_security_prices( - aapl, + symbol: aapl.ticker, + exchange_operating_mic: aapl.exchange_operating_mic, start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01") ) assert response.success? + assert response.data.first.date.is_a?(Date) assert_equal 147, response.data.count # Synth won't return prices on weekends / holidays, so less than total day count of 213 end end @@ -44,7 +46,11 @@ module SecurityProviderInterfaceTest aapl = securities(:aapl) VCR.use_cassette("#{vcr_key_prefix}/security_info") do - response = @subject.fetch_security_info(aapl) + response = @subject.fetch_security_info( + symbol: aapl.ticker, + exchange_operating_mic: aapl.exchange_operating_mic + ) + info = response.data assert_equal "AAPL", info.symbol diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb deleted file mode 100644 index 8d77cae5..00000000 --- a/test/models/account/convertible_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -require "test_helper" -require "ostruct" - -class Account::ConvertibleTest < ActiveSupport::TestCase - include EntriesTestHelper, ProviderTestHelper - - setup do - @family = families(:empty) - @family.update!(currency: "USD") - - # Foreign account (currency is not in the family's primary currency, so it will require exchange rates for net worth rollups) - @account = @family.accounts.create!(name: "Test Account", currency: "EUR", balance: 10000, accountable: Depository.new) - - @provider = mock - ExchangeRate.stubs(:provider).returns(@provider) - end - - test "syncs required exchange rates for an account" do - create_valuation(account: @account, date: 1.day.ago.to_date, amount: 9500, currency: "EUR") - - # Since we had a valuation 1 day ago, this account starts 2 days ago and needs daily exchange rates looking forward - assert_equal 2.days.ago.to_date, @account.start_date - - ExchangeRate.delete_all - - provider_response = provider_success_response( - [ - OpenStruct.new(from: "EUR", to: "USD", date: 2.days.ago.to_date, rate: 1.1), - OpenStruct.new(from: "EUR", to: "USD", date: 1.day.ago.to_date, rate: 1.2), - OpenStruct.new(from: "EUR", to: "USD", date: Date.current, rate: 1.3) - ] - ) - - @provider.expects(:fetch_exchange_rates) - .with(from: "EUR", to: "USD", start_date: 2.days.ago.to_date, end_date: Date.current) - .returns(provider_response) - - assert_difference "ExchangeRate.count", 3 do - @account.sync_required_exchange_rates - end - end - - test "does not sync rates for a domestic account" do - @account.update!(currency: "USD") - - @provider.expects(:fetch_exchange_rates).never - - assert_no_difference "ExchangeRate.count" do - @account.sync_required_exchange_rates - end - end -end diff --git a/test/models/account/market_data_syncer_test.rb b/test/models/account/market_data_syncer_test.rb new file mode 100644 index 00000000..596798f5 --- /dev/null +++ b/test/models/account/market_data_syncer_test.rb @@ -0,0 +1,107 @@ +require "test_helper" +require "ostruct" + +class Account::MarketDataSyncerTest < ActiveSupport::TestCase + include ProviderTestHelper + + PROVIDER_BUFFER = 5.days + + setup do + # Ensure a clean slate for deterministic assertions + Security::Price.delete_all + ExchangeRate.delete_all + Trade.delete_all + Holding.delete_all + Security.delete_all + Entry.delete_all + + @provider = mock("provider") + Provider::Registry.any_instance + .stubs(:get_provider) + .with(:synth) + .returns(@provider) + end + + test "syncs required exchange rates for a foreign-currency account" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Chequing", + currency: "CAD", + balance: 100, + accountable: Depository.new + ) + + # Seed a rate for the first required day so that the syncer only needs the next day forward + existing_date = account.start_date + ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0) + + expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_exchange_rates) + .with(from: "CAD", + to: "USD", + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(from: "CAD", to: "USD", date: existing_date, rate: 1.5) + ])) + + before = ExchangeRate.count + Account::MarketDataSyncer.new(account).sync_market_data + after = ExchangeRate.count + + assert_operator after, :>, before, "Should insert at least one new exchange-rate row" + end + + test "syncs security prices for securities traded by the account" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Brokerage", + currency: "USD", + balance: 0, + accountable: Investment.new + ) + + security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + trade_date = 10.days.ago.to_date + trade = Trade.new(security: security, qty: 1, price: 100, currency: "USD") + + account.entries.create!( + name: "Buy AAPL", + date: trade_date, + amount: 100, + currency: "USD", + entryable: trade + ) + + expected_start_date = trade_date - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: trade_date, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic) + .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo"))) + + # Ignore exchange-rate calls for this test + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + Account::MarketDataSyncer.new(account).sync_market_data + + assert_equal 1, Security::Price.where(security: security, date: trade_date).count + end +end diff --git a/test/models/exchange_rate/syncer_test.rb b/test/models/exchange_rate/syncer_test.rb new file mode 100644 index 00000000..58818834 --- /dev/null +++ b/test/models/exchange_rate/syncer_test.rb @@ -0,0 +1,148 @@ +require "test_helper" +require "ostruct" + +class ExchangeRate::SyncerTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @provider = mock + end + + test "syncs missing rates from provider" do + ExchangeRate.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current + ).sync_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date..Date.current) + .order(:date) + + assert_equal 3, db_rates.count + assert_equal 1.3, db_rates[0].rate + assert_equal 1.4, db_rates[1].rate + assert_equal 1.5, db_rates[2].rate + end + + test "syncs diff when some rates already exist" do + ExchangeRate.delete_all + + # Pre-populate DB with the first two days + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 3.days.ago.to_date, rate: 1.2) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.25) + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.3) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_rates + + db_rates = ExchangeRate.order(:date) + assert_equal 4, db_rates.count + assert_equal [ 1.2, 1.25, 1.3, 1.3 ], db_rates.map(&:rate) + end + + test "no provider calls when all rates exist" do + ExchangeRate.delete_all + + (3.days.ago.to_date..Date.current).each_with_index do |date, idx| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date:, rate: 1.2 + idx * 0.01) + end + + @provider.expects(:fetch_exchange_rates).never + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_rates + end + + # A helpful "reset" option for when we need to refresh provider data + test "full upsert if clear_cache is true" do + ExchangeRate.delete_all + + # Seed DB with stale data + (2.days.ago.to_date..Date.current).each do |date| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date:, rate: 1.0) + end + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).sync_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date) + assert_equal [ 1.3, 1.4, 1.5 ], db_rates.map(&:rate) + end + + test "clamps end_date to today when future date is provided" do + ExchangeRate.delete_all + + future_date = Date.current + 3.days + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.6) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: Date.current, + end_date: future_date + ).sync_provider_rates + + assert_equal 1, ExchangeRate.count + end + + private + def get_provider_fetch_start_date(start_date) + # We fetch with a 5 day buffer to account for weekends and holidays + start_date - 5.days + end +end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 64fc328b..021b4edf 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -67,26 +67,4 @@ class ExchangeRateTest < ActiveSupport::TestCase assert_nil ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true) end - - test "upserts rates for currency pair and date range" do - ExchangeRate.delete_all - - ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9) - - provider_response = provider_success_response([ - OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.3), - OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), - OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.5) - ]) - - @provider.expects(:fetch_exchange_rates) - .with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current) - .returns(provider_response) - - ExchangeRate.sync_provider_rates(from: "USD", to: "EUR", start_date: 2.days.ago.to_date) - - assert_equal 1.3, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate - assert_equal 1.4, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date).rate - assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date).rate - end end diff --git a/test/models/market_data_syncer_test.rb b/test/models/market_data_syncer_test.rb index 299fb82e..8a9db1f5 100644 --- a/test/models/market_data_syncer_test.rb +++ b/test/models/market_data_syncer_test.rb @@ -2,70 +2,84 @@ require "test_helper" require "ostruct" class MarketDataSyncerTest < ActiveSupport::TestCase - include EntriesTestHelper, ProviderTestHelper + include ProviderTestHelper - test "syncs exchange rates with upsert" do - empty_db + SNAPSHOT_START_DATE = MarketDataSyncer::SNAPSHOT_DAYS.days.ago.to_date + PROVIDER_BUFFER = 5.days - family1 = Family.create!(name: "Family 1", currency: "USD") - account1 = family1.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Depository.new) - account2 = family1.accounts.create!(name: "Account 2", currency: "CAD", balance: 100, accountable: Depository.new) + setup do + Security::Price.delete_all + ExchangeRate.delete_all + Trade.delete_all + Holding.delete_all + Security.delete_all - family2 = Family.create!(name: "Family 2", currency: "EUR") - account3 = family2.accounts.create!(name: "Account 3", currency: "EUR", balance: 100, accountable: Depository.new) - account4 = family2.accounts.create!(name: "Account 4", currency: "USD", balance: 100, accountable: Depository.new) - - mock_provider = mock - Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once - - start_date = 1.month.ago.to_date - end_date = Date.current.in_time_zone("America/New_York").to_date - - # Put an existing rate in DB to test upsert - ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: start_date, rate: 2.0) - - mock_provider.expects(:fetch_exchange_rates) - .with(from: "CAD", to: "USD", start_date: start_date, end_date: end_date) - .returns(provider_success_response([ OpenStruct.new(from: "CAD", to: "USD", date: start_date, rate: 1.0) ])) - - mock_provider.expects(:fetch_exchange_rates) - .with(from: "USD", to: "EUR", start_date: start_date, end_date: end_date) - .returns(provider_success_response([ OpenStruct.new(from: "USD", to: "EUR", date: start_date, rate: 1.0) ])) - - assert_difference "ExchangeRate.count", 1 do - MarketDataSyncer.new.sync_exchange_rates - end - - assert_equal 1.0, ExchangeRate.where(from_currency: "CAD", to_currency: "USD", date: start_date).first.rate + @provider = mock("provider") + Provider::Registry.any_instance + .stubs(:get_provider) + .with(:synth) + .returns(@provider) end - test "syncs security prices with upsert" do - empty_db + test "syncs required exchange rates" do + family = Family.create!(name: "Smith", currency: "USD") + family.accounts.create!(name: "Chequing", + currency: "CAD", + balance: 100, + accountable: Depository.new) - aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + # Seed stale rate so only the next missing day is fetched + ExchangeRate.create!(from_currency: "CAD", + to_currency: "USD", + date: SNAPSHOT_START_DATE, + rate: 2.0) - family = Family.create!(name: "Family 1", currency: "USD") - account = family.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Investment.new) + expected_start_date = (SNAPSHOT_START_DATE + 1.day) - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date - mock_provider = mock - Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once + @provider.expects(:fetch_exchange_rates) + .with(from: "CAD", + to: "USD", + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(from: "CAD", to: "USD", date: SNAPSHOT_START_DATE, rate: 1.5) + ])) - start_date = 1.month.ago.to_date - end_date = Date.current.in_time_zone("America/New_York").to_date + before = ExchangeRate.count + MarketDataSyncer.new(mode: :snapshot).sync_exchange_rates + after = ExchangeRate.count - mock_provider.expects(:fetch_security_prices) - .with(aapl, start_date: start_date, end_date: end_date) - .returns(provider_success_response([ OpenStruct.new(security: aapl, date: start_date, price: 100, currency: "USD") ])) - - assert_difference "Security::Price.count", 1 do - MarketDataSyncer.new.sync_prices - end + assert_operator after, :>, before, "Should insert at least one new exchange-rate row" end - private - def empty_db - Invitation.destroy_all - Family.destroy_all - Security.destroy_all - end + test "syncs security prices" do + security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + expected_start_date = SNAPSHOT_START_DATE - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: SNAPSHOT_START_DATE, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: "AAPL", exchange_operating_mic: "XNAS") + .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo"))) + + # Ignore exchange rate calls for this test + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + MarketDataSyncer.new(mode: :snapshot).sync_prices + + assert_equal 1, Security::Price.where(security: security, date: SNAPSHOT_START_DATE).count + end end diff --git a/test/models/security/price/syncer_test.rb b/test/models/security/price/syncer_test.rb new file mode 100644 index 00000000..25a3f14c --- /dev/null +++ b/test/models/security/price/syncer_test.rb @@ -0,0 +1,143 @@ +require "test_helper" +require "ostruct" + +class Security::Price::SyncerTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @provider = mock + @security = Security.create!(ticker: "AAPL") + end + + test "syncs missing prices from provider" do + Security::Price.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: "USD"), + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD"), + OpenStruct.new(security: @security, date: Date.current, price: 160, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current + ).sync_provider_prices + + db_prices = Security::Price.where(security: @security, date: 2.days.ago.to_date..Date.current).order(:date) + + assert_equal 3, db_prices.count + assert_equal [ 150, 155, 160 ], db_prices.map(&:price) + end + + test "syncs diff when some prices already exist" do + Security::Price.delete_all + + # Pre-populate DB with first two days + Security::Price.create!(security: @security, date: 3.days.ago.to_date, price: 140, currency: "USD") + Security::Price.create!(security: @security, date: 2.days.ago.to_date, price: 145, currency: "USD") + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 150, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_prices + + db_prices = Security::Price.where(security: @security).order(:date) + assert_equal 4, db_prices.count + assert_equal [ 140, 145, 150, 150 ], db_prices.map(&:price) + end + + test "no provider calls when all prices exist" do + Security::Price.delete_all + + (3.days.ago.to_date..Date.current).each_with_index do |date, idx| + Security::Price.create!(security: @security, date:, price: 100 + idx, currency: "USD") + end + + @provider.expects(:fetch_security_prices).never + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_prices + end + + test "full upsert if clear_cache is true" do + Security::Price.delete_all + + # Seed DB with stale prices + (2.days.ago.to_date..Date.current).each do |date| + Security::Price.create!(security: @security, date:, price: 100, currency: "USD") + end + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: "USD"), + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD"), + OpenStruct.new(security: @security, date: Date.current, price: 160, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).sync_provider_prices + + db_prices = Security::Price.where(security: @security).order(:date) + assert_equal [ 150, 155, 160 ], db_prices.map(&:price) + end + + test "clamps end_date to today when future date is provided" do + Security::Price.delete_all + + future_date = Date.current + 3.days + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: Date.current, price: 165, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: Date.current, + end_date: future_date + ).sync_provider_prices + + assert_equal 1, Security::Price.count + end + + private + def get_provider_fetch_start_date(start_date) + start_date - 5.days + end +end diff --git a/test/vcr_cassettes/synth/exchange_rate.yml b/test/vcr_cassettes/synth/exchange_rate.yml index be1fa77a..adc22263 100644 --- a/test/vcr_cassettes/synth/exchange_rate.yml +++ b/test/vcr_cassettes/synth/exchange_rate.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:46 GMT + - Fri, 16 May 2025 13:01:38 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"b0b21c870fe53492404cc5ac258fa465" + - W/"0c93a67d0c68e6f206e2954a41aa2933" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 44367fcb-e5b4-457d + - 146e30b2-e03b-47e3 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 8ce9dc85-afbd-437c-b18d-ec788b712334 + - 3cf7ade1-8066-422a-97c7-5f8b99e24296 X-Runtime: - - '0.031963' + - '0.024284' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=SwRPS1vBsrKtk%2Ftb7Ix8j%2FCWYw9tZgbJxR1FCmotWn%2FIZAE3Ri%2FUwHtvkOSqBq6HN5pLVetfem5hp%2BkqWmD5GRCVho0mp3VgRr3J1tBMwrVK2p50tfpmb3X22Jj%2BOfapq1C22PnN"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ih8sEFqAOWyINqAEtKGKPKO2lr1qAYSVeipyB5F8g2umPODXvCD4hN3G6wTTs2Q7H8CDWsqiOlYkmVvmr%2BWvl2ojOtBwO25Ahk9TbhlcgRO9nT6mEIXOSdVXJpzpRn5Ov%2FMGigpQ"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,13 +69,13 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f6378fe582237-ORD + - 940b109b5df1a3d7-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26670&min_rtt=26569&rtt_var=10167&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=922&delivery_rate=105759&cwnd=181&unsent_bytes=0&cid=f0a872e0b2909c59&ts=188&x=0" + - cfL4;desc="?proto=TCP&rtt=25865&min_rtt=25683&rtt_var=9996&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=922&delivery_rate=106690&cwnd=219&unsent_bytes=0&cid=e48ae188d1f86721&ts=190&x=0" body: encoding: ASCII-8BIT - string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249830,"date":"2024-01-01"}}' - recorded_at: Sat, 15 Mar 2025 22:18:46 GMT + string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249734,"date":"2024-01-01"}}' + recorded_at: Fri, 16 May 2025 13:01:38 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/exchange_rates.yml b/test/vcr_cassettes/synth/exchange_rates.yml index ffb7b69a..87071b8f 100644 --- a/test/vcr_cassettes/synth/exchange_rates.yml +++ b/test/vcr_cassettes/synth/exchange_rates.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 21:48:33 GMT + - Fri, 16 May 2025 13:01:35 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"8081859271e9ca46ee021f706a0cc683" + - W/"ad21b1fba71fe0b149fe37b483a60438" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 6d036078-7f2f-4037 + - 28bc6622-47b8-4aeb Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 9ec8d111-aa67-4fb9-8885-7de64e1b1219 + - fcf251a3-f850-4464-9592-ced9de5e0c86 X-Runtime: - - '0.025769' + - '0.080857' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=3PGbjN13Yz7GFiZNw1N13jCnLyMkC1O69nVw4k9Y0Iif7pu0H1eBKZxhkRTGzeECSRtzryqMRpzh9lG11e9SVXA9PNTSTR1%2BC%2FZkOMTsFUk%2Fajh29RmkkGeYrQgCAPEWBST36B3V"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=622lysAXubNaj3TsuhR9RYZRXPc%2BgnyMWj52fxy%2BptvXoPr%2FxVJgJZ0g02mOUjCywdAymkMpawfWCaZVQOIaPVpocco3g4Y%2B0FB667ilf3UtCyiHwqCosUq0T99JabIsgFFJ%2FhP4"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,13 +69,13 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f37347b14e7f9-ORD + - 940b108a2921607d-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=27528&min_rtt=26760&rtt_var=11571&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=961&delivery_rate=88005&cwnd=248&unsent_bytes=0&cid=28a3fac05fc0df52&ts=177&x=0" + - cfL4;desc="?proto=TCP&rtt=25729&min_rtt=25575&rtt_var=9899&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=961&delivery_rate=108019&cwnd=251&unsent_bytes=0&cid=ca574e4a637aba29&ts=241&x=0" body: encoding: ASCII-8BIT - string: '{"data":[{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},{"date":"2024-01-02","source":"USD","rates":{"GBP":0.785644}},{"date":"2024-01-03","source":"USD","rates":{"GBP":0.792232}},{"date":"2024-01-04","source":"USD","rates":{"GBP":0.789053}},{"date":"2024-01-05","source":"USD","rates":{"GBP":0.788487}},{"date":"2024-01-06","source":"USD","rates":{"GBP":0.785787}},{"date":"2024-01-07","source":"USD","rates":{"GBP":0.785994}},{"date":"2024-01-08","source":"USD","rates":{"GBP":0.786378}},{"date":"2024-01-09","source":"USD","rates":{"GBP":0.784775}},{"date":"2024-01-10","source":"USD","rates":{"GBP":0.786769}},{"date":"2024-01-11","source":"USD","rates":{"GBP":0.784633}},{"date":"2024-01-12","source":"USD","rates":{"GBP":0.782576}},{"date":"2024-01-13","source":"USD","rates":{"GBP":0.78447}},{"date":"2024-01-14","source":"USD","rates":{"GBP":0.784423}},{"date":"2024-01-15","source":"USD","rates":{"GBP":0.785204}},{"date":"2024-01-16","source":"USD","rates":{"GBP":0.786438}},{"date":"2024-01-17","source":"USD","rates":{"GBP":0.791264}},{"date":"2024-01-18","source":"USD","rates":{"GBP":0.788852}},{"date":"2024-01-19","source":"USD","rates":{"GBP":0.786744}},{"date":"2024-01-20","source":"USD","rates":{"GBP":0.787186}},{"date":"2024-01-21","source":"USD","rates":{"GBP":0.787166}},{"date":"2024-01-22","source":"USD","rates":{"GBP":0.787487}},{"date":"2024-01-23","source":"USD","rates":{"GBP":0.786985}},{"date":"2024-01-24","source":"USD","rates":{"GBP":0.787961}},{"date":"2024-01-25","source":"USD","rates":{"GBP":0.786236}},{"date":"2024-01-26","source":"USD","rates":{"GBP":0.786961}},{"date":"2024-01-27","source":"USD","rates":{"GBP":0.786935}},{"date":"2024-01-28","source":"USD","rates":{"GBP":0.787014}},{"date":"2024-01-29","source":"USD","rates":{"GBP":0.78761}},{"date":"2024-01-30","source":"USD","rates":{"GBP":0.786652}},{"date":"2024-01-31","source":"USD","rates":{"GBP":0.787736}},{"date":"2024-02-01","source":"USD","rates":{"GBP":0.788759}},{"date":"2024-02-02","source":"USD","rates":{"GBP":0.784546}},{"date":"2024-02-03","source":"USD","rates":{"GBP":0.791634}},{"date":"2024-02-04","source":"USD","rates":{"GBP":0.791637}},{"date":"2024-02-05","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-02-06","source":"USD","rates":{"GBP":0.797836}},{"date":"2024-02-07","source":"USD","rates":{"GBP":0.79341}},{"date":"2024-02-08","source":"USD","rates":{"GBP":0.791971}},{"date":"2024-02-09","source":"USD","rates":{"GBP":0.792371}},{"date":"2024-02-10","source":"USD","rates":{"GBP":0.791997}},{"date":"2024-02-11","source":"USD","rates":{"GBP":0.792019}},{"date":"2024-02-12","source":"USD","rates":{"GBP":0.791339}},{"date":"2024-02-13","source":"USD","rates":{"GBP":0.791977}},{"date":"2024-02-14","source":"USD","rates":{"GBP":0.794262}},{"date":"2024-02-15","source":"USD","rates":{"GBP":0.795709}},{"date":"2024-02-16","source":"USD","rates":{"GBP":0.793714}},{"date":"2024-02-17","source":"USD","rates":{"GBP":0.793499}},{"date":"2024-02-18","source":"USD","rates":{"GBP":0.79367}},{"date":"2024-02-19","source":"USD","rates":{"GBP":0.792968}},{"date":"2024-02-20","source":"USD","rates":{"GBP":0.794437}},{"date":"2024-02-21","source":"USD","rates":{"GBP":0.791988}},{"date":"2024-02-22","source":"USD","rates":{"GBP":0.791262}},{"date":"2024-02-23","source":"USD","rates":{"GBP":0.789749}},{"date":"2024-02-24","source":"USD","rates":{"GBP":0.78886}},{"date":"2024-02-25","source":"USD","rates":{"GBP":0.789107}},{"date":"2024-02-26","source":"USD","rates":{"GBP":0.78917}},{"date":"2024-02-27","source":"USD","rates":{"GBP":0.788381}},{"date":"2024-02-28","source":"USD","rates":{"GBP":0.78861}},{"date":"2024-02-29","source":"USD","rates":{"GBP":0.789837}},{"date":"2024-03-01","source":"USD","rates":{"GBP":0.792028}},{"date":"2024-03-02","source":"USD","rates":{"GBP":0.790312}},{"date":"2024-03-03","source":"USD","rates":{"GBP":0.790258}},{"date":"2024-03-04","source":"USD","rates":{"GBP":0.789891}},{"date":"2024-03-05","source":"USD","rates":{"GBP":0.788025}},{"date":"2024-03-06","source":"USD","rates":{"GBP":0.787136}},{"date":"2024-03-07","source":"USD","rates":{"GBP":0.785219}},{"date":"2024-03-08","source":"USD","rates":{"GBP":0.780438}},{"date":"2024-03-09","source":"USD","rates":{"GBP":0.777772}},{"date":"2024-03-10","source":"USD","rates":{"GBP":0.777884}},{"date":"2024-03-11","source":"USD","rates":{"GBP":0.77786}},{"date":"2024-03-12","source":"USD","rates":{"GBP":0.780067}},{"date":"2024-03-13","source":"USD","rates":{"GBP":0.781535}},{"date":"2024-03-14","source":"USD","rates":{"GBP":0.781184}},{"date":"2024-03-15","source":"USD","rates":{"GBP":0.784604}},{"date":"2024-03-16","source":"USD","rates":{"GBP":0.785537}},{"date":"2024-03-17","source":"USD","rates":{"GBP":0.785147}},{"date":"2024-03-18","source":"USD","rates":{"GBP":0.785457}},{"date":"2024-03-19","source":"USD","rates":{"GBP":0.785746}},{"date":"2024-03-20","source":"USD","rates":{"GBP":0.786238}},{"date":"2024-03-21","source":"USD","rates":{"GBP":0.781351}},{"date":"2024-03-22","source":"USD","rates":{"GBP":0.789841}},{"date":"2024-03-23","source":"USD","rates":{"GBP":0.793659}},{"date":"2024-03-24","source":"USD","rates":{"GBP":0.793385}},{"date":"2024-03-25","source":"USD","rates":{"GBP":0.793673}},{"date":"2024-03-26","source":"USD","rates":{"GBP":0.791344}},{"date":"2024-03-27","source":"USD","rates":{"GBP":0.791899}},{"date":"2024-03-28","source":"USD","rates":{"GBP":0.792585}},{"date":"2024-03-29","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-03-30","source":"USD","rates":{"GBP":0.792228}},{"date":"2024-03-31","source":"USD","rates":{"GBP":0.792057}},{"date":"2024-04-01","source":"USD","rates":{"GBP":0.79134}},{"date":"2024-04-02","source":"USD","rates":{"GBP":0.797058}},{"date":"2024-04-03","source":"USD","rates":{"GBP":0.795147}},{"date":"2024-04-04","source":"USD","rates":{"GBP":0.790398}},{"date":"2024-04-05","source":"USD","rates":{"GBP":0.791151}},{"date":"2024-04-06","source":"USD","rates":{"GBP":0.791314}},{"date":"2024-04-07","source":"USD","rates":{"GBP":0.791273}},{"date":"2024-04-08","source":"USD","rates":{"GBP":0.792111}},{"date":"2024-04-09","source":"USD","rates":{"GBP":0.790047}},{"date":"2024-04-10","source":"USD","rates":{"GBP":0.788828}},{"date":"2024-04-11","source":"USD","rates":{"GBP":0.797646}},{"date":"2024-04-12","source":"USD","rates":{"GBP":0.796524}},{"date":"2024-04-13","source":"USD","rates":{"GBP":0.803024}},{"date":"2024-04-14","source":"USD","rates":{"GBP":0.802912}},{"date":"2024-04-15","source":"USD","rates":{"GBP":0.8025}},{"date":"2024-04-16","source":"USD","rates":{"GBP":0.80344}},{"date":"2024-04-17","source":"USD","rates":{"GBP":0.804505}},{"date":"2024-04-18","source":"USD","rates":{"GBP":0.80301}},{"date":"2024-04-19","source":"USD","rates":{"GBP":0.804145}},{"date":"2024-04-20","source":"USD","rates":{"GBP":0.80845}},{"date":"2024-04-21","source":"USD","rates":{"GBP":0.808199}},{"date":"2024-04-22","source":"USD","rates":{"GBP":0.808004}},{"date":"2024-04-23","source":"USD","rates":{"GBP":0.809734}},{"date":"2024-04-24","source":"USD","rates":{"GBP":0.802955}},{"date":"2024-04-25","source":"USD","rates":{"GBP":0.80264}},{"date":"2024-04-26","source":"USD","rates":{"GBP":0.799526}},{"date":"2024-04-27","source":"USD","rates":{"GBP":0.80053}},{"date":"2024-04-28","source":"USD","rates":{"GBP":0.800761}},{"date":"2024-04-29","source":"USD","rates":{"GBP":0.799397}},{"date":"2024-04-30","source":"USD","rates":{"GBP":0.796217}},{"date":"2024-05-01","source":"USD","rates":{"GBP":0.800703}},{"date":"2024-05-02","source":"USD","rates":{"GBP":0.797562}},{"date":"2024-05-03","source":"USD","rates":{"GBP":0.797457}},{"date":"2024-05-04","source":"USD","rates":{"GBP":0.797001}},{"date":"2024-05-05","source":"USD","rates":{"GBP":0.797107}},{"date":"2024-05-06","source":"USD","rates":{"GBP":0.797363}},{"date":"2024-05-07","source":"USD","rates":{"GBP":0.796218}},{"date":"2024-05-08","source":"USD","rates":{"GBP":0.799915}},{"date":"2024-05-09","source":"USD","rates":{"GBP":0.800422}},{"date":"2024-05-10","source":"USD","rates":{"GBP":0.798411}},{"date":"2024-05-11","source":"USD","rates":{"GBP":0.798489}},{"date":"2024-05-12","source":"USD","rates":{"GBP":0.798475}},{"date":"2024-05-13","source":"USD","rates":{"GBP":0.79853}},{"date":"2024-05-14","source":"USD","rates":{"GBP":0.796122}},{"date":"2024-05-15","source":"USD","rates":{"GBP":0.794614}},{"date":"2024-05-16","source":"USD","rates":{"GBP":0.78804}},{"date":"2024-05-17","source":"USD","rates":{"GBP":0.789188}},{"date":"2024-05-18","source":"USD","rates":{"GBP":0.787162}},{"date":"2024-05-19","source":"USD","rates":{"GBP":0.787194}},{"date":"2024-05-20","source":"USD","rates":{"GBP":0.787022}},{"date":"2024-05-21","source":"USD","rates":{"GBP":0.786793}},{"date":"2024-05-22","source":"USD","rates":{"GBP":0.786723}},{"date":"2024-05-23","source":"USD","rates":{"GBP":0.786132}},{"date":"2024-05-24","source":"USD","rates":{"GBP":0.78778}},{"date":"2024-05-25","source":"USD","rates":{"GBP":0.785013}},{"date":"2024-05-26","source":"USD","rates":{"GBP":0.785081}},{"date":"2024-05-27","source":"USD","rates":{"GBP":0.78526}},{"date":"2024-05-28","source":"USD","rates":{"GBP":0.78296}},{"date":"2024-05-29","source":"USD","rates":{"GBP":0.783808}},{"date":"2024-05-30","source":"USD","rates":{"GBP":0.787552}},{"date":"2024-05-31","source":"USD","rates":{"GBP":0.785599}},{"date":"2024-06-01","source":"USD","rates":{"GBP":0.785113}},{"date":"2024-06-02","source":"USD","rates":{"GBP":0.785019}},{"date":"2024-06-03","source":"USD","rates":{"GBP":0.784657}},{"date":"2024-06-04","source":"USD","rates":{"GBP":0.780649}},{"date":"2024-06-05","source":"USD","rates":{"GBP":0.782934}},{"date":"2024-06-06","source":"USD","rates":{"GBP":0.781631}},{"date":"2024-06-07","source":"USD","rates":{"GBP":0.781732}},{"date":"2024-06-08","source":"USD","rates":{"GBP":0.785947}},{"date":"2024-06-09","source":"USD","rates":{"GBP":0.785767}},{"date":"2024-06-10","source":"USD","rates":{"GBP":0.785588}},{"date":"2024-06-11","source":"USD","rates":{"GBP":0.785791}},{"date":"2024-06-12","source":"USD","rates":{"GBP":0.784932}},{"date":"2024-06-13","source":"USD","rates":{"GBP":0.781472}},{"date":"2024-06-14","source":"USD","rates":{"GBP":0.784041}},{"date":"2024-06-15","source":"USD","rates":{"GBP":0.789096}},{"date":"2024-06-16","source":"USD","rates":{"GBP":0.788449}},{"date":"2024-06-17","source":"USD","rates":{"GBP":0.788479}},{"date":"2024-06-18","source":"USD","rates":{"GBP":0.786542}},{"date":"2024-06-19","source":"USD","rates":{"GBP":0.786916}},{"date":"2024-06-20","source":"USD","rates":{"GBP":0.786107}},{"date":"2024-06-21","source":"USD","rates":{"GBP":0.789875}},{"date":"2024-06-22","source":"USD","rates":{"GBP":0.79058}},{"date":"2024-06-23","source":"USD","rates":{"GBP":0.790546}},{"date":"2024-06-24","source":"USD","rates":{"GBP":0.791248}},{"date":"2024-06-25","source":"USD","rates":{"GBP":0.788496}},{"date":"2024-06-26","source":"USD","rates":{"GBP":0.788395}},{"date":"2024-06-27","source":"USD","rates":{"GBP":0.792298}},{"date":"2024-06-28","source":"USD","rates":{"GBP":0.79087}},{"date":"2024-06-29","source":"USD","rates":{"GBP":0.790726}},{"date":"2024-06-30","source":"USD","rates":{"GBP":0.790719}},{"date":"2024-07-01","source":"USD","rates":{"GBP":0.790622}},{"date":"2024-07-02","source":"USD","rates":{"GBP":0.790812}},{"date":"2024-07-03","source":"USD","rates":{"GBP":0.78816}},{"date":"2024-07-04","source":"USD","rates":{"GBP":0.784451}},{"date":"2024-07-05","source":"USD","rates":{"GBP":0.783992}},{"date":"2024-07-06","source":"USD","rates":{"GBP":0.780243}},{"date":"2024-07-07","source":"USD","rates":{"GBP":0.780594}},{"date":"2024-07-08","source":"USD","rates":{"GBP":0.780827}},{"date":"2024-07-09","source":"USD","rates":{"GBP":0.780333}},{"date":"2024-07-10","source":"USD","rates":{"GBP":0.781936}},{"date":"2024-07-11","source":"USD","rates":{"GBP":0.777992}},{"date":"2024-07-12","source":"USD","rates":{"GBP":0.773816}},{"date":"2024-07-13","source":"USD","rates":{"GBP":0.770374}},{"date":"2024-07-14","source":"USD","rates":{"GBP":0.770294}},{"date":"2024-07-15","source":"USD","rates":{"GBP":0.771174}},{"date":"2024-07-16","source":"USD","rates":{"GBP":0.771041}},{"date":"2024-07-17","source":"USD","rates":{"GBP":0.770574}},{"date":"2024-07-18","source":"USD","rates":{"GBP":0.768775}},{"date":"2024-07-19","source":"USD","rates":{"GBP":0.772195}},{"date":"2024-07-20","source":"USD","rates":{"GBP":0.774311}},{"date":"2024-07-21","source":"USD","rates":{"GBP":0.774096}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773251}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.773304}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775165}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775289}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777882}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777203}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.776969}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.777176}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.777613}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778999}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","total_records":213,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249832,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Sat, 15 Mar 2025 21:48:33 GMT + string: '{"data":[{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},{"date":"2024-01-02","source":"USD","rates":{"GBP":0.785644}},{"date":"2024-01-03","source":"USD","rates":{"GBP":0.792232}},{"date":"2024-01-04","source":"USD","rates":{"GBP":0.789053}},{"date":"2024-01-05","source":"USD","rates":{"GBP":0.788487}},{"date":"2024-01-06","source":"USD","rates":{"GBP":0.785787}},{"date":"2024-01-07","source":"USD","rates":{"GBP":0.785994}},{"date":"2024-01-08","source":"USD","rates":{"GBP":0.786378}},{"date":"2024-01-09","source":"USD","rates":{"GBP":0.784775}},{"date":"2024-01-10","source":"USD","rates":{"GBP":0.786769}},{"date":"2024-01-11","source":"USD","rates":{"GBP":0.784633}},{"date":"2024-01-12","source":"USD","rates":{"GBP":0.782576}},{"date":"2024-01-13","source":"USD","rates":{"GBP":0.78447}},{"date":"2024-01-14","source":"USD","rates":{"GBP":0.784423}},{"date":"2024-01-15","source":"USD","rates":{"GBP":0.785204}},{"date":"2024-01-16","source":"USD","rates":{"GBP":0.786438}},{"date":"2024-01-17","source":"USD","rates":{"GBP":0.791264}},{"date":"2024-01-18","source":"USD","rates":{"GBP":0.788852}},{"date":"2024-01-19","source":"USD","rates":{"GBP":0.786744}},{"date":"2024-01-20","source":"USD","rates":{"GBP":0.787186}},{"date":"2024-01-21","source":"USD","rates":{"GBP":0.787166}},{"date":"2024-01-22","source":"USD","rates":{"GBP":0.787487}},{"date":"2024-01-23","source":"USD","rates":{"GBP":0.786985}},{"date":"2024-01-24","source":"USD","rates":{"GBP":0.787961}},{"date":"2024-01-25","source":"USD","rates":{"GBP":0.786236}},{"date":"2024-01-26","source":"USD","rates":{"GBP":0.786961}},{"date":"2024-01-27","source":"USD","rates":{"GBP":0.786935}},{"date":"2024-01-28","source":"USD","rates":{"GBP":0.787014}},{"date":"2024-01-29","source":"USD","rates":{"GBP":0.78761}},{"date":"2024-01-30","source":"USD","rates":{"GBP":0.786652}},{"date":"2024-01-31","source":"USD","rates":{"GBP":0.787736}},{"date":"2024-02-01","source":"USD","rates":{"GBP":0.788759}},{"date":"2024-02-02","source":"USD","rates":{"GBP":0.784546}},{"date":"2024-02-03","source":"USD","rates":{"GBP":0.791634}},{"date":"2024-02-04","source":"USD","rates":{"GBP":0.791637}},{"date":"2024-02-05","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-02-06","source":"USD","rates":{"GBP":0.797836}},{"date":"2024-02-07","source":"USD","rates":{"GBP":0.79341}},{"date":"2024-02-08","source":"USD","rates":{"GBP":0.791971}},{"date":"2024-02-09","source":"USD","rates":{"GBP":0.792371}},{"date":"2024-02-10","source":"USD","rates":{"GBP":0.791997}},{"date":"2024-02-11","source":"USD","rates":{"GBP":0.792019}},{"date":"2024-02-12","source":"USD","rates":{"GBP":0.791339}},{"date":"2024-02-13","source":"USD","rates":{"GBP":0.791977}},{"date":"2024-02-14","source":"USD","rates":{"GBP":0.794262}},{"date":"2024-02-15","source":"USD","rates":{"GBP":0.795709}},{"date":"2024-02-16","source":"USD","rates":{"GBP":0.793714}},{"date":"2024-02-17","source":"USD","rates":{"GBP":0.793499}},{"date":"2024-02-18","source":"USD","rates":{"GBP":0.79367}},{"date":"2024-02-19","source":"USD","rates":{"GBP":0.792968}},{"date":"2024-02-20","source":"USD","rates":{"GBP":0.794437}},{"date":"2024-02-21","source":"USD","rates":{"GBP":0.791988}},{"date":"2024-02-22","source":"USD","rates":{"GBP":0.791262}},{"date":"2024-02-23","source":"USD","rates":{"GBP":0.789749}},{"date":"2024-02-24","source":"USD","rates":{"GBP":0.78886}},{"date":"2024-02-25","source":"USD","rates":{"GBP":0.789107}},{"date":"2024-02-26","source":"USD","rates":{"GBP":0.78917}},{"date":"2024-02-27","source":"USD","rates":{"GBP":0.788381}},{"date":"2024-02-28","source":"USD","rates":{"GBP":0.78861}},{"date":"2024-02-29","source":"USD","rates":{"GBP":0.789837}},{"date":"2024-03-01","source":"USD","rates":{"GBP":0.792028}},{"date":"2024-03-02","source":"USD","rates":{"GBP":0.790312}},{"date":"2024-03-03","source":"USD","rates":{"GBP":0.790258}},{"date":"2024-03-04","source":"USD","rates":{"GBP":0.789891}},{"date":"2024-03-05","source":"USD","rates":{"GBP":0.788025}},{"date":"2024-03-06","source":"USD","rates":{"GBP":0.787136}},{"date":"2024-03-07","source":"USD","rates":{"GBP":0.785219}},{"date":"2024-03-08","source":"USD","rates":{"GBP":0.780438}},{"date":"2024-03-09","source":"USD","rates":{"GBP":0.777772}},{"date":"2024-03-10","source":"USD","rates":{"GBP":0.777884}},{"date":"2024-03-11","source":"USD","rates":{"GBP":0.77786}},{"date":"2024-03-12","source":"USD","rates":{"GBP":0.780067}},{"date":"2024-03-13","source":"USD","rates":{"GBP":0.781535}},{"date":"2024-03-14","source":"USD","rates":{"GBP":0.781184}},{"date":"2024-03-15","source":"USD","rates":{"GBP":0.784604}},{"date":"2024-03-16","source":"USD","rates":{"GBP":0.785537}},{"date":"2024-03-17","source":"USD","rates":{"GBP":0.785147}},{"date":"2024-03-18","source":"USD","rates":{"GBP":0.785457}},{"date":"2024-03-19","source":"USD","rates":{"GBP":0.785746}},{"date":"2024-03-20","source":"USD","rates":{"GBP":0.786238}},{"date":"2024-03-21","source":"USD","rates":{"GBP":0.781351}},{"date":"2024-03-22","source":"USD","rates":{"GBP":0.789841}},{"date":"2024-03-23","source":"USD","rates":{"GBP":0.793659}},{"date":"2024-03-24","source":"USD","rates":{"GBP":0.793385}},{"date":"2024-03-25","source":"USD","rates":{"GBP":0.793673}},{"date":"2024-03-26","source":"USD","rates":{"GBP":0.791344}},{"date":"2024-03-27","source":"USD","rates":{"GBP":0.791899}},{"date":"2024-03-28","source":"USD","rates":{"GBP":0.792585}},{"date":"2024-03-29","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-03-30","source":"USD","rates":{"GBP":0.792228}},{"date":"2024-03-31","source":"USD","rates":{"GBP":0.792057}},{"date":"2024-04-01","source":"USD","rates":{"GBP":0.79134}},{"date":"2024-04-02","source":"USD","rates":{"GBP":0.797058}},{"date":"2024-04-03","source":"USD","rates":{"GBP":0.795147}},{"date":"2024-04-04","source":"USD","rates":{"GBP":0.790398}},{"date":"2024-04-05","source":"USD","rates":{"GBP":0.791151}},{"date":"2024-04-06","source":"USD","rates":{"GBP":0.791314}},{"date":"2024-04-07","source":"USD","rates":{"GBP":0.791273}},{"date":"2024-04-08","source":"USD","rates":{"GBP":0.792111}},{"date":"2024-04-09","source":"USD","rates":{"GBP":0.790047}},{"date":"2024-04-10","source":"USD","rates":{"GBP":0.788828}},{"date":"2024-04-11","source":"USD","rates":{"GBP":0.797646}},{"date":"2024-04-12","source":"USD","rates":{"GBP":0.796524}},{"date":"2024-04-13","source":"USD","rates":{"GBP":0.803024}},{"date":"2024-04-14","source":"USD","rates":{"GBP":0.802912}},{"date":"2024-04-15","source":"USD","rates":{"GBP":0.8025}},{"date":"2024-04-16","source":"USD","rates":{"GBP":0.80344}},{"date":"2024-04-17","source":"USD","rates":{"GBP":0.804505}},{"date":"2024-04-18","source":"USD","rates":{"GBP":0.80301}},{"date":"2024-04-19","source":"USD","rates":{"GBP":0.804145}},{"date":"2024-04-20","source":"USD","rates":{"GBP":0.80845}},{"date":"2024-04-21","source":"USD","rates":{"GBP":0.808199}},{"date":"2024-04-22","source":"USD","rates":{"GBP":0.808004}},{"date":"2024-04-23","source":"USD","rates":{"GBP":0.809734}},{"date":"2024-04-24","source":"USD","rates":{"GBP":0.802955}},{"date":"2024-04-25","source":"USD","rates":{"GBP":0.80264}},{"date":"2024-04-26","source":"USD","rates":{"GBP":0.799526}},{"date":"2024-04-27","source":"USD","rates":{"GBP":0.80053}},{"date":"2024-04-28","source":"USD","rates":{"GBP":0.800761}},{"date":"2024-04-29","source":"USD","rates":{"GBP":0.799397}},{"date":"2024-04-30","source":"USD","rates":{"GBP":0.796217}},{"date":"2024-05-01","source":"USD","rates":{"GBP":0.800703}},{"date":"2024-05-02","source":"USD","rates":{"GBP":0.797562}},{"date":"2024-05-03","source":"USD","rates":{"GBP":0.797457}},{"date":"2024-05-04","source":"USD","rates":{"GBP":0.797001}},{"date":"2024-05-05","source":"USD","rates":{"GBP":0.797107}},{"date":"2024-05-06","source":"USD","rates":{"GBP":0.797363}},{"date":"2024-05-07","source":"USD","rates":{"GBP":0.796218}},{"date":"2024-05-08","source":"USD","rates":{"GBP":0.799915}},{"date":"2024-05-09","source":"USD","rates":{"GBP":0.800422}},{"date":"2024-05-10","source":"USD","rates":{"GBP":0.798411}},{"date":"2024-05-11","source":"USD","rates":{"GBP":0.798489}},{"date":"2024-05-12","source":"USD","rates":{"GBP":0.798475}},{"date":"2024-05-13","source":"USD","rates":{"GBP":0.79853}},{"date":"2024-05-14","source":"USD","rates":{"GBP":0.796122}},{"date":"2024-05-15","source":"USD","rates":{"GBP":0.794614}},{"date":"2024-05-16","source":"USD","rates":{"GBP":0.78804}},{"date":"2024-05-17","source":"USD","rates":{"GBP":0.789188}},{"date":"2024-05-18","source":"USD","rates":{"GBP":0.787162}},{"date":"2024-05-19","source":"USD","rates":{"GBP":0.787194}},{"date":"2024-05-20","source":"USD","rates":{"GBP":0.787022}},{"date":"2024-05-21","source":"USD","rates":{"GBP":0.786793}},{"date":"2024-05-22","source":"USD","rates":{"GBP":0.786723}},{"date":"2024-05-23","source":"USD","rates":{"GBP":0.786132}},{"date":"2024-05-24","source":"USD","rates":{"GBP":0.78778}},{"date":"2024-05-25","source":"USD","rates":{"GBP":0.785013}},{"date":"2024-05-26","source":"USD","rates":{"GBP":0.785081}},{"date":"2024-05-27","source":"USD","rates":{"GBP":0.78526}},{"date":"2024-05-28","source":"USD","rates":{"GBP":0.78296}},{"date":"2024-05-29","source":"USD","rates":{"GBP":0.783808}},{"date":"2024-05-30","source":"USD","rates":{"GBP":0.787552}},{"date":"2024-05-31","source":"USD","rates":{"GBP":0.785599}},{"date":"2024-06-01","source":"USD","rates":{"GBP":0.785113}},{"date":"2024-06-02","source":"USD","rates":{"GBP":0.785019}},{"date":"2024-06-03","source":"USD","rates":{"GBP":0.784657}},{"date":"2024-06-04","source":"USD","rates":{"GBP":0.780649}},{"date":"2024-06-05","source":"USD","rates":{"GBP":0.782934}},{"date":"2024-06-06","source":"USD","rates":{"GBP":0.781631}},{"date":"2024-06-07","source":"USD","rates":{"GBP":0.781732}},{"date":"2024-06-08","source":"USD","rates":{"GBP":0.785947}},{"date":"2024-06-09","source":"USD","rates":{"GBP":0.785767}},{"date":"2024-06-10","source":"USD","rates":{"GBP":0.785588}},{"date":"2024-06-11","source":"USD","rates":{"GBP":0.785791}},{"date":"2024-06-12","source":"USD","rates":{"GBP":0.784932}},{"date":"2024-06-13","source":"USD","rates":{"GBP":0.781472}},{"date":"2024-06-14","source":"USD","rates":{"GBP":0.784041}},{"date":"2024-06-15","source":"USD","rates":{"GBP":0.789096}},{"date":"2024-06-16","source":"USD","rates":{"GBP":0.788449}},{"date":"2024-06-17","source":"USD","rates":{"GBP":0.788479}},{"date":"2024-06-18","source":"USD","rates":{"GBP":0.786542}},{"date":"2024-06-19","source":"USD","rates":{"GBP":0.786916}},{"date":"2024-06-20","source":"USD","rates":{"GBP":0.786107}},{"date":"2024-06-21","source":"USD","rates":{"GBP":0.789875}},{"date":"2024-06-22","source":"USD","rates":{"GBP":0.79058}},{"date":"2024-06-23","source":"USD","rates":{"GBP":0.790546}},{"date":"2024-06-24","source":"USD","rates":{"GBP":0.791248}},{"date":"2024-06-25","source":"USD","rates":{"GBP":0.788496}},{"date":"2024-06-26","source":"USD","rates":{"GBP":0.788395}},{"date":"2024-06-27","source":"USD","rates":{"GBP":0.792298}},{"date":"2024-06-28","source":"USD","rates":{"GBP":0.79087}},{"date":"2024-06-29","source":"USD","rates":{"GBP":0.790726}},{"date":"2024-06-30","source":"USD","rates":{"GBP":0.790719}},{"date":"2024-07-01","source":"USD","rates":{"GBP":0.790622}},{"date":"2024-07-02","source":"USD","rates":{"GBP":0.790812}},{"date":"2024-07-03","source":"USD","rates":{"GBP":0.78816}},{"date":"2024-07-04","source":"USD","rates":{"GBP":0.784451}},{"date":"2024-07-05","source":"USD","rates":{"GBP":0.783992}},{"date":"2024-07-06","source":"USD","rates":{"GBP":0.780243}},{"date":"2024-07-07","source":"USD","rates":{"GBP":0.780594}},{"date":"2024-07-08","source":"USD","rates":{"GBP":0.780827}},{"date":"2024-07-09","source":"USD","rates":{"GBP":0.780333}},{"date":"2024-07-10","source":"USD","rates":{"GBP":0.781936}},{"date":"2024-07-11","source":"USD","rates":{"GBP":0.777992}},{"date":"2024-07-12","source":"USD","rates":{"GBP":0.773816}},{"date":"2024-07-13","source":"USD","rates":{"GBP":0.770374}},{"date":"2024-07-14","source":"USD","rates":{"GBP":0.770294}},{"date":"2024-07-15","source":"USD","rates":{"GBP":0.771174}},{"date":"2024-07-16","source":"USD","rates":{"GBP":0.771041}},{"date":"2024-07-17","source":"USD","rates":{"GBP":0.770574}},{"date":"2024-07-18","source":"USD","rates":{"GBP":0.768775}},{"date":"2024-07-19","source":"USD","rates":{"GBP":0.772195}},{"date":"2024-07-20","source":"USD","rates":{"GBP":0.774311}},{"date":"2024-07-21","source":"USD","rates":{"GBP":0.774096}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773251}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.773304}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775165}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775289}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777882}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777203}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.776969}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.777176}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.777613}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778999}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","total_records":213,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249739,"date_start":"2024-01-01","date_end":"2024-07-31"}}' + recorded_at: Fri, 16 May 2025 13:01:35 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/health.yml b/test/vcr_cassettes/synth/health.yml index 4d8ca054..24e3e67b 100644 --- a/test/vcr_cassettes/synth/health.yml +++ b/test/vcr_cassettes/synth/health.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:47 GMT + - Fri, 16 May 2025 13:01:39 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + - W/"c5c1d51b68b499d00936c9eb1e8bfdbb" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 0cab64c9-e312-4bec + - 3abc1256-5517-44a7 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 1958563c-7c18-4201-a03c-a4b343dc68ab + - aaf85301-dd16-4b9b-a3a4-c4fbcf1d3f55 X-Runtime: - - '0.014938' + - '0.014386' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=P3OWn4c8LFFWI0Dwr2CSYwHLaNhf9iD9TfAhqdx5PtLoWZ0pSImebfUsh00ZbOmh4r2cRJEQOmvy67wAwl6p0W%2Fx9017EkCnCaXibBBCKqJTBOdGnsSuV%2B45LrHsQmg%2BGeBwrw4b"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=OaVSdNPSl6CQ8gbhnDzkCisX2ILOEWAwweMW3rXXP5rBKuxZoDT024srQWmHKGLsCEhpt4G9mqCthDwlHu2%2BuZ3AyTJQcnBONtE%2FNQ7fKT9x8nLz4mnqL8iyynLuRWQSUJ8SWMj5"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,14 +69,14 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f637aa8cf1152-ORD + - 940b109d086eb4b8-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=25627&min_rtt=25594&rtt_var=9664&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=878&delivery_rate=111991&cwnd=248&unsent_bytes=0&cid=c8e4c4e269114d14&ts=263&x=0" + - cfL4;desc="?proto=TCP&rtt=32457&min_rtt=26792&rtt_var=14094&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=108091&cwnd=229&unsent_bytes=0&cid=a6f330e4d5f16682&ts=309&x=0" body: encoding: ASCII-8BIT - string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test - User","plan":"Business","api_calls_remaining":249830,"api_limit":250000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' - recorded_at: Sat, 15 Mar 2025 22:18:47 GMT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"zach@maybe.co","name":"Zach + Gollwitzer","plan":"Business","api_calls_remaining":249733,"api_limit":250000,"credits_reset_at":"2025-06-01T00:00:00.000-04:00","current_period_start":"2025-05-01T00:00:00.000-04:00"}' + recorded_at: Fri, 16 May 2025 13:01:39 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_info.yml b/test/vcr_cassettes/synth/security_info.yml index 8122b882..8dbb76d1 100644 --- a/test/vcr_cassettes/synth/security_info.yml +++ b/test/vcr_cassettes/synth/security_info.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:04:12 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"a9deeb6437d359f080be449b9b2c547b" + - W/"75f336ad88e262c72044e8b865265298" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 1e77ae49-050a-45fc + - ba973abf-7d96-4a9a Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 222dacf1-37f3-4eb8-91d5-edf13d732d46 + - 76cb13a6-0d7e-4c36-8df9-bb63110d9e2a X-Runtime: - - '0.059222' + - '0.099716' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2BLW%2Fd%2BbcNg4%2FleO6ECyB4RJBMbm6vWG3%2FX4oKQXfn1ROSPVrISc3ZFVlXfITGW4XYJSPyUDF%2FXrrRF6p3Wzow07QamOrsux7sxBMvtWmcubgpCMFI4zgnhESklW6KcmAefwrgj9i"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=aDn7ApAO9Ma86gZ%2BJKCUCFjH2Re%2BtXdB5gcqYj2KTGXJKNpgf5TNgzbrp5%2Bw%2FGL5nTvtp%2B7cxT8MMcLWjAV6Ne1r6z5YBFq1K4W7Zw5m1lhMiqYLnTnEs2Oq85TjzOvpsE%2BmC33d"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,11 +69,11 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141c97bfd9124c-ORD + - 940b10910abdd2ec-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=27459&min_rtt=26850&rtt_var=11288&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=905&delivery_rate=91272&cwnd=104&unsent_bytes=0&cid=ccd6aa7e48e4b0eb&ts=287&x=0" + - cfL4;desc="?proto=TCP&rtt=28163&min_rtt=27237&rtt_var=12066&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=905&delivery_rate=83590&cwnd=239&unsent_bytes=0&cid=7ef62bd693b52ccd&ts=240&x=0" body: encoding: ASCII-8BIT string: '{"data":{"ticker":"AAPL","name":"Apple Inc.","links":{"homepage_url":"https://www.apple.com"},"logo_url":"https://logo.synthfinance.com/ticker/AAPL","description":"Apple @@ -100,6 +100,6 @@ http_interactions: Apple Park Way","city":"Cupertino","state":"CA","postal_code":"95014"},"exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United States","country_code":"US","timezone":"America/New_York"},"ceo":"Mr. Timothy - D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":213.95,"low_today":209.58,"open_today":211.25,"close_today":213.49,"volume_today":60060200.0,"fifty_two_week_high":260.1,"fifty_two_week_low":164.08,"average_volume":62848099.37313433,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249808}}' - recorded_at: Sun, 16 Mar 2025 12:04:12 GMT + D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":212.96,"low_today":209.54,"open_today":210.95,"close_today":211.45,"volume_today":44979900.0,"fifty_two_week_high":260.1,"fifty_two_week_low":169.21,"average_volume":61769396.875,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249737}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_price.yml b/test/vcr_cassettes/synth/security_price.yml index 2e6d1dfb..5cd435d4 100644 --- a/test/vcr_cassettes/synth/security_price.yml +++ b/test/vcr_cassettes/synth/security_price.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:08:00 GMT + - Fri, 16 May 2025 13:01:36 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"cdf04c2cd77e230c03117dd13d0921f9" + - W/"72340d82266397447b865407dda15492" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - e74b3425-0b7c-447d + - 4c3462aa-2471-40b4 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - b906c5e1-18cc-44cc-9085-313ff066a6ce + - bdbc757d-2528-44c3-ae08-9788e8ee15f7 X-Runtime: - - '0.544708' + - '0.034898' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=dZNe6qCGGI2XGXgByLr69%2FYrDQdy2FLtnXafxJnlsvyVjrRFiCvmbbIzgF5CDgtj9HZ8RC5Rh9jbuEI6hPokpa3Al4FEIAZB5AbfZ9toP%2Bc5muG%2FuBgHR%2FnIZpsWG%2BQKmBPu9MBa"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=2Mu4PK4XTsAq%2Bn1%2F2yxy%2Blj7kz3ZCiQ9t8ikr2m19BrhQhrqfeUQfPwxbLc1WIgGMIxpPInKYtDVIX3En%2FGpTNQLAeu%2FpuLKv%2BRmCx%2B7u28od5L%2F9%2BLmEhFWqJjs8Y6C1O2a3SKv"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,15 +69,15 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 921422292d0feacc-ORD + - 940b108f29129d03-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=30826&min_rtt=26727&rtt_var=12950&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=108354&cwnd=219&unsent_bytes=0&cid=43c717161effdc57&ts=695&x=0" + - cfL4;desc="?proto=TCP&rtt=27793&min_rtt=26182&rtt_var=13041&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=74111&cwnd=244&unsent_bytes=0&cid=9bcc030369a615fb&ts=210&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249807}}' - recorded_at: Sun, 16 Mar 2025 12:08:00 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249738}}' + recorded_at: Fri, 16 May 2025 13:01:36 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_prices.yml b/test/vcr_cassettes/synth/security_prices.yml index 1da82461..8f41f56a 100644 --- a/test/vcr_cassettes/synth/security_prices.yml +++ b/test/vcr_cassettes/synth/security_prices.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:02:51 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"eb6f73b7cb267ae753291839d20c72e4" + - W/"909e48e0b9ed1f892c1a1e1b4abd3082" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - e0119cf5-873c-4315 + - 63af1418-59b9-4111 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 590af10c-b7c1-47a1-9a9a-7f8a5f031734 + - 74da6a68-0bbd-48fb-b52a-0c5a750bd925 X-Runtime: - - '0.511130' + - '0.044404' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=BgSpKtsSHqPqVuO8FUQ0Zb4nT2VXJ9Q%2F3QrLGiZyq1%2FvGm4KnPL2jbgehp8fTKMHqK64Dm4aoEfwI6iK22Gz%2BG9Kq8wpHPGugon0YRBz1tiYLnq5QVoyvNJi6HV%2B6IBWQ5jCK5wA"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=nwA0tsfR9it%2B9%2BtfHGjyzyfiSqPdNGxQqOLNF%2BIqlTeT1wJT6gLDCtbd1WFpOc1f8UXm2Zjn%2FJDOf7jOKWmGN6SKUBBjZvFLlBq%2FWyC7DN55NJwwyO77vD%2F5nf%2FaqduWCdPx7n7m"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,17 +69,17 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141a99ed232306-ORD + - 940b10932ec92305-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26790&min_rtt=26357&rtt_var=10751&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=97096&cwnd=126&unsent_bytes=0&cid=5ac523d87c018022&ts=666&x=0" + - cfL4;desc="?proto=TCP&rtt=27451&min_rtt=26715&rtt_var=11491&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=88818&cwnd=249&unsent_bytes=0&cid=63105dfc059c15ef&ts=344&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":82488700},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414500},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71983600},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62303300},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144500},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841800},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46792900},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128400},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40444700},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65603000},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317400},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":78005800},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68741000},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60133900},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355600},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631300},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822100},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44594000},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145600},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55859400},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467800},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885400},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102518000},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69668800},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490800},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53439000},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962000},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155200},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781900},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529500},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54630500},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434500},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49701400},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53665600},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41529700},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52292200},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":45119700},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867400},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318900},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48953900},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682600},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73488000},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81510100},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":95132400},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68587700},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71765100},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76114600},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139500},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59825400},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488700},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913500},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121664700},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75604200},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215200},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423100},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181300},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71106600},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288300},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388400},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60273300},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65672700},{"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":49329500},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691700},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53704400},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42055200},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425500},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451200},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49709300},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91070300},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101593300},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":73531800},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":73711200},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":50901200},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":43122900},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":67772100},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":48116400},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":49537800},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":48251800},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":50558300},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44838400},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":68169400},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":65934800},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":50383100},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":94214900},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":163224100},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":78569700},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":77305800},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":45057100},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":48983000},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":50759500},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":72044800},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":52393600},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":70400000},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":52845200},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":41282900},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":44361300},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":42309400},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":34648500},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":51005900}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\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":249810}}' - recorded_at: Sun, 16 Mar 2025 12:02:51 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":82488700},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414500},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71983600},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62303300},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144500},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841800},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46792900},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128400},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40444700},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65603000},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317400},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":78005800},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68741000},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60133900},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355600},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631300},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822100},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44594000},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145600},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55859400},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467800},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885400},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102518000},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69668800},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490800},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53439000},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962000},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155200},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781900},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529500},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54630500},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434500},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49701400},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53665600},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41529700},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52292200},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":45119700},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867400},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318900},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48953900},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682600},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73488000},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81510100},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":95132400},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68587700},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71765100},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76114600},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139500},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59825400},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488700},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913500},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121664700},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75604200},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215200},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423100},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181300},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71106600},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288300},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388400},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60273300},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65672700},{"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":49329500},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691700},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53704400},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42055200},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425500},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451200},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49709300},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91070300},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101593300},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":73531800},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":73711200},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":50901200},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":43122900},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":67772100},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":48116400},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":49537800},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":48251800},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":50558300},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44838400},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":68169400},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":65934800},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":50383100},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":94214900},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":163224100},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":78569700},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":77305800},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":45057100},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":48983000},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":50759500},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":72044800},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":52393600},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":70400000},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":52845200},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":41282900},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":44361300},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":42309400},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":34648500},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":51005900}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"total_records":147,"credits_used":1,"credits_remaining":249736}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT - request: method: get uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=2&start_date=2024-01-01 @@ -94,7 +94,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -105,7 +105,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:02:52 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -115,11 +115,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"78f6663a1523295a82d0ded13df426e4" + - W/"bbc82ef9591694561dd9992a9c06d491" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - b0cd3704-937c-4017 + - 63ebee52-f1b2-4e81 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -133,15 +133,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 59a55ec3-49af-4fa1-a104-77480fa6914e + - dd95cb59-aead-4d1e-b1a2-881696e742fb X-Runtime: - - '0.583469' + - '0.031482' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ze9Rfqww2cEeTSTiP5axby5TPvYyBZDoEHRZKniMMybJrqYiBI1oGlCViODsaOXisw23njq1YaO%2Fhc0yGlPaqYdTcMXc6bQbVnWANjASqMS%2BQoVmPBFPr3nvSqeU99huB4BKWGlY"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ebrOdAId1yoCYepT0CPKImNIA%2BOe8V3W3BHYheEOkVQFLsffFpfl%2B%2BYXfEHL21wczvW5dkZSd3OrF%2FklB%2FwGGDahXpveXzf497azY1Ho4YJrtDJeghxyZV6J%2BALPYwwpGrfUpv%2F1"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -149,15 +149,15 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141a9edbeee15f-ORD + - 940b1095ccd41156-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=32376&min_rtt=31508&rtt_var=12435&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=91913&cwnd=171&unsent_bytes=0&cid=d782914cf2ed620a&ts=758&x=0" + - cfL4;desc="?proto=TCP&rtt=26344&min_rtt=26162&rtt_var=10175&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=104847&cwnd=237&unsent_bytes=0&cid=9603fe0eb1df39aa&ts=212&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":36294600},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":52280100},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":53068000},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":49947900},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":75158300},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":50080500},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":47471400},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":54156800},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":41181800},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":53103900},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":97262100},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":172373300},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":198134300},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":97862700},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":70122700},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":93728300},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":79943300},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":86172500},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":246421400},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":80727000},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":56713900},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":66213200},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":49772700},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":82542700},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":60402900},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":58046200},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":37369800},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":60412400},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":59085900},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":48076100},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":62627700},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":64710600},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":53046500},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":62631300},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":43234300},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":57345900},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":66034600},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":49151500},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":48201800},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":39960300},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":61777600},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":51391200},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":41601300},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":36311800},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":41643800},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":50036300},{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":249809}}' - recorded_at: Sun, 16 Mar 2025 12:02:52 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":36294600},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":52280100},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":53068000},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":49947900},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":75158300},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":50080500},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":47471400},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":54156800},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":41181800},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":53103900},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":97262100},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":172373300},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":198134300},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":97862700},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":70122700},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":93728300},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":79943300},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":86172500},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":246421400},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":80727000},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":56713900},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":66213200},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":49772700},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":82542700},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":60402900},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":58046200},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":37369800},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":60412400},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":59085900},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":48076100},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":62627700},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":64710600},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":53046500},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":62631300},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":43234300},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":57345900},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":66034600},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":49151500},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":48201800},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":39960300},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":61777600},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":51391200},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":41601300},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":36311800},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":41643800},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":50036300},{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"total_records":147,"credits_used":1,"credits_remaining":249735}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_search.yml b/test/vcr_cassettes/synth/security_search.yml index f9504804..c06c0f42 100644 --- a/test/vcr_cassettes/synth/security_search.yml +++ b/test/vcr_cassettes/synth/security_search.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:01:58 GMT + - Fri, 16 May 2025 13:01:38 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -39,7 +39,7 @@ http_interactions: Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 2effb56b-f67f-402d + - 701ae22a-18c8-4e62 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 33470619-5119-4923-b4e0-e9a0eeb532a1 + - edb55bc6-e3ea-470b-b7af-9b4d9883420b X-Runtime: - - '0.453770' + - '0.355152' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ayZOlXkCwLgUl%2FrB2%2BlqtqR5HCllubf4HLDipEt3klWKyHS4nilHi9XZ1fiEQWx7xwiRMJZ5EW0Xzm7ISoHWTtEbkgMQHWYQwSTeg30ahFFHK1pkOOnET1fuW1UxiZwlJtq1XZGB"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=QGeBWdYED%2F%2FgT9BzborFAnM%2FG6UiNmI0ej212XGHWdFwYXUvTJ2GyqA9hMJrpYIvgbHdQ9Ed0MsQUv3KFb57VXQq0T6UXTNPa%2BFRPepK0hsXeGDLxch04v6KnkTATqcw2M8HuYHS"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,11 +69,11 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 921419514e0a6399-ORD + - 940b1097a830f856-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=25809&min_rtt=25801&rtt_var=9692&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=939&delivery_rate=111952&cwnd=121&unsent_bytes=0&cid=2beb787f15cd8ab9&ts=610&x=0" + - cfL4;desc="?proto=TCP&rtt=26401&min_rtt=25556&rtt_var=11273&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=939&delivery_rate=89615&cwnd=244&unsent_bytes=0&cid=cf6d0758d165295d&ts=500&x=0" body: encoding: ASCII-8BIT string: '{"data":[{"symbol":"AAPL","name":"Apple Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs @@ -100,5 +100,5 @@ http_interactions: Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPJ","currency":"USD","exchange":{"name":"Otc Pink Marketplace","mic_code":"PINX","operating_mic_code":"OTCM","acronym":"","country":"United States","country_code":"US","timezone":"America/New_York"}}]}' - recorded_at: Sun, 16 Mar 2025 12:01:58 GMT + recorded_at: Fri, 16 May 2025 13:01:38 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/transaction_enrich.yml b/test/vcr_cassettes/synth/transaction_enrich.yml deleted file mode 100644 index 08463ed7..00000000 --- a/test/vcr_cassettes/synth/transaction_enrich.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.synthfinance.com/enrich?amount=25.5&city=San%20Francisco&country=US&date=2025-03-16&description=UBER%20EATS&state=CA - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.12.2 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Sun, 16 Mar 2025 12:09:33 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"00411c83cfeaade519bcc3e57d9e461e" - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - 56a8791d-85ed-4342 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 1b35b9c1-0092-40b1-8b70-2bce7c5796af - X-Runtime: - - '0.884634' - X-Xss-Protection: - - '0' - Cf-Cache-Status: - - DYNAMIC - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=qUtB0aWbK%2Fh5W7cV%2FugsUGbWKtJzsf%2FXd5i8cm8KlepEtLyuVPH7XX0fqwzHp43OCWQkGr9r8hRBBSEcx9LWW5vS7%2B1kXCJaKPaTRn%2BWtsEymHg78OHqDcMahwSuy%2FkpSGLWo0or"}],"group":"cf-nel","max_age":604800}' - Nel: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Speculation-Rules: - - '"/cdn-cgi/speculation"' - Server: - - cloudflare - Cf-Ray: - - 921424681aa4acab-ORD - Alt-Svc: - - h3=":443"; ma=86400 - Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26975&min_rtt=26633&rtt_var=10231&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=969&delivery_rate=108737&cwnd=210&unsent_bytes=0&cid=318ff675628918e1&ts=1035&x=0" - body: - encoding: ASCII-8BIT - string: '{"merchant":"Uber Eats","merchant_id":"mer_aea41e7f29ce47b5873f3caf49d5972d","category":"Dining - Out","website":"ubereats.com","icon":"https://logo.synthfinance.com/ubereats.com","meta":{"credits_used":1,"credits_remaining":249806}}' - recorded_at: Sun, 16 Mar 2025 12:09:33 GMT -recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/usage.yml b/test/vcr_cassettes/synth/usage.yml index 27e5300f..d60922db 100644 --- a/test/vcr_cassettes/synth/usage.yml +++ b/test/vcr_cassettes/synth/usage.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:47 GMT + - Fri, 16 May 2025 13:01:36 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + - W/"7b8c2bf0cba54bc26b78bdc6e611dcbd" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 54c8ecf9-6858-4db6 + - 1b53adf6-b391-45b2 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - a4112cfb-0eac-4e3e-a880-7536d90dcba0 + - f88670a2-81d2-48b6-8d73-a911c846e330 X-Runtime: - - '0.007036' + - '0.018749' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Rt0BTtrgXzYjWOQFgb%2Bg6N4xKvXtPI66Q251bq9nWtqUhGHo17GmVVAPkutwN7Gisw1RmvYfxYUiMCCxlc4%2BjuHxbU1%2BXr9KHy%2F5pUpLhgLNNrtkqqKOCW4GduODnDbw2I38Rocu"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=oH4OsWB6itK0jpi%2FPs%2BswVyCZIbkJGPfyJaoR4TKFtTAfmnqa8Lp6aZhv22WKzotXJuAKbh99VdYdZIOkeIPWbYTc6j4rGw%2BkQB3Hw%2Fc44QxDBJFdIo6wJNe8TGiPAZ%2BvgoBVHWn"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,14 +69,14 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f637d1fe8eb68-ORD + - 940b108c38f66392-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=28779&min_rtt=27036&rtt_var=11384&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=878&delivery_rate=107116&cwnd=203&unsent_bytes=0&cid=52bc39ad09dd9eff&ts=145&x=0" + - cfL4;desc="?proto=TCP&rtt=33369&min_rtt=25798&rtt_var=15082&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=112256&cwnd=205&unsent_bytes=0&cid=1b13324eb0768fd3&ts=285&x=0" body: encoding: ASCII-8BIT - string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test - User","plan":"Business","api_calls_remaining":1200,"api_limit":5000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' - recorded_at: Sat, 15 Mar 2025 22:18:47 GMT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"zach@maybe.co","name":"Zach + Gollwitzer","plan":"Business","api_calls_remaining":249738,"api_limit":250000,"credits_reset_at":"2025-06-01T00:00:00.000-04:00","current_period_start":"2025-05-01T00:00:00.000-04:00"}' + recorded_at: Fri, 16 May 2025 13:01:36 GMT recorded_with: VCR 6.3.1