From f65b93a3520fa91504e278b9301e925459292f6b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 17 Mar 2025 11:54:53 -0400 Subject: [PATCH] Data provider simplification, tests, and documentation (#1997) * Ignore env.test from source control * Simplification of providers interface * Synth tests * Update money to use new find rates method * Remove unused issues code * Additional issue feature removals * Update price data fetching and tests * Update documentation for providers * Security test fixes * Fix self host test * Update synth usage data access * Remove AI pr schema changes --- .cursor/rules/project-design.mdc | 174 ++++++++ .env.test | 8 - .env.test.example | 2 + .gitignore | 1 - ...hange_rate_provider_missings_controller.rb | 20 - app/controllers/issues_controller.rb | 13 - app/controllers/securities_controller.rb | 8 +- .../settings/hostings_controller.rb | 2 +- app/jobs/enrich_transaction_batch_job.rb | 3 +- app/models/account.rb | 4 +- app/models/account/convertible.rb | 5 +- app/models/account/data_enricher.rb | 66 --- app/models/account/enrichable.rb | 61 ++- app/models/account/entry.rb | 2 +- app/models/account/entry/provided.rb | 11 - app/models/account/holding/portfolio_cache.rb | 13 +- app/models/account/transaction.rb | 2 +- app/models/account/transaction/provided.rb | 15 + app/models/concerns/issuable.rb | 52 --- app/models/concerns/synthable.rb | 37 -- app/models/exchange_rate.rb | 24 +- app/models/exchange_rate/provideable.rb | 15 + app/models/exchange_rate/provided.rb | 75 ++-- app/models/family.rb | 7 +- app/models/financial_assistant.rb | 11 + app/models/financial_assistant/provided.rb | 13 + app/models/issue.rb | 35 -- .../issue/exchange_rate_provider_missing.rb | 9 - app/models/issue/exchange_rates_missing.rb | 15 - app/models/issue/unknown.rb | 11 - app/models/plaid_account.rb | 2 - app/models/plaid_item.rb | 2 +- .../plaidable.rb => plaid_item/provided.rb} | 6 +- app/models/provider.rb | 35 ++ app/models/provider/base.rb | 18 - app/models/provider/synth.rb | 376 +++++++++--------- app/models/providers.rb | 35 ++ app/models/security.rb | 2 +- app/models/security/price.rb | 31 +- app/models/security/price/provided.rb | 65 --- app/models/security/provideable.rb | 31 ++ app/models/security/provided.rb | 61 ++- app/models/trade_import.rb | 2 +- app/models/upgrader/provided.rb | 6 +- app/views/account/trades/_form.html.erb | 2 +- app/views/accounts/_account.html.erb | 7 - .../accounts/_account_sidebar_tabs.html.erb | 2 +- app/views/accounts/show/_header.html.erb | 4 - .../issue/_request_synth_data_action.html.erb | 12 - .../show.html.erb | 28 -- .../exchange_rates_missings/show.html.erb | 11 - app/views/issue/prices_missings/show.html.erb | 11 - app/views/issue/unknowns/show.html.erb | 23 -- app/views/issues/_issue.html.erb | 15 - app/views/layouts/issues.html.erb | 15 - .../hostings/_synth_settings.html.erb | 10 +- config/brakeman.ignore | 33 +- config/locales/models/issue/en.yml | 8 - config/locales/views/accounts/en.yml | 1 - config/routes.rb | 6 - ...1233_remove_ticker_from_security_prices.rb | 5 + db/migrate/20250316103753_remove_issues.rb | 11 + ...50316122019_security_price_unique_index.rb | 31 ++ db/schema.rb | 27 +- lib/money.rb | 2 +- ..._rate_provider_missings_controller_test.rb | 19 - test/controllers/issues_controller_test.rb | 18 - .../settings/hostings_controller_test.rb | 16 + test/fixtures/issues.yml | 5 - .../exchange_rate_provider_interface_test.rb | 38 +- .../security_price_provider_interface_test.rb | 26 -- .../security_provider_interface_test.rb | 62 +++ test/lib/money_test.rb | 6 +- test/models/account/convertible_test.rb | 43 +- .../account/holding/portfolio_cache_test.rb | 98 +++-- test/models/exchange_rate_test.rb | 161 ++++---- test/models/provider/synth_test.rb | 61 ++- test/models/provider_test.rb | 61 +++ test/models/providers_test.rb | 27 ++ test/models/security/price_test.rb | 138 +++---- test/models/trade_import_test.rb | 4 +- test/support/provider_test_helper.rb | 17 + test/system/imports_test.rb | 5 +- test/system/settings_test.rb | 1 + test/system/trades_test.rb | 20 +- test/vcr_cassettes/synth/exchange_rate.yml | 50 ++- .../synth/exchange_rate_historical.yml | 213 ---------- test/vcr_cassettes/synth/exchange_rates.yml | 81 ++++ test/vcr_cassettes/synth/health.yml | 82 ++++ test/vcr_cassettes/synth/security_info.yml | 105 +++++ test/vcr_cassettes/synth/security_price.yml | 83 ++++ test/vcr_cassettes/synth/security_prices.yml | 294 +++++++------- test/vcr_cassettes/synth/security_search.yml | 104 +++++ .../synth/transaction_enrich.yml | 82 ++++ test/vcr_cassettes/synth/usage.yml | 82 ++++ 95 files changed, 2014 insertions(+), 1638 deletions(-) delete mode 100644 .env.test delete mode 100644 app/controllers/issue/exchange_rate_provider_missings_controller.rb delete mode 100644 app/controllers/issues_controller.rb delete mode 100644 app/models/account/data_enricher.rb delete mode 100644 app/models/account/entry/provided.rb create mode 100644 app/models/account/transaction/provided.rb delete mode 100644 app/models/concerns/issuable.rb delete mode 100644 app/models/concerns/synthable.rb create mode 100644 app/models/exchange_rate/provideable.rb create mode 100644 app/models/financial_assistant.rb create mode 100644 app/models/financial_assistant/provided.rb delete mode 100644 app/models/issue.rb delete mode 100644 app/models/issue/exchange_rate_provider_missing.rb delete mode 100644 app/models/issue/exchange_rates_missing.rb delete mode 100644 app/models/issue/unknown.rb rename app/models/{concerns/plaidable.rb => plaid_item/provided.rb} (65%) create mode 100644 app/models/provider.rb delete mode 100644 app/models/provider/base.rb create mode 100644 app/models/providers.rb delete mode 100644 app/models/security/price/provided.rb create mode 100644 app/models/security/provideable.rb delete mode 100644 app/views/issue/_request_synth_data_action.html.erb delete mode 100644 app/views/issue/exchange_rate_provider_missings/show.html.erb delete mode 100644 app/views/issue/exchange_rates_missings/show.html.erb delete mode 100644 app/views/issue/prices_missings/show.html.erb delete mode 100644 app/views/issue/unknowns/show.html.erb delete mode 100644 app/views/issues/_issue.html.erb delete mode 100644 app/views/layouts/issues.html.erb delete mode 100644 config/locales/models/issue/en.yml create mode 100644 db/migrate/20250315191233_remove_ticker_from_security_prices.rb create mode 100644 db/migrate/20250316103753_remove_issues.rb create mode 100644 db/migrate/20250316122019_security_price_unique_index.rb delete mode 100644 test/controllers/issue/exchange_rate_provider_missings_controller_test.rb delete mode 100644 test/controllers/issues_controller_test.rb delete mode 100644 test/fixtures/issues.yml delete mode 100644 test/interfaces/security_price_provider_interface_test.rb create mode 100644 test/interfaces/security_provider_interface_test.rb create mode 100644 test/models/provider_test.rb create mode 100644 test/models/providers_test.rb create mode 100644 test/support/provider_test_helper.rb delete mode 100644 test/vcr_cassettes/synth/exchange_rate_historical.yml create mode 100644 test/vcr_cassettes/synth/exchange_rates.yml create mode 100644 test/vcr_cassettes/synth/health.yml create mode 100644 test/vcr_cassettes/synth/security_info.yml create mode 100644 test/vcr_cassettes/synth/security_price.yml create mode 100644 test/vcr_cassettes/synth/security_search.yml create mode 100644 test/vcr_cassettes/synth/transaction_enrich.yml create mode 100644 test/vcr_cassettes/synth/usage.yml diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 84f994b5..6d4f2091 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -1,6 +1,7 @@ --- description: This rule explains the system architecture and data flow of the Rails app globs: * +alwaysApply: false --- This file outlines how the codebase is structured and how data flows through the app. @@ -131,4 +132,177 @@ A Plaid Item sync is an ETL (extract, transform, load) operation: A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs. +## Data Providers + +The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured. + +Because of this optionality, data providers must be configured at _runtime_ through the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys: + +```rb +module Providers + module_function + + def synth + api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) + + return nil unless api_key.present? + + Provider::Synth.new(api_key) + end +end +``` + +There are two types of 3rd party data in the Maybe app: + +1. "Concept" data +2. One-off data + +### "Concept" data + +Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept". + +Each "concept" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this: + +``` +app/models/ + exchange_rate.rb # <- ActiveRecord model and "concept" + exchange_rate/ + provided.rb # <- Chooses the provider for this concept based on user settings / config + provideable.rb # <- Defines interface for providing exchange rates + provider.rb # <- Base provider class + provider/ + synth.rb # <- Concrete provider implementation +``` + +Where the `Provideable` and concrete provider implementations would be something like: + +```rb +# Defines the interface an exchange rate provider must implement +module ExchangeRate::Provideable + extend ActiveSupport::Concern + + FetchRateData = Data.define(:rate) + FetchRatesData = Data.define(:rates) + + def fetch_exchange_rate(from:, to:, date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" + end +end +``` + +Any provider that is a valid exchange rate provider must implement this interface: + +```rb +class ConcreteProvider < Provider + include ExchangeRate::Provideable + + def fetch_exchange_rate(from:, to:, date:) + provider_response do + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new # build response + ) + end + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + # Implementation + end +end +``` + +### One-off data + +For data that does not fit neatly into a "concept", a `Provideable` is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions: + +```rb +class SomeModel < Application + def synth_usage + Providers.synth.usage + end +end +``` + +## "Provided" Concerns + +In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for: + +- Choosing the provider to use for this "concept" +- Providing convenience methods on the model for accessing data + +For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods: + +```rb +module ExchangeRate::Provided + extend ActiveSupport::Concern + + class_methods do + def provider + Providers.synth + end + + def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) + # Implementation + end + + def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + # Implementation + end + end +end +``` + +This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response: + +```rb +def access_patterns_example + # Call exchange rate provider directly + ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current) + + # Call convenience method + ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date) +end +``` + +## Concrete provider implementations + +Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object: + +```rb +class ConcreteProvider < Provider + def fetch_some_data + provider_response do + ExampleData.new( + example: "data" + ) + end + end +end +``` + +The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: + +```rb +class ConcreteProvider < Provider + def fetch_some_data + provider_response do + data = nil + + # Raise an error if data cannot be returned + raise ProviderError.new("Could not find the data you need") if data.nil? + + data + end + end +end +``` + + + + + + diff --git a/.env.test b/.env.test deleted file mode 100644 index f47801f1..00000000 --- a/.env.test +++ /dev/null @@ -1,8 +0,0 @@ -SELF_HOSTED=false -SYNTH_API_KEY=fookey - -# Set to true if you want SimpleCov reports generated -COVERAGE=false - -# Set to true to run test suite serially -DISABLE_PARALLELIZATION=false \ No newline at end of file diff --git a/.env.test.example b/.env.test.example index e5133c42..37fb9ef9 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,3 +1,5 @@ +SELF_HOSTED=false + # ================ # Data Providers # --------------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index b75bf5d4..0b8983aa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ # Ignore all environment files (except templates). /.env* !/.env*.erb -!.env.test !.env*.example # Ignore all logfiles and tempfiles. diff --git a/app/controllers/issue/exchange_rate_provider_missings_controller.rb b/app/controllers/issue/exchange_rate_provider_missings_controller.rb deleted file mode 100644 index 7e53f4df..00000000 --- a/app/controllers/issue/exchange_rate_provider_missings_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -class Issue::ExchangeRateProviderMissingsController < ApplicationController - before_action :set_issue, only: :update - - def update - Setting.synth_api_key = exchange_rate_params[:synth_api_key] - account = @issue.issuable - account.sync_later - redirect_back_or_to account - end - - private - - def set_issue - @issue = Current.family.issues.find(params[:id]) - end - - def exchange_rate_params - params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key) - end -end diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb deleted file mode 100644 index 0585446d..00000000 --- a/app/controllers/issues_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -class IssuesController < ApplicationController - before_action :set_issue, only: :show - - def show - render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues" - end - - private - - def set_issue - @issue = Current.family.issues.find(params[:id]) - end -end diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 2c4124cf..f2e1b1b7 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -1,8 +1,8 @@ class SecuritiesController < ApplicationController def index - @securities = Security.search_provider({ - search: params[:q], - country: params[:country_code] == "US" ? "US" : nil - }) + @securities = Security.search_provider( + params[:q], + country_code: params[:country_code] == "US" ? "US" : nil + ) end end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 0740b0bc..637ff80f 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -5,7 +5,7 @@ class Settings::HostingsController < ApplicationController before_action :ensure_admin, only: :clear_cache def show - @synth_usage = Current.family.synth_usage + @synth_usage = Providers.synth&.usage end def update diff --git a/app/jobs/enrich_transaction_batch_job.rb b/app/jobs/enrich_transaction_batch_job.rb index 22d026f7..a796db67 100644 --- a/app/jobs/enrich_transaction_batch_job.rb +++ b/app/jobs/enrich_transaction_batch_job.rb @@ -2,7 +2,6 @@ class EnrichTransactionBatchJob < ApplicationJob queue_as :latency_high def perform(account, batch_size = 100, offset = 0) - enricher = Account::DataEnricher.new(account) - enricher.enrich_transaction_batch(batch_size, offset) + account.enrich_transaction_batch(batch_size, offset) end end diff --git a/app/models/account.rb b/app/models/account.rb index a50ef13a..5e1383e4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable, Convertible + include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible validates :name, :balance, :currency, presence: true @@ -13,7 +13,6 @@ class Account < ApplicationRecord has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" has_many :holdings, dependent: :destroy, class_name: "Account::Holding" has_many :balances, dependent: :destroy - has_many :issues, as: :issuable, dependent: :destroy monetize :balance, :cash_balance @@ -88,7 +87,6 @@ class Account < ApplicationRecord def post_sync broadcast_remove_to(family, target: "syncing-notice") - resolve_stale_issues accountable.post_sync end diff --git a/app/models/account/convertible.rb b/app/models/account/convertible.rb index 8f5a1199..fde6fa10 100644 --- a/app/models/account/convertible.rb +++ b/app/models/account/convertible.rb @@ -7,14 +7,13 @@ module Account::Convertible return end - rates = ExchangeRate.find_rates( + affected_row_count = ExchangeRate.sync_provider_rates( from: currency, to: target_currency, start_date: start_date, - cache: true # caches from provider to DB ) - Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}") + Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}") end private diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb deleted file mode 100644 index 8d07eff8..00000000 --- a/app/models/account/data_enricher.rb +++ /dev/null @@ -1,66 +0,0 @@ -class Account::DataEnricher - attr_reader :account - - def initialize(account) - @account = account - end - - def run - total_unenriched = account.entries.account_transactions - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") - .count - - if total_unenriched > 0 - batch_size = 50 - batches = (total_unenriched.to_f / batch_size).ceil - - batches.times do |batch| - EnrichTransactionBatchJob.perform_later(account, batch_size, batch * batch_size) - end - end - end - - def enrich_transaction_batch(batch_size = 50, offset = 0) - candidates = account.entries.account_transactions - .includes(entryable: [ :merchant, :category ]) - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") - .offset(offset) - .limit(batch_size) - - Rails.logger.info("Enriching batch of #{candidates.count} transactions for account #{account.id} (offset: #{offset})") - - merchants = {} - - candidates.each do |entry| - begin - info = entry.fetch_enrichment_info - - next unless info.present? - - if info.name.present? - merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name) - - if info.icon_url.present? - merchant.icon_url = info.icon_url - end - end - - entryable_attributes = { id: entry.entryable_id } - entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil? - - Account.transaction do - merchant.save! if merchant.present? - entry.update!( - enriched_at: Time.current, - enriched_name: info.name, - entryable_attributes: entryable_attributes - ) - end - rescue => e - Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}") - end - end - end -end diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb index 236cce58..260aec5a 100644 --- a/app/models/account/enrichable.rb +++ b/app/models/account/enrichable.rb @@ -2,11 +2,70 @@ module Account::Enrichable extend ActiveSupport::Concern def enrich_data - DataEnricher.new(self).run + total_unenriched = entries.account_transactions + .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") + .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") + .count + + if total_unenriched > 0 + batch_size = 50 + batches = (total_unenriched.to_f / batch_size).ceil + + batches.times do |batch| + EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size) + # EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size) + end + end + end + + def enrich_transaction_batch(batch_size = 50, offset = 0) + transactions_batch = enrichable_transactions.offset(offset).limit(batch_size) + + Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})") + + merchants = {} + + transactions_batch.each do |transaction| + begin + info = transaction.fetch_enrichment_info + + next unless info.present? + + if info.name.present? + merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name) + + if info.icon_url.present? + merchant.icon_url = info.icon_url + end + end + + Account.transaction do + merchant.save! if merchant.present? + transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil? + + transaction.entry.update!( + enriched_at: Time.current, + enriched_name: info.name, + ) + end + rescue => e + Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}") + end + end end private def enrichable? family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?) end + + def enrichable_transactions + transactions.active + .includes(:merchant, :category) + .where( + "account_entries.enriched_at IS NULL", + "OR merchant_id IS NULL", + "OR category_id IS NULL" + ) + end end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 25065efd..b53db19b 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -1,5 +1,5 @@ class Account::Entry < ApplicationRecord - include Monetizable, Provided + include Monetizable monetize :amount diff --git a/app/models/account/entry/provided.rb b/app/models/account/entry/provided.rb deleted file mode 100644 index c18654c9..00000000 --- a/app/models/account/entry/provided.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Account::Entry::Provided - extend ActiveSupport::Concern - - include Synthable - - def fetch_enrichment_info - return nil unless synth_client.present? - - synth_client.enrich_transaction(name).info - end -end diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb index bb6035cf..224d0b83 100644 --- a/app/models/account/holding/portfolio_cache.rb +++ b/app/models/account/holding/portfolio_cache.rb @@ -79,12 +79,11 @@ class Account::Holding::PortfolioCache securities.each do |security| Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}" - # Highest priority prices - db_or_provider_prices = Security::Price.find_prices( - security: security, - start_date: account.start_date, - end_date: Date.current - ).map do |price| + # Load prices from provider to DB + security.sync_provider_prices(start_date: account.start_date) + + # High priority prices from DB (synced from provider) + db_prices = security.prices.where(date: account.start_date..Date.current).map do |price| PriceWithPriority.new( price: price, priority: 1 @@ -125,7 +124,7 @@ class Account::Holding::PortfolioCache @security_cache[security.id] = { security: security, - prices: db_or_provider_prices + trade_prices + holding_prices + prices: db_prices + trade_prices + holding_prices } end end diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index a500ef74..e31a5607 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -1,5 +1,5 @@ class Account::Transaction < ApplicationRecord - include Account::Entryable, Transferable + include Account::Entryable, Transferable, Provided belongs_to :category, optional: true belongs_to :merchant, optional: true diff --git a/app/models/account/transaction/provided.rb b/app/models/account/transaction/provided.rb new file mode 100644 index 00000000..14df5b55 --- /dev/null +++ b/app/models/account/transaction/provided.rb @@ -0,0 +1,15 @@ +module Account::Transaction::Provided + extend ActiveSupport::Concern + + def fetch_enrichment_info + return nil unless Providers.synth # Only Synth can provide this data + + response = Providers.synth.enrich_transaction( + entry.name, + amount: entry.amount, + date: entry.date + ) + + response.data + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb deleted file mode 100644 index f295332f..00000000 --- a/app/models/concerns/issuable.rb +++ /dev/null @@ -1,52 +0,0 @@ -module Issuable - extend ActiveSupport::Concern - - included do - has_many :issues, dependent: :destroy, as: :issuable - end - - def has_issues? - issues.active.any? - end - - def resolve_stale_issues - issues.active.each do |issue| - issue.resolve! if issue.stale? - end - end - - def observe_unknown_issue(error) - observe_issue( - Issue::Unknown.new(data: { error: error.message }) - ) - end - - def observe_missing_exchange_rates(from:, to:, dates:) - observe_issue( - Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates }) - ) - end - - def observe_missing_exchange_rate_provider - observe_issue( - Issue::ExchangeRateProviderMissing.new - ) - end - - def highest_priority_issue - issues.active.ordered.first - end - - private - - def observe_issue(new_issue) - existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil) - - if existing_issue - existing_issue.update!(last_observed_at: Time.current, data: new_issue.data) - else - new_issue.issuable = self - new_issue.save! - end - end -end diff --git a/app/models/concerns/synthable.rb b/app/models/concerns/synthable.rb deleted file mode 100644 index 51adcade..00000000 --- a/app/models/concerns/synthable.rb +++ /dev/null @@ -1,37 +0,0 @@ -module Synthable - extend ActiveSupport::Concern - - class_methods do - def synth_usage - synth_client&.usage - end - - def synth_overage? - synth_usage&.usage&.utilization.to_i >= 100 - end - - def synth_healthy? - synth_client&.healthy? - end - - def synth_client - api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) - - return nil unless api_key.present? - - Provider::Synth.new(api_key) - end - end - - def synth_client - self.class.synth_client - end - - def synth_usage - self.class.synth_usage - end - - def synth_overage? - self.class.synth_overage? - end -end diff --git a/app/models/exchange_rate.rb b/app/models/exchange_rate.rb index 01b63e2b..cca0c41c 100644 --- a/app/models/exchange_rate.rb +++ b/app/models/exchange_rate.rb @@ -2,27 +2,5 @@ class ExchangeRate < ApplicationRecord include Provided validates :from_currency, :to_currency, :date, :rate, presence: true - - class << self - def find_rate(from:, to:, date:, cache: true) - result = find_by \ - from_currency: from, - to_currency: to, - date: date - - result || fetch_rate_from_provider(from:, to:, date:, cache:) - end - - def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true) - rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a - all_dates = (start_date..end_date).to_a - existing_dates = rates.map(&:date) - missing_dates = all_dates - existing_dates - if missing_dates.any? - rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:) - end - - rates - end - end + validates :date, uniqueness: { scope: %i[from_currency to_currency] } end diff --git a/app/models/exchange_rate/provideable.rb b/app/models/exchange_rate/provideable.rb new file mode 100644 index 00000000..5f2278c6 --- /dev/null +++ b/app/models/exchange_rate/provideable.rb @@ -0,0 +1,15 @@ +# Defines the interface an exchange rate provider must implement +module ExchangeRate::Provideable + extend ActiveSupport::Concern + + FetchRateData = Data.define(:rate) + FetchRatesData = Data.define(:rates) + + def fetch_exchange_rate(from:, to:, date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" + end +end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index d010ff98..6c502c05 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -1,63 +1,44 @@ module ExchangeRate::Provided extend ActiveSupport::Concern - include Synthable - class_methods do def provider - synth_client + Providers.synth end - private - def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false) - return [] unless provider.present? + def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) + rate = find_by(from_currency: from, to_currency: to, date: date) + return rate if rate.present? - response = provider.fetch_exchange_rates \ - from: from, - to: to, - start_date: start_date, - end_date: end_date + return nil unless provider.present? # No provider configured (some self-hosted apps) - if response.success? - response.rates.map do |exchange_rate| - rate = ExchangeRate.new \ - from_currency: from, - to_currency: to, - date: exchange_rate.dig(:date).to_date, - rate: exchange_rate.dig(:rate) + response = provider.fetch_exchange_rate(from: from, to: to, date: date) - rate.save! if cache - rate - rescue ActiveRecord::RecordNotUnique - next - end - else - [] - end + return nil unless response.success? # Provider error + + rate = response.data.rate + rate.save! if cache + rate + end + + def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + unless provider.present? + Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates") + return 0 end - def fetch_rate_from_provider(from:, to:, date:, cache: false) - return nil unless provider.present? + fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date) - response = provider.fetch_exchange_rate \ - from: from, - to: to, - date: date - - if response.success? - rate = ExchangeRate.new \ - from_currency: from, - to_currency: to, - rate: response.rate, - date: date - - if cache - rate.save! rescue ActiveRecord::RecordNotUnique - end - rate - else - nil - end + 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.rates.map do |rate| + rate.attributes.slice("from_currency", "to_currency", "date", "rate") + end + + ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date]) + end end end diff --git a/app/models/family.rb b/app/models/family.rb index 0f71731f..ec2d1bb6 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include Synthable, Plaidable, Syncable, AutoTransferMatchable + include Syncable, AutoTransferMatchable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -19,7 +19,6 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy - has_many :issues, through: :accounts has_many :entries, through: :accounts has_many :transactions, through: :accounts @@ -75,9 +74,9 @@ class Family < ApplicationRecord def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) provider = if region.to_sym == :eu - self.class.plaid_eu_provider + Providers.plaid_eu else - self.class.plaid_us_provider + Providers.plaid_us end # early return when no provider diff --git a/app/models/financial_assistant.rb b/app/models/financial_assistant.rb new file mode 100644 index 00000000..7480becc --- /dev/null +++ b/app/models/financial_assistant.rb @@ -0,0 +1,11 @@ +class FinancialAssistant + include Provided + + def initialize(chat) + @chat = chat + end + + def query(prompt, model_key: "gpt-4o") + llm_provider = self.class.llm_provider_for(model_key) + end +end diff --git a/app/models/financial_assistant/provided.rb b/app/models/financial_assistant/provided.rb new file mode 100644 index 00000000..f88ad339 --- /dev/null +++ b/app/models/financial_assistant/provided.rb @@ -0,0 +1,13 @@ +module FinancialAssistant::Provided + extend ActiveSupport::Concern + + # Placeholder for AI chat PR + def llm_provider_for(model_key) + case model_key + when "gpt-4o" + Providers.openai + else + raise "Unknown LLM model key: #{model_key}" + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb deleted file mode 100644 index 0f0cf2d2..00000000 --- a/app/models/issue.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Issue < ApplicationRecord - belongs_to :issuable, polymorphic: true - - after_initialize :set_default_severity - - enum :severity, { critical: 1, error: 2, warning: 3, info: 4 } - - validates :severity, presence: true - - scope :active, -> { where(resolved_at: nil) } - scope :ordered, -> { order(:severity) } - - def title - model_name.human - end - - # The conditions that must be met for an issue to be fixed - def stale? - raise NotImplementedError, "#{self.class} must implement #{__method__}" - end - - def resolve! - update!(resolved_at: Time.current) - end - - def default_severity - :warning - end - - private - - def set_default_severity - self.severity ||= default_severity - end -end diff --git a/app/models/issue/exchange_rate_provider_missing.rb b/app/models/issue/exchange_rate_provider_missing.rb deleted file mode 100644 index 72411990..00000000 --- a/app/models/issue/exchange_rate_provider_missing.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Issue::ExchangeRateProviderMissing < Issue - def default_severity - :error - end - - def stale? - ExchangeRate.provider_healthy? - end -end diff --git a/app/models/issue/exchange_rates_missing.rb b/app/models/issue/exchange_rates_missing.rb deleted file mode 100644 index 1527fec5..00000000 --- a/app/models/issue/exchange_rates_missing.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Issue::ExchangeRatesMissing < Issue - store_accessor :data, :from_currency, :to_currency, :dates - - validates :from_currency, :to_currency, :dates, presence: true - - def stale? - if dates.length == 1 - ExchangeRate.find_rate(from: from_currency, to: to_currency, date: dates.first).present? - else - sorted_dates = dates.sort - rates = ExchangeRate.find_rates(from: from_currency, to: to_currency, start_date: sorted_dates.first, end_date: sorted_dates.last) - rates.length == dates.length - end - end -end diff --git a/app/models/issue/unknown.rb b/app/models/issue/unknown.rb deleted file mode 100644 index d232ebcb..00000000 --- a/app/models/issue/unknown.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Issue::Unknown < Issue - def default_severity - :warning - end - - # Unknown issues are always stale because we only want to show them - # to the user once. If the same error occurs again, we'll create a new instance. - def stale? - true - end -end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 77a4288d..e0e71f67 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,6 +1,4 @@ class PlaidAccount < ApplicationRecord - include Plaidable - TYPE_MAPPING = { "depository" => Depository, "credit" => CreditCard, diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 9ffdadf1..b990729a 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,5 +1,5 @@ class PlaidItem < ApplicationRecord - include Plaidable, Syncable + include Provided, Syncable enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good diff --git a/app/models/concerns/plaidable.rb b/app/models/plaid_item/provided.rb similarity index 65% rename from app/models/concerns/plaidable.rb rename to app/models/plaid_item/provided.rb index 8765559d..761a75c1 100644 --- a/app/models/concerns/plaidable.rb +++ b/app/models/plaid_item/provided.rb @@ -1,13 +1,13 @@ -module Plaidable +module PlaidItem::Provided extend ActiveSupport::Concern class_methods do def plaid_us_provider - Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid + Providers.plaid_us end def plaid_eu_provider - Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu + Providers.plaid_eu end def plaid_provider_for_region(region) diff --git a/app/models/provider.rb b/app/models/provider.rb new file mode 100644 index 00000000..6843475b --- /dev/null +++ b/app/models/provider.rb @@ -0,0 +1,35 @@ +class Provider + include Retryable + + ProviderError = Class.new(StandardError) + ProviderResponse = Data.define(:success?, :data, :error) + + private + PaginatedData = Data.define(:paginated, :first_page, :total_pages) + UsageData = Data.define(:used, :limit, :utilization, :plan) + + # Subclasses can specify errors that can be retried + def retryable_errors + [] + end + + def provider_response(retries: nil, &block) + data = if retries + retrying(retryable_errors, max_retries: retries) { yield } + else + yield + end + + ProviderResponse.new( + success?: true, + data: data, + error: nil, + ) + rescue StandardError => error + ProviderResponse.new( + success?: false, + data: nil, + error: error, + ) + end +end diff --git a/app/models/provider/base.rb b/app/models/provider/base.rb deleted file mode 100644 index dcf438e5..00000000 --- a/app/models/provider/base.rb +++ /dev/null @@ -1,18 +0,0 @@ - -class Provider::Base - ProviderError = Class.new(StandardError) - - TRANSIENT_NETWORK_ERRORS = [ - Faraday::TimeoutError, - Faraday::ConnectionFailed, - Faraday::SSLError, - Faraday::ClientError, - Faraday::ServerError - ] - - class << self - def known_transient_errors - TRANSIENT_NETWORK_ERRORS + [ ProviderError ] - end - end -end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 829a5b7f..89850aa3 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -1,217 +1,226 @@ -class Provider::Synth - include Retryable +class Provider::Synth < Provider + include ExchangeRate::Provideable + include Security::Provideable def initialize(api_key) @api_key = api_key end def healthy? - response = client.get("#{base_url}/user") - JSON.parse(response.body).dig("id").present? + provider_response do + response = client.get("#{base_url}/user") + JSON.parse(response.body).dig("id").present? + end end def usage - response = client.get("#{base_url}/user") + provider_response do + response = client.get("#{base_url}/user") - if response.status == 401 - return UsageResponse.new( - success?: false, - error: "Unauthorized: Invalid API key", - raw_response: response + parsed = JSON.parse(response.body) + + remaining = parsed.dig("api_calls_remaining") + limit = parsed.dig("api_limit") + used = limit - remaining + + UsageData.new( + used: used, + limit: limit, + utilization: used.to_f / limit * 100, + plan: parsed.dig("plan"), ) end - - parsed = JSON.parse(response.body) - - remaining = parsed.dig("api_calls_remaining") - limit = parsed.dig("api_limit") - used = limit - remaining - - UsageResponse.new( - used: used, - limit: limit, - utilization: used.to_f / limit * 100, - plan: parsed.dig("plan"), - success?: true, - raw_response: response - ) - rescue StandardError => error - UsageResponse.new( - success?: false, - error: error, - raw_response: error - ) end - def fetch_security_prices(ticker:, start_date:, end_date:, mic_code: nil) - params = { - start_date: start_date, - end_date: end_date - } - - params[:mic_code] = mic_code if mic_code.present? - - prices = paginate( - "#{base_url}/tickers/#{ticker}/open-close", - params - ) do |body| - body.dig("prices").map do |price| - { - date: price.dig("date"), - price: price.dig("close")&.to_f || price.dig("open")&.to_f, - currency: body.dig("currency") || "USD" - } - end - end - - SecurityPriceResponse.new \ - prices: prices, - success?: true, - raw_response: prices.to_json - rescue StandardError => error - SecurityPriceResponse.new \ - success?: false, - error: error, - raw_response: error - end + # ================================ + # Exchange Rates + # ================================ def fetch_exchange_rate(from:, to:, date:) - retrying Provider::Base.known_transient_errors do |on_last_attempt| + provider_response retries: 2 do response = client.get("#{base_url}/rates/historical") do |req| req.params["date"] = date.to_s req.params["from"] = from req.params["to"] = to end - if response.success? - ExchangeRateResponse.new \ - rate: JSON.parse(response.body).dig("data", "rates", to), - success?: true, - raw_response: response - else - if on_last_attempt - ExchangeRateResponse.new \ - success?: false, - error: build_error(response), - raw_response: response - else - raise build_error(response) - end - end + rates = JSON.parse(response.body).dig("data", "rates") + + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new( + from_currency: from, + to_currency: to, + date: date, + rate: rates.dig(to) + ) + ) end end def fetch_exchange_rates(from:, to:, start_date:, end_date:) - exchange_rates = paginate( - "#{base_url}/rates/historical-range", - from: from, - to: to, - date_start: start_date.to_s, - date_end: end_date.to_s - ) do |body| - body.dig("data").map do |exchange_rate| - { - date: exchange_rate.dig("date"), - rate: exchange_rate.dig("rates", to) - } + provider_response retries: 1 do + data = paginate( + "#{base_url}/rates/historical-range", + from: from, + to: to, + date_start: start_date.to_s, + date_end: end_date.to_s + ) do |body| + body.dig("data") end - end - ExchangeRatesResponse.new \ - rates: exchange_rates, - success?: true, - raw_response: exchange_rates.to_json - rescue StandardError => error - ExchangeRatesResponse.new \ - success?: false, - error: error, - raw_response: error + ExchangeRate::Provideable::FetchRatesData.new( + rates: data.paginated.map do |exchange_rate| + ExchangeRate.new( + from_currency: from, + to_currency: to, + date: exchange_rate.dig("date"), + rate: exchange_rate.dig("rates", to) + ) + end + ) + end end - def search_securities(query:, dataset: "limited", country_code: nil, exchange_operating_mic: nil) - response = client.get("#{base_url}/tickers/search") do |req| - req.params["name"] = query - req.params["dataset"] = dataset - req.params["country_code"] = country_code if country_code.present? - req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present? - req.params["limit"] = 25 + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + provider_response do + response = client.get("#{base_url}/tickers/search") do |req| + req.params["name"] = symbol + req.params["dataset"] = "limited" + req.params["country_code"] = country_code if country_code.present? + req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present? + req.params["limit"] = 25 + end + + parsed = JSON.parse(response.body) + + Security::Provideable::Search.new( + securities: parsed.dig("data").map do |security| + Security.new( + ticker: security.dig("symbol"), + name: security.dig("name"), + logo_url: security.dig("logo_url"), + exchange_acronym: security.dig("exchange", "acronym"), + exchange_mic: security.dig("exchange", "mic_code"), + exchange_operating_mic: security.dig("exchange", "operating_mic_code"), + country_code: security.dig("exchange", "country_code") + ) + end + ) end + end - parsed = JSON.parse(response.body) + def fetch_security_info(security) + 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? + end - securities = parsed.dig("data").map do |security| - { - ticker: security.dig("symbol"), - name: security.dig("name"), - logo_url: security.dig("logo_url"), - exchange_acronym: security.dig("exchange", "acronym"), - exchange_mic: security.dig("exchange", "mic_code"), - exchange_operating_mic: security.dig("exchange", "operating_mic_code"), - country_code: security.dig("exchange", "country_code") + data = JSON.parse(response.body).dig("data") + + Security::Provideable::SecurityInfo.new( + ticker: security.ticker, + name: data.dig("name"), + links: data.dig("links"), + logo_url: data.dig("logo_url"), + description: data.dig("description"), + kind: data.dig("kind") + ) + end + end + + def fetch_security_price(security, date:) + provider_response do + historical_data = fetch_security_prices(security, start_date: date, end_date: date) + + raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.prices.empty? + + Security::Provideable::PriceData.new( + price: historical_data.data.prices.first + ) + end + end + + def fetch_security_prices(security, start_date:, end_date:) + provider_response retries: 1 do + params = { + start_date: start_date, + end_date: end_date } - end - SearchSecuritiesResponse.new \ - securities: securities, - success?: true, - raw_response: response + params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present? + + data = paginate( + "#{base_url}/tickers/#{security.ticker}/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") + + Security::Provideable::PricesData.new( + prices: data.paginated.map do |price| + Security::Price.new( + security: security, + date: price.dig("date"), + price: price.dig("close") || price.dig("open"), + currency: currency + ) + end + ) + end end - def fetch_security_info(ticker:, mic_code: nil, operating_mic: nil) - response = client.get("#{base_url}/tickers/#{ticker}") do |req| - req.params["mic_code"] = mic_code if mic_code.present? - req.params["operating_mic"] = operating_mic if operating_mic.present? - end - - parsed = JSON.parse(response.body) - - SecurityInfoResponse.new \ - info: parsed.dig("data"), - success?: true, - raw_response: response - end + # ================================ + # Transactions + # ================================ def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil) - params = { - description: description, - amount: amount, - date: date, - city: city, - state: state, - country: country - }.compact + provider_response do + params = { + description: description, + amount: amount, + date: date, + city: city, + state: state, + country: country + }.compact - response = client.get("#{base_url}/enrich", params) + response = client.get("#{base_url}/enrich", params) - parsed = JSON.parse(response.body) + parsed = JSON.parse(response.body) - EnrichTransactionResponse.new \ - info: EnrichTransactionInfo.new( + TransactionEnrichmentData.new( name: parsed.dig("merchant"), icon_url: parsed.dig("icon"), category: parsed.dig("category") - ), - success?: true, - raw_response: response - rescue StandardError => error - EnrichTransactionResponse.new \ - success?: false, - error: error, - raw_response: error + ) + end end private - attr_reader :api_key - ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true - SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true - ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true - UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true - SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true - SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true - EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true - EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true + TransactionEnrichmentData = Data.define(:name, :icon_url, :category) + + def retryable_errors + [ + Faraday::TimeoutError, + Faraday::ConnectionFailed, + Faraday::SSLError, + Faraday::ClientError, + Faraday::ServerError + ] + end def base_url ENV["SYNTH_URL"] || "https://api.synthfinance.com" @@ -227,26 +236,15 @@ class Provider::Synth def client @client ||= Faraday.new(url: base_url) do |faraday| + faraday.response :raise_error faraday.headers["Authorization"] = "Bearer #{api_key}" faraday.headers["X-Source"] = app_name faraday.headers["X-Source-Type"] = app_type end end - def build_error(response) - Provider::Base::ProviderError.new(<<~ERROR) - Failed to fetch data from #{self.class} - Status: #{response.status} - Body: #{response.body.inspect} - ERROR - end - def fetch_page(url, page, params = {}) - client.get(url) do |req| - req.headers["Authorization"] = "Bearer #{api_key}" - params.each { |k, v| req.params[k.to_s] = v.to_s } - req.params["page"] = page - end + client.get(url, params.merge(page: page)) end def paginate(url, params = {}) @@ -254,24 +252,26 @@ class Provider::Synth page = 1 current_page = 0 total_pages = 1 + first_page = nil while current_page < total_pages response = fetch_page(url, page, params) - if response.success? - body = JSON.parse(response.body) - page_results = yield(body) - results.concat(page_results) + body = JSON.parse(response.body) + first_page = body unless first_page + page_results = yield(body) + results.concat(page_results) - current_page = body.dig("paging", "current_page") - total_pages = body.dig("paging", "total_pages") + current_page = body.dig("paging", "current_page") + total_pages = body.dig("paging", "total_pages") - page += 1 - else - raise build_error(response) - end + page += 1 end - results + PaginatedData.new( + paginated: results, + first_page: first_page, + total_pages: total_pages + ) end end diff --git a/app/models/providers.rb b/app/models/providers.rb new file mode 100644 index 00000000..e0cd48ea --- /dev/null +++ b/app/models/providers.rb @@ -0,0 +1,35 @@ +module Providers + module_function + + def synth + api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) + + return nil unless api_key.present? + + Provider::Synth.new(api_key) + end + + def plaid_us + config = Rails.application.config.plaid + + return nil unless config.present? + + Provider::Plaid.new(config, region: :us) + end + + def plaid_eu + config = Rails.application.config.plaid_eu + + return nil unless config.present? + + Provider::Plaid.new(config, region: :eu) + end + + def github + Provider::Github.new + end + + def openai + # TODO: Placeholder for AI chat PR + end +end diff --git a/app/models/security.rb b/app/models/security.rb index 6d94c798..72a09705 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -10,7 +10,7 @@ class Security < ApplicationRecord validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } def current_price - @current_price ||= Security::Price.find_price(security: self, date: Date.current) + @current_price ||= find_or_fetch_price return nil if @current_price.nil? Money.new(@current_price.price, @current_price.currency) end diff --git a/app/models/security/price.rb b/app/models/security/price.rb index 3341a448..4143a0c8 100644 --- a/app/models/security/price.rb +++ b/app/models/security/price.rb @@ -1,33 +1,6 @@ class Security::Price < ApplicationRecord - include Provided - belongs_to :security - validates :price, :currency, presence: true - - class << self - def find_price(security:, date:, cache: true) - result = find_by(security:, date:) - - result || fetch_price_from_provider(security:, date:, cache:) - end - - def find_prices(security:, start_date:, end_date: Date.current, cache: true) - prices = where(security_id: security.id, date: start_date..end_date).to_a - all_dates = (start_date..end_date).to_a.to_set - existing_dates = prices.map(&:date).to_set - missing_dates = (all_dates - existing_dates).sort - - if missing_dates.any? - prices += fetch_prices_from_provider( - security: security, - start_date: missing_dates.first, - end_date: missing_dates.last, - cache: cache - ) - end - - prices - end - end + validates :date, :price, :currency, presence: true + validates :date, uniqueness: { scope: %i[security_id currency] } end diff --git a/app/models/security/price/provided.rb b/app/models/security/price/provided.rb deleted file mode 100644 index c429e0a6..00000000 --- a/app/models/security/price/provided.rb +++ /dev/null @@ -1,65 +0,0 @@ -module Security::Price::Provided - extend ActiveSupport::Concern - - include Synthable - - class_methods do - def provider - synth_client - end - - private - def fetch_price_from_provider(security:, date:, cache: false) - return nil unless provider.present? - return nil unless security.has_prices? - - response = provider.fetch_security_prices \ - ticker: security.ticker, - mic_code: security.exchange_operating_mic, - start_date: date, - end_date: date - - if response.success? && response.prices.size > 0 - price = Security::Price.new \ - security: security, - date: response.prices.first[:date], - price: response.prices.first[:price], - currency: response.prices.first[:currency] - - price.save! if cache - price - else - nil - end - end - - def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false) - return [] unless provider.present? - return [] unless security - return [] unless security.has_prices? - - response = provider.fetch_security_prices \ - ticker: security.ticker, - mic_code: security.exchange_operating_mic, - start_date: start_date, - end_date: end_date - - if response.success? - response.prices.map do |price| - new_price = Security::Price.find_or_initialize_by( - security: security, - date: price[:date] - ) do |p| - p.price = price[:price] - p.currency = price[:currency] - end - - new_price.save! if cache && new_price.new_record? - new_price - end - else - [] - end - end - end -end diff --git a/app/models/security/provideable.rb b/app/models/security/provideable.rb new file mode 100644 index 00000000..2227e19f --- /dev/null +++ b/app/models/security/provideable.rb @@ -0,0 +1,31 @@ +module Security::Provideable + extend ActiveSupport::Concern + + Search = Data.define(:securities) + PriceData = Data.define(:price) + PricesData = Data.define(:prices) + SecurityInfo = Data.define( + :ticker, + :name, + :links, + :logo_url, + :description, + :kind, + ) + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + raise NotImplementedError, "Subclasses must implement #search_securities" + end + + def fetch_security_info(security) + raise NotImplementedError, "Subclasses must implement #fetch_security_info" + end + + def fetch_security_price(security, date:) + raise NotImplementedError, "Subclasses must implement #fetch_security_price" + end + + def fetch_security_prices(security, start_date:, end_date:) + raise NotImplementedError, "Subclasses must implement #fetch_security_prices" + end +end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index c7e38fb5..4ef0f735 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -1,28 +1,65 @@ module Security::Provided extend ActiveSupport::Concern - include Synthable - class_methods do def provider - synth_client + Providers.synth end - def search_provider(query) - return [] if query[:search].blank? || query[:search].length < 2 + def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) + return [] if symbol.blank? || symbol.length < 2 - response = provider.search_securities( - query: query[:search], - dataset: "limited", - country_code: query[:country], - exchange_operating_mic: query[:exchange_operating_mic] - ) + response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic) if response.success? - response.securities.map { |attrs| new(**attrs) } + response.data.securities else [] end end end + + def sync_provider_prices(start_date:, end_date: Date.current) + unless has_prices? + Rails.logger.warn("Security id=#{id} ticker=#{ticker} is not known by provider, skipping price sync") + return 0 + end + + unless provider.present? + Rails.logger.warn("No security provider configured, cannot sync prices for id=#{id} ticker=#{ticker}") + return 0 + end + + response = provider.fetch_security_prices(self, start_date: start_date, end_date: end_date) + + unless response.success? + Rails.logger.error("Provider error for sync_provider_prices with id=#{id} ticker=#{ticker}: #{response.error}") + return 0 + end + + fetched_prices = response.data.prices.map do |price| + price.attributes.slice("security_id", "date", "price", "currency") + end + + Security::Price.upsert_all(fetched_prices, unique_by: %i[security_id date currency]) + end + + def find_or_fetch_price(date: Date.current, cache: true) + price = prices.find_by(date: date) + + return price if price.present? + + response = provider.fetch_security_price(self, date: date) + + return nil unless response.success? # Provider error + + price = response.data.price + price.save! if cache + price + end + + private + def provider + self.class.provider + end end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index 4bbffdaa..b4d464d1 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -97,7 +97,7 @@ class TradeImport < Import provider_security = @provider_securities_cache[cache_key] ||= begin Security.search_provider( - query: ticker, + ticker, exchange_operating_mic: exchange_operating_mic ).first end diff --git a/app/models/upgrader/provided.rb b/app/models/upgrader/provided.rb index fc1e65b7..c0eac5e0 100644 --- a/app/models/upgrader/provided.rb +++ b/app/models/upgrader/provided.rb @@ -4,11 +4,7 @@ module Upgrader::Provided class_methods do private def fetch_latest_upgrade_candidates_from_provider - git_repository_provider.fetch_latest_upgrade_candidates - end - - def git_repository_provider - Provider::Github.new + Providers.github.fetch_latest_upgrade_candidates end end end diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index 89c8c151..25f4cf0a 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -37,7 +37,7 @@ required: true %> <% else %> - <%= form.text_field :manual_ticker, label: "Ticker", placeholder: "AAPL", required: true %> + <%= form.text_field :manual_ticker, label: "Ticker symbol", placeholder: "AAPL", required: true %> <% end %> <% end %> diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index a037416a..80ad5a07 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -17,13 +17,6 @@

<% else %> <%= link_to account.name, account, class: [(account.is_active ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> - <% if account.has_issues? %> -
- <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> - <%= tag.span t(".has_issues") %> - <%= link_to t(".troubleshoot"), issue_path(account.issues.first), class: "underline", data: { turbo_frame: :drawer } %> -
- <% end %> <% end %> diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index bb409492..2acbf65f 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,6 +1,6 @@ <%# locals: (family:) %> -<% if family.requires_data_provider? && family.synth_client.nil? %> +<% if family.requires_data_provider? && Providers.synth.nil? %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 8dbaf273..6d822711 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -35,8 +35,4 @@ <%= render "accounts/show/menu", account: account %>
- - <% if account.highest_priority_issue %> - <%= render partial: "issues/issue", locals: { issue: account.highest_priority_issue } %> - <% end %> diff --git a/app/views/issue/_request_synth_data_action.html.erb b/app/views/issue/_request_synth_data_action.html.erb deleted file mode 100644 index a805524b..00000000 --- a/app/views/issue/_request_synth_data_action.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -

The Synth data provider could not find the requested data.

- -

We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by - requesting the data you need.

- -

Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the - following information:

- -
    -
  • What type of data is missing?
  • -
  • Any other information you think might be helpful
  • -
diff --git a/app/views/issue/exchange_rate_provider_missings/show.html.erb b/app/views/issue/exchange_rate_provider_missings/show.html.erb deleted file mode 100644 index a00cef56..00000000 --- a/app/views/issue/exchange_rate_provider_missings/show.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

You have set your family currency preference to <%= Current.family.currency %>. <%= @issue.issuable.name %> has - entries in another currency, which means we have to fetch exchange rates from a data provider to accurately show - historical results.

- -

We have detected that your exchange rates provider is not configured yet.

-<% end %> - -<%= content_for :action do %> - <% if self_hosted? %> -

To fix this issue, you need to provide an API key for your exchange rate provider.

- -

Currently, we support <%= link_to "Synth Finance", "https://synthfinance.com", target: "_blank" %>, so you need - to - get a free API key from the link provided.

- -

Once you have your API key, paste it below to configure it.

- - <%= styled_form_with model: @issue, url: issue_exchange_rate_provider_missing_path(@issue), method: :patch, class: "space-y-3" do |form| %> - <%= form.text_field :synth_api_key, label: "Synth API Key", placeholder: "Synth API Key", type: "password", class: "w-full", value: Setting.synth_api_key %> - <%= form.submit "Save and Re-Sync Account", class: "btn-primary" %> - <% end %> - <% else %> -

Please contact the Maybe team.

- <% end %> -<% end %> diff --git a/app/views/issue/exchange_rates_missings/show.html.erb b/app/views/issue/exchange_rates_missings/show.html.erb deleted file mode 100644 index 65c624ed..00000000 --- a/app/views/issue/exchange_rates_missings/show.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

Some exchange rates are missing for this account.

- -
<%= JSON.pretty_generate(@issue.data) %>
-<% end %> - -<%= content_for :action do %> - <%= render "issue/request_synth_data_action" %> -<% end %> diff --git a/app/views/issue/prices_missings/show.html.erb b/app/views/issue/prices_missings/show.html.erb deleted file mode 100644 index c71e7bff..00000000 --- a/app/views/issue/prices_missings/show.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

Some stock prices are missing for this account.

- -
<%= JSON.pretty_generate(@issue.data) %>
-<% end %> - -<%= content_for :action do %> - <%= render "issue/request_synth_data_action" %> -<% end %> diff --git a/app/views/issue/unknowns/show.html.erb b/app/views/issue/unknowns/show.html.erb deleted file mode 100644 index a6ba2084..00000000 --- a/app/views/issue/unknowns/show.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

An unknown issue has occurred.

- -
<%= JSON.pretty_generate(@issue.data || "No data provided for this issue") %>
-<% end %> - -<%= content_for :action do %> -

There is no fix for this issue yet.

- -

Maybe is in active development and we value your feedback. There are a couple ways you can report this issue to - help us make Maybe better:

- -
    -
  • Post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %>
  • -
  • Open an issue on - our <%= link_to "Github repository", "https://github.com/maybe-finance/maybe/issues", target: "_blank" %>
  • -
- -

If there is data shown in the code block above that you think might be helpful, please include it in your - report.

-<% end %> diff --git a/app/views/issues/_issue.html.erb b/app/views/issues/_issue.html.erb deleted file mode 100644 index 76ee7c62..00000000 --- a/app/views/issues/_issue.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<%# locals: (issue:) %> - -<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %> -<% text_class = issue.critical? || issue.error? ? "text-error" : "text-warning" %> - -<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %> -
- <%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %> -

<%= issue.title %>

-
- -
- <%= link_to "Troubleshoot", issue_path(issue), class: "#{text_class} font-medium hover:underline", data: { turbo_frame: :drawer } %> -
-<% end %> diff --git a/app/views/layouts/issues.html.erb b/app/views/layouts/issues.html.erb deleted file mode 100644 index 4ae03b85..00000000 --- a/app/views/layouts/issues.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<%= render "layouts/shared/htmldoc" do %> - <%= drawer do %> -
- <%= tag.h2 do %> - <%= yield :title %> - <% end %> - - <%= tag.h3 "Issue Description" %> - <%= yield :description %> - - <%= tag.h3 "How to fix this issue" %> - <%= yield :action %> -
- <% end %> -<% end %> diff --git a/app/views/settings/hostings/_synth_settings.html.erb b/app/views/settings/hostings/_synth_settings.html.erb index 4ee9c182..d8f71839 100644 --- a/app/views/settings/hostings/_synth_settings.html.erb +++ b/app/views/settings/hostings/_synth_settings.html.erb @@ -30,18 +30,18 @@

<%= t(".api_calls_used", - used: number_with_delimiter(@synth_usage.used), - limit: number_with_delimiter(@synth_usage.limit), - percentage: number_to_percentage(@synth_usage.utilization, precision: 1)) %> + used: number_with_delimiter(@synth_usage.data.used), + limit: number_with_delimiter(@synth_usage.data.limit), + percentage: number_to_percentage(@synth_usage.data.utilization, precision: 1)) %>

+ style="width: <%= [@synth_usage.data.utilization, 2].max %>%;">

- <%= t(".plan", plan: @synth_usage.plan) %> + <%= t(".plan", plan: @synth_usage.data.plan) %>

diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 65697755..0040f4b9 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,28 +1,5 @@ { "ignored_warnings": [ - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "03a2010b605b8bdb7d4e1566720904d69ef2fbf8e7bc35735b84e161b475215e", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/controllers/issues_controller.rb", - "line": 5, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(template => \"#{Current.family.issues.find(params[:id]).class.name.underscore.pluralize}/show\", { :layout => \"issues\" })", - "render_path": null, - "location": { - "type": "method", - "class": "IssuesController", - "method": "show" - }, - "user_input": "params[:id]", - "confidence": "Weak", - "cwe_id": [ - 22 - ], - "note": "" - }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -30,7 +7,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/concerns/entryable_resource.rb", - "line": 122, + "line": 124, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.require(:account_entry).permit(:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)", "render_path": null, @@ -53,7 +30,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/invitations_controller.rb", - "line": 40, + "line": 58, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.require(:invitation).permit(:email, :role)", "render_path": null, @@ -76,7 +53,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/pages/changelog.html.erb", - "line": 22, + "line": 18, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Provider::Github.new.fetch_latest_release_notes[:body]", "render_path": [ @@ -84,7 +61,7 @@ "type": "controller", "class": "PagesController", "method": "changelog", - "line": 35, + "line": 15, "file": "app/controllers/pages_controller.rb", "rendered": { "name": "pages/changelog", @@ -134,7 +111,7 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/import/configurations/show.html.erb", - "line": 15, + "line": 34, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })", "render_path": [ diff --git a/config/locales/models/issue/en.yml b/config/locales/models/issue/en.yml deleted file mode 100644 index 9c0938b4..00000000 --- a/config/locales/models/issue/en.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -en: - activerecord: - models: - issue/exchange_rate_provider_missing: Exchange rate provider missing - issue/exchange_rates_missing: Exchange rates missing - issue/missing_prices: Missing prices - issue/unknown: Unknown issue occurred diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index ecfe68a1..f557755e 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -2,7 +2,6 @@ en: accounts: account: - has_issues: Issue detected. troubleshoot: Troubleshoot chart: no_change: no change diff --git a/config/routes.rb b/config/routes.rb index c714a3ef..da875dce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,12 +154,6 @@ Rails.application.routes.draw do resources :invite_codes, only: %i[index create] - resources :issues, only: :show - - namespace :issue do - resources :exchange_rate_provider_missings, only: :update - end - resources :invitations, only: [ :new, :create, :destroy ] do get :accept, on: :member end diff --git a/db/migrate/20250315191233_remove_ticker_from_security_prices.rb b/db/migrate/20250315191233_remove_ticker_from_security_prices.rb new file mode 100644 index 00000000..6fd33f06 --- /dev/null +++ b/db/migrate/20250315191233_remove_ticker_from_security_prices.rb @@ -0,0 +1,5 @@ +class RemoveTickerFromSecurityPrices < ActiveRecord::Migration[7.2] + def change + remove_column :security_prices, :ticker + end +end diff --git a/db/migrate/20250316103753_remove_issues.rb b/db/migrate/20250316103753_remove_issues.rb new file mode 100644 index 00000000..31ae4179 --- /dev/null +++ b/db/migrate/20250316103753_remove_issues.rb @@ -0,0 +1,11 @@ +class RemoveIssues < ActiveRecord::Migration[7.2] + def change + drop_table :issues do |t| + t.references :issuable, polymorphic: true, null: false + t.string :type, null: false + t.integer :severity, null: false + t.datetime :last_observed_at + t.datetime :resolved_at + end + end +end diff --git a/db/migrate/20250316122019_security_price_unique_index.rb b/db/migrate/20250316122019_security_price_unique_index.rb new file mode 100644 index 00000000..4a8dea64 --- /dev/null +++ b/db/migrate/20250316122019_security_price_unique_index.rb @@ -0,0 +1,31 @@ +class SecurityPriceUniqueIndex < ActiveRecord::Migration[7.2] + def change + # First, we have to delete duplicate prices from DB so we can apply the unique index + reversible do |dir| + dir.up do + execute <<~SQL + DELETE FROM security_prices + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY security_id, date, currency + ORDER BY updated_at DESC, id DESC + ) as row_num + FROM security_prices + ) as duplicates + WHERE row_num > 1 + ); + SQL + end + end + + add_index :security_prices, [ :security_id, :date, :currency ], unique: true + change_column_null :security_prices, :date, false + change_column_null :security_prices, :price, false + change_column_null :security_prices, :currency, false + + change_column_null :exchange_rates, :date, false + change_column_null :exchange_rates, :rate, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 59eabedf..abbaacf5 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_03_04_140435) do +ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -219,8 +219,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "from_currency", null: false t.string "to_currency", null: false - t.decimal "rate" - t.date "date" + t.decimal "rate", null: false + t.date "date", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true @@ -449,19 +449,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do t.index ["token"], name: "index_invite_codes_on_token", unique: true end - create_table "issues", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "issuable_type" - t.uuid "issuable_id" - t.string "type" - t.integer "severity" - t.datetime "last_observed_at" - t.datetime "resolved_at" - t.jsonb "data" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["issuable_type", "issuable_id"], name: "index_issues_on_issuable" - end - create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -560,13 +547,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ticker" - t.date "date" - t.decimal "price", precision: 19, scale: 4 - t.string "currency", default: "USD" + t.date "date", null: false + t.decimal "price", precision: 19, scale: 4, null: false + t.string "currency", default: "USD", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "security_id" + t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end diff --git a/lib/money.rb b/lib/money.rb index a00fc836..ed1e00d4 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -46,7 +46,7 @@ class Money if iso_code == other_iso_code self else - exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate + exchange_rate = store.find_or_fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate diff --git a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb deleted file mode 100644 index 6b884014..00000000 --- a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "test_helper" - -class Issue::ExchangeRateProviderMissingsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - @issue = issues(:one) - end - - test "should update issue" do - patch issue_exchange_rate_provider_missing_url(@issue), params: { - issue_exchange_rate_provider_missing: { - synth_api_key: "1234" - } - } - - assert_enqueued_with job: SyncJob - assert_redirected_to @issue.issuable - end -end diff --git a/test/controllers/issues_controller_test.rb b/test/controllers/issues_controller_test.rb deleted file mode 100644 index d18970b7..00000000 --- a/test/controllers/issues_controller_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "test_helper" - -class IssuesControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - end - - test "should get show polymorphically" do - issues.each do |issue| - get issue_url(issue) - - assert_response :success - assert_dom "h2", text: issue.title - assert_dom "h3", text: "Issue Description" - assert_dom "h3", text: "How to fix this issue" - end - end -end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 3ee8a226..2e092952 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -1,8 +1,22 @@ require "test_helper" +require "ostruct" class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest + include ProviderTestHelper + setup do sign_in users(:family_admin) + + @provider = mock + Providers.stubs(:synth).returns(@provider) + @usage_response = provider_success_response( + OpenStruct.new( + used: 10, + limit: 100, + utilization: 10, + plan: "free", + ) + ) end test "cannot edit when self hosting is disabled" do @@ -16,6 +30,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "should get edit when self hosting is enabled" do + @provider.expects(:usage).returns(@usage_response) + with_self_hosting do get settings_hosting_url assert_response :success diff --git a/test/fixtures/issues.yml b/test/fixtures/issues.yml deleted file mode 100644 index 23ccd35d..00000000 --- a/test/fixtures/issues.yml +++ /dev/null @@ -1,5 +0,0 @@ -one: - issuable: depository - issuable_type: Account - type: Issue::Unknown - last_observed_at: 2024-08-15 08:54:04 diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index d45e546e..748c66f0 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -3,20 +3,34 @@ require "test_helper" module ExchangeRateProviderInterfaceTest extend ActiveSupport::Testing::Declarative - test "exchange rate provider interface" do - assert_respond_to @subject, :healthy? - assert_respond_to @subject, :fetch_exchange_rate - assert_respond_to @subject, :fetch_exchange_rates - end + test "fetches single exchange rate" do + VCR.use_cassette("#{vcr_key_prefix}/exchange_rate") do + response = @subject.fetch_exchange_rate( + from: "USD", + to: "GBP", + date: Date.parse("01.01.2024") + ) - test "exchange rate provider response contract" do - VCR.use_cassette "synth/exchange_rate" do - response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") + rate = response.data.rate - assert_respond_to response, :rate - assert_respond_to response, :success? - assert_respond_to response, :error - assert_respond_to response, :raw_response + assert_kind_of ExchangeRate, rate + assert_equal "USD", rate.from_currency + assert_equal "GBP", rate.to_currency end end + + test "fetches paginated exchange_rate historical data" do + VCR.use_cassette("#{vcr_key_prefix}/exchange_rates") do + response = @subject.fetch_exchange_rates( + from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024") + ) + + assert 213, response.data.rates.count # 213 days between 01.01.2024 and 31.07.2024 + end + end + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end end diff --git a/test/interfaces/security_price_provider_interface_test.rb b/test/interfaces/security_price_provider_interface_test.rb deleted file mode 100644 index 484d25fa..00000000 --- a/test/interfaces/security_price_provider_interface_test.rb +++ /dev/null @@ -1,26 +0,0 @@ -require "test_helper" - -module SecurityPriceProviderInterfaceTest - extend ActiveSupport::Testing::Declarative - - test "security price provider interface" do - assert_respond_to @subject, :healthy? - assert_respond_to @subject, :fetch_security_prices - end - - test "security price provider response contract" do - VCR.use_cassette "synth/security_prices" do - response = @subject.fetch_security_prices( - ticker: "AAPL", - mic_code: "XNAS", - start_date: Date.iso8601("2024-01-01"), - end_date: Date.iso8601("2024-08-01") - ) - - assert_respond_to response, :prices - assert_respond_to response, :success? - assert_respond_to response, :error - assert_respond_to response, :raw_response - end - end -end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb new file mode 100644 index 00000000..b22a6a10 --- /dev/null +++ b/test/interfaces/security_provider_interface_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +module SecurityProviderInterfaceTest + extend ActiveSupport::Testing::Declarative + + test "fetches security price" do + aapl = securities(:aapl) + + VCR.use_cassette("#{vcr_key_prefix}/security_price") do + response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + assert response.success? + assert response.data.price.present? + end + end + + test "fetches paginated securities prices" do + aapl = securities(:aapl) + + VCR.use_cassette("#{vcr_key_prefix}/security_prices") do + response = @subject.fetch_security_prices( + aapl, + start_date: Date.iso8601("2024-01-01"), + end_date: Date.iso8601("2024-08-01") + ) + + assert response.success? + assert 213, response.data.prices.count + end + end + + test "searches securities" do + VCR.use_cassette("#{vcr_key_prefix}/security_search") do + response = @subject.search_securities("AAPL", country_code: "US") + securities = response.data.securities + + assert securities.any? + security = securities.first + assert_kind_of Security, security + assert_equal "AAPL", security.ticker + end + end + + test "fetches security info" do + aapl = securities(:aapl) + + VCR.use_cassette("#{vcr_key_prefix}/security_info") do + response = @subject.fetch_security_info(aapl) + info = response.data + + assert_equal "AAPL", info.ticker + assert_equal "Apple Inc.", info.name + assert info.logo_url.present? + assert_equal "common stock", info.kind + assert info.description.present? + end + end + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end +end diff --git a/test/lib/money_test.rb b/test/lib/money_test.rb index 60d04284..699a1ab3 100644 --- a/test/lib/money_test.rb +++ b/test/lib/money_test.rb @@ -91,13 +91,13 @@ class MoneyTest < ActiveSupport::TestCase end test "converts currency when rate available" do - ExchangeRate.expects(:find_rate).returns(OpenStruct.new(rate: 1.2)) + ExchangeRate.expects(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1.2)) assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur) end test "raises when no conversion rate available and no fallback rate provided" do - ExchangeRate.expects(:find_rate).returns(nil) + ExchangeRate.expects(:find_or_fetch_rate).returns(nil) assert_raises Money::ConversionError do Money.new(1000).exchange_to(:jpy) @@ -105,7 +105,7 @@ class MoneyTest < ActiveSupport::TestCase end test "converts currency with a fallback rate" do - ExchangeRate.expects(:find_rate).returns(nil).twice + ExchangeRate.expects(:find_or_fetch_rate).returns(nil).twice assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0) assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1) diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb index a142f881..8fb739c6 100644 --- a/test/models/account/convertible_test.rb +++ b/test/models/account/convertible_test.rb @@ -2,7 +2,7 @@ require "test_helper" require "ostruct" class Account::ConvertibleTest < ActiveSupport::TestCase - include Account::EntriesTestHelper + include Account::EntriesTestHelper, ProviderTestHelper setup do @family = families(:empty) @@ -16,33 +16,28 @@ class Account::ConvertibleTest < ActiveSupport::TestCase end test "syncs required exchange rates for an account" do - create_valuation(account: @account, date: 5.days.ago.to_date, amount: 9500, currency: "EUR") + create_valuation(account: @account, date: 1.day.ago.to_date, amount: 9500, currency: "EUR") - # Since we had a valuation 5 days ago, this account starts 6 days ago and needs daily exchange rates looking forward - assert_equal 6.days.ago.to_date, @account.start_date + # 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( + ExchangeRate::Provideable::FetchRatesData.new( + rates: [ + ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1), + ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 1.day.ago.to_date, rate: 1.2), + ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.3) + ] + ) + ) @provider.expects(:fetch_exchange_rates) - .with( - from: "EUR", - to: "USD", - start_date: 6.days.ago.to_date, - end_date: Date.current - ).returns( - OpenStruct.new( - success?: true, - rates: [ - OpenStruct.new(date: 6.days.ago.to_date, rate: 1.1), - OpenStruct.new(date: 5.days.ago.to_date, rate: 1.2), - OpenStruct.new(date: 4.days.ago.to_date, rate: 1.3), - OpenStruct.new(date: 3.days.ago.to_date, rate: 1.4), - OpenStruct.new(date: 2.days.ago.to_date, rate: 1.5), - OpenStruct.new(date: 1.day.ago.to_date, rate: 1.6), - OpenStruct.new(date: Date.current, rate: 1.7) - ] - ) - ) + .with(from: "EUR", to: "USD", start_date: 2.days.ago.to_date, end_date: Date.current) + .returns(provider_response) - assert_difference "ExchangeRate.count", 7 do + assert_difference "ExchangeRate.count", 3 do @account.sync_required_exchange_rates end end diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb index b973fa00..bebc66c2 100644 --- a/test/models/account/holding/portfolio_cache_test.rb +++ b/test/models/account/holding/portfolio_cache_test.rb @@ -1,63 +1,93 @@ require "test_helper" class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase - include Account::EntriesTestHelper + include Account::EntriesTestHelper, ProviderTestHelper setup do - # Prices, highest to lowest priority - @db_price = 210 - @provider_price = 220 - @trade_price = 200 - @holding_price = 250 + @provider = mock + Security.stubs(:provider).returns(@provider) - @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 10000, currency: "USD", accountable: Investment.new) - @test_security = Security.create!(name: "Test Security", ticker: "TEST") + @account = families(:empty).accounts.create!( + name: "Test Brokerage", + balance: 10000, + currency: "USD", + accountable: Investment.new + ) - @trade = create_trade(@test_security, account: @account, qty: 1, date: Date.current, price: @trade_price) - @holding = Account::Holding.create!(security: @test_security, account: @account, date: Date.current, qty: 1, price: @holding_price, amount: @holding_price, currency: "USD") - Security::Price.create!(security: @test_security, date: Date.current, price: @db_price) + @security = Security.create!(name: "Test Security", ticker: "TEST", exchange_operating_mic: "TEST") + + @trade = create_trade(@security, account: @account, qty: 1, date: 2.days.ago.to_date, price: 210.23).account_trade end test "gets price from DB if available" do - cache = Account::Holding::PortfolioCache.new(@account) + db_price = 210 - assert_equal @db_price, cache.get_price(@test_security.id, Date.current).price + Security::Price.create!( + security: @security, + date: Date.current, + price: db_price + ) + + expect_provider_prices([], start_date: @account.start_date) + + cache = Account::Holding::PortfolioCache.new(@account) + assert_equal db_price, cache.get_price(@security.id, Date.current).price end test "if no price in DB, try fetching from provider" do - Security::Price.destroy_all - Security::Price.expects(:find_prices) - .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) - .returns([ - Security::Price.new(security: @test_security, date: Date.current, price: @provider_price, currency: "USD") - ]) + Security::Price.delete_all + + provider_price = Security::Price.new( + security: @security, + date: Date.current, + price: 220, + currency: "USD" + ) + + expect_provider_prices([ provider_price ], start_date: @account.start_date) cache = Account::Holding::PortfolioCache.new(@account) - - assert_equal @provider_price, cache.get_price(@test_security.id, Date.current).price + assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price end test "if no price from db or provider, try getting the price from trades" do - Security::Price.destroy_all # No DB prices - Security::Price.expects(:find_prices) - .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) - .returns([]) # No provider prices + Security::Price.destroy_all + expect_provider_prices([], start_date: @account.start_date) cache = Account::Holding::PortfolioCache.new(@account) - - assert_equal @trade_price, cache.get_price(@test_security.id, Date.current).price + assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price end test "if no price from db, provider, or trades, search holdings" do - Security::Price.destroy_all # No DB prices - Security::Price.expects(:find_prices) - .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) - .returns([]) # No provider prices + Security::Price.delete_all + Account::Entry.delete_all - @account.entries.destroy_all # No prices from trades + holding = Account::Holding.create!( + security: @security, + account: @account, + date: Date.current, + qty: 1, + price: 250, + amount: 250 * 1, + currency: "USD" + ) + + expect_provider_prices([], start_date: @account.start_date) cache = Account::Holding::PortfolioCache.new(@account, use_holdings: true) - - assert_equal @holding_price, cache.get_price(@test_security.id, Date.current).price + assert_equal holding.price, cache.get_price(@security.id, holding.date).price end + + private + def expect_provider_prices(prices, start_date:, end_date: Date.current) + @provider.expects(:fetch_security_prices) + .with(@security, start_date: start_date, end_date: end_date) + .returns( + provider_success_response( + Security::Provideable::PricesData.new( + prices: prices + ) + ) + ) + end end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 7bc7ba61..720162f4 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -2,116 +2,99 @@ require "test_helper" require "ostruct" class ExchangeRateTest < ActiveSupport::TestCase + include ProviderTestHelper + setup do @provider = mock ExchangeRate.stubs(:provider).returns(@provider) end - test "exchange rate provider nil if no api key configured" do - ExchangeRate.unstub(:provider) + test "finds rate in DB" do + existing_rate = exchange_rates(:one) - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_not ExchangeRate.provider - end - end - - test "finds single rate in DB" do @provider.expects(:fetch_exchange_rate).never - rate = exchange_rates(:one) - - assert_equal rate, ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date) + assert_equal existing_rate, ExchangeRate.find_or_fetch_rate( + from: existing_rate.from_currency, + to: existing_rate.to_currency, + date: existing_rate.date + ) end - test "finds single rate from provider and caches to DB" do - expected_rate = 1.21 - @provider.expects(:fetch_exchange_rate).once.returns(OpenStruct.new(success?: true, rate: expected_rate)) + test "fetches rate from provider without cache" do + ExchangeRate.delete_all - fetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true) - refetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true) + provider_response = provider_success_response( + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new( + from_currency: "USD", + to_currency: "EUR", + date: Date.current, + rate: 1.2 + ) + ) + ) - assert_equal expected_rate, fetched_rate.rate - assert_equal expected_rate, refetched_rate.rate - end + @provider.expects(:fetch_exchange_rate).returns(provider_response) - test "nil if rate is not found in DB and provider throws an error" do - @provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false)) - - assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) - end - - test "nil if rate is not found in DB and provider is disabled" do - ExchangeRate.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) + assert_no_difference "ExchangeRate.count" do + assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: false).rate end end - test "finds multiple rates in DB" do - @provider.expects(:fetch_exchange_rate).never + test "fetches rate from provider with cache" do + ExchangeRate.delete_all - rate1 = exchange_rates(:one) # EUR -> GBP, today - rate2 = exchange_rates(:two) # EUR -> GBP, yesterday - - fetched_rates = ExchangeRate.find_rates(from: rate1.from_currency, to: rate1.to_currency, start_date: 1.day.ago.to_date).sort_by(&:date) - - assert_equal rate1, fetched_rates[1] - assert_equal rate2, fetched_rates[0] - end - - test "finds multiple rates from provider and caches to DB" do - @provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, end_date: Date.current) - .returns( - OpenStruct.new( - rates: [ - OpenStruct.new(date: 1.day.ago.to_date, rate: 1.1), - OpenStruct.new(date: Date.current, rate: 1.2) - ], - success?: true + provider_response = provider_success_response( + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new( + from_currency: "USD", + to_currency: "EUR", + date: Date.current, + rate: 1.2 ) - ).once + ) + ) - fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true) - refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date) + @provider.expects(:fetch_exchange_rate).returns(provider_response) - assert_equal [ 1.1, 1.2 ], fetched_rates.sort_by(&:date).map(&:rate) - assert_equal [ 1.1, 1.2 ], refetched_rates.sort_by(&:date).map(&:rate) - end - - test "finds missing db rates from provider and appends to results" do - @provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date) - .returns( - OpenStruct.new( - rates: [ - OpenStruct.new(date: 2.day.ago.to_date, rate: 1.1) - ], - success?: true - ) - ).once - - rate1 = exchange_rates(:one) # EUR -> GBP, today - rate2 = exchange_rates(:two) # EUR -> GBP, yesterday - - fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, cache: true) - refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date) - - assert_equal [ 1.1, rate2.rate, rate1.rate ], fetched_rates.sort_by(&:date).map(&:rate) - assert_equal [ 1.1, rate2.rate, rate1.rate ], refetched_rates.sort_by(&:date).map(&:rate) - end - - test "returns empty array if no rates found in DB or provider" do - ExchangeRate.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date) + assert_difference "ExchangeRate.count", 1 do + assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true).rate end end + + test "returns nil on provider error" do + provider_response = provider_error_response(Provider::ProviderError.new("Test error")) + + @provider.expects(:fetch_exchange_rate).returns(provider_response) + + 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( + ExchangeRate::Provideable::FetchRatesData.new( + rates: [ + ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 1.3), + ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 1.4), + ExchangeRate.new(from_currency: "USD", to_currency: "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/provider/synth_test.rb b/test/models/provider/synth_test.rb index fdca07c3..b489d136 100644 --- a/test/models/provider/synth_test.rb +++ b/test/models/provider/synth_test.rb @@ -2,55 +2,42 @@ require "test_helper" require "ostruct" class Provider::SynthTest < ActiveSupport::TestCase - include ExchangeRateProviderInterfaceTest, SecurityPriceProviderInterfaceTest + include ExchangeRateProviderInterfaceTest, SecurityProviderInterfaceTest setup do @subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"]) end - test "fetches paginated securities prices" do - VCR.use_cassette("synth/security_prices") do - response = @synth.fetch_security_prices( - ticker: "AAPL", - mic_code: "XNAS", - start_date: Date.iso8601("2024-01-01"), - end_date: Date.iso8601("2024-08-01") - ) - - assert 213, response.size + test "health check" do + VCR.use_cassette("synth/health") do + assert @synth.healthy? end end - test "fetches paginated exchange_rate historical data" do - VCR.use_cassette("synth/exchange_rate_historical") do - response = @synth.fetch_exchange_rates( - from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024") - ) - - assert 213, response.rates.size # 213 days between 01.01.2024 and 31.07.2024 - assert_equal [ :date, :rate ], response.rates.first.keys + test "usage info" do + VCR.use_cassette("synth/usage") do + usage = @synth.usage.data + assert usage.used.present? + assert usage.limit.present? + assert usage.utilization.present? + assert usage.plan.present? end end - test "retries then provides failed response" do - @client = mock - Faraday.stubs(:new).returns(@client) + test "enriches transaction" do + VCR.use_cassette("synth/transaction_enrich") do + response = @synth.enrich_transaction( + "UBER EATS", + amount: 25.50, + date: Date.iso8601("2025-03-16"), + city: "San Francisco", + state: "CA", + country: "US" + ) - @client.expects(:get).returns(OpenStruct.new(success?: false)).times(3) - - response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") - - assert_match "Failed to fetch data from Provider::Synth", response.error.message - end - - test "retrying, then raising on network error" do - @client = mock - Faraday.stubs(:new).returns(@client) - - @client.expects(:get).raises(Faraday::TimeoutError).times(3) - - assert_raises Faraday::TimeoutError do - @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") + data = response.data + assert data.name.present? + assert data.category.present? end end end diff --git a/test/models/provider_test.rb b/test/models/provider_test.rb new file mode 100644 index 00000000..afa770e4 --- /dev/null +++ b/test/models/provider_test.rb @@ -0,0 +1,61 @@ +require "test_helper" +require "ostruct" + +class TestProvider < Provider + def fetch_data + provider_response(retries: 3) do + client.get("/test") + end + end + + private + def client + @client ||= Faraday.new + end + + def retryable_errors + [ Faraday::TimeoutError ] + end +end + +class ProviderTest < ActiveSupport::TestCase + setup do + @provider = TestProvider.new + end + + test "retries then provides failed response" do + client = mock + Faraday.stubs(:new).returns(client) + + client.expects(:get) + .with("/test") + .raises(Faraday::TimeoutError) + .times(3) + + response = @provider.fetch_data + + assert_not response.success? + assert_match "timeout", response.error.message + end + + test "fail, retry, succeed" do + client = mock + Faraday.stubs(:new).returns(client) + + sequence = sequence("retry_sequence") + + client.expects(:get) + .with("/test") + .raises(Faraday::TimeoutError) + .in_sequence(sequence) + + client.expects(:get) + .with("/test") + .returns(Provider::ProviderResponse.new(success?: true, data: "success", error: nil)) + .in_sequence(sequence) + + response = @provider.fetch_data + + assert response.success? + end +end diff --git a/test/models/providers_test.rb b/test/models/providers_test.rb new file mode 100644 index 00000000..d7851cd8 --- /dev/null +++ b/test/models/providers_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class ProvidersTest < ActiveSupport::TestCase + test "synth configured with ENV" do + Setting.stubs(:synth_api_key).returns(nil) + + with_env_overrides SYNTH_API_KEY: "123" do + assert_instance_of Provider::Synth, Providers.synth + end + end + + test "synth configured with Setting" do + Setting.stubs(:synth_api_key).returns("123") + + with_env_overrides SYNTH_API_KEY: nil do + assert_instance_of Provider::Synth, Providers.synth + end + end + + test "synth not configured" do + Setting.stubs(:synth_api_key).returns(nil) + + with_env_overrides SYNTH_API_KEY: nil do + assert_nil Providers.synth + end + end +end diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index 32dd00f3..84412c29 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -2,120 +2,82 @@ require "test_helper" require "ostruct" class Security::PriceTest < ActiveSupport::TestCase + include ProviderTestHelper + setup do @provider = mock + Security.stubs(:provider).returns(@provider) - Security::Price.stubs(:provider).returns(@provider) - end - - test "security price provider nil if no api key provided" do - Security::Price.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_not Security::Price.provider - end + @security = securities(:aapl) end test "finds single security price in DB" do - @provider.expects(:fetch_security_prices).never - security = securities(:aapl) - + @provider.expects(:fetch_security_price).never price = security_prices(:one) - assert_equal price, Security::Price.find_price(security: security, date: price.date) + assert_equal price, @security.find_or_fetch_price(date: price.date) end - test "caches prices to DB" do - expected_price = 314.34 - security = securities(:aapl) - tomorrow = Date.current + 1.day + test "caches prices from provider to DB" do + price_date = 10.days.ago.to_date - @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: tomorrow, end_date: tomorrow) - .once - .returns( - OpenStruct.new( - success?: true, - prices: [ { date: tomorrow, price: expected_price, currency: "USD" } ] - ) - ) + expected_price = Security::Price.new( + security: @security, + date: price_date, + price: 314.34, + currency: "USD" + ) - fetched_rate = Security::Price.find_price(security: security, date: tomorrow, cache: true) - refetched_rate = Security::Price.find_price(security: security, date: tomorrow, cache: true) + expect_provider_price(security: @security, price: expected_price, date: price_date) - assert_equal expected_price, fetched_rate.price - assert_equal expected_price, refetched_rate.price + assert_difference "Security::Price.count", 1 do + fetched_price = @security.find_or_fetch_price(date: price_date, cache: true) + assert_equal expected_price.price, fetched_price.price + end end test "returns nil if no price found in DB or from provider" do security = securities(:aapl) Security::Price.delete_all # Clear any existing prices - @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: Date.current, end_date: Date.current) - .once - .returns(OpenStruct.new(success?: false)) + provider_response = provider_error_response(Provider::ProviderError.new("Test error")) - assert_not Security::Price.find_price(security: security, date: Date.current) + @provider.expects(:fetch_security_price) + .with(security, date: Date.current) + .returns(provider_response) + + assert_not @security.find_or_fetch_price(date: Date.current) end - test "returns nil if price not found in DB and provider disabled" do - Security::Price.unstub(:provider) + test "upserts historical prices from provider" do + Security::Price.delete_all - Setting.stubs(:synth_api_key).returns(nil) + # Will be overwritten by upsert + Security::Price.create!(security: @security, date: 1.day.ago.to_date, price: 190, currency: "USD") - security = Security.new(ticker: "NVDA") + expect_provider_prices(security: @security, start_date: 2.days.ago.to_date, end_date: Date.current, prices: [ + Security::Price.new(security: @security, date: Date.current, price: 215, currency: "USD"), + Security::Price.new(security: @security, date: 1.day.ago.to_date, price: 214, currency: "USD"), + Security::Price.new(security: @security, date: 2.days.ago.to_date, price: 213, currency: "USD") + ]) - with_env_overrides SYNTH_API_KEY: nil do - assert_not Security::Price.find_price(security: security, date: Date.current) + @security.sync_provider_prices(start_date: 2.days.ago.to_date) + + assert_equal 215, @security.prices.find_by(date: Date.current).price + assert_equal 214, @security.prices.find_by(date: 1.day.ago.to_date).price + assert_equal 213, @security.prices.find_by(date: 2.days.ago.to_date).price + end + + private + def expect_provider_price(security:, price:, date:) + @provider.expects(:fetch_security_price) + .with(security, date: date) + .returns(provider_success_response(Security::Provideable::PriceData.new(price: price))) end - end - test "fetches multiple dates at once" do - @provider.expects(:fetch_security_prices).never - security = securities(:aapl) - price1 = security_prices(:one) # AAPL today - price2 = security_prices(:two) # AAPL yesterday - - fetched_prices = Security::Price.find_prices(security: security, start_date: 1.day.ago.to_date, end_date: Date.current).sort_by(&:date) - - assert_equal price1, fetched_prices[1] - assert_equal price2, fetched_prices[0] - end - - test "caches multiple prices to DB" do - missing_price = 213.21 - security = securities(:aapl) - - @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, - mic_code: security.exchange_operating_mic, - start_date: 2.days.ago.to_date, - end_date: 2.days.ago.to_date) - .returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price, currency: "USD" } ])) - .once - - price1 = security_prices(:one) # AAPL today - price2 = security_prices(:two) # AAPL yesterday - - fetched_prices = Security::Price.find_prices(security: security, start_date: 2.days.ago.to_date, end_date: Date.current, cache: true) - refetched_prices = Security::Price.find_prices(security: security, start_date: 2.days.ago.to_date, end_date: Date.current, cache: true) - - assert_equal [ missing_price, price2.price, price1.price ], fetched_prices.sort_by(&:date).map(&:price) - assert_equal [ missing_price, price2.price, price1.price ], refetched_prices.sort_by(&:date).map(&:price) - - assert Security::Price.exists?(security: security, date: 2.days.ago.to_date, price: missing_price) - end - - test "returns empty array if no prices found in DB or from provider" do - Security::Price.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_equal [], Security::Price.find_prices(security: Security.new(ticker: "NVDA"), start_date: 10.days.ago.to_date, end_date: Date.current) + def expect_provider_prices(security:, prices:, start_date:, end_date:) + @provider.expects(:fetch_security_prices) + .with(security, start_date: start_date, end_date: end_date) + .returns(provider_success_response(Security::Provideable::PricesData.new(prices: prices))) end - end end diff --git a/test/models/trade_import_test.rb b/test/models/trade_import_test.rb index f0ee3e68..b6293b0e 100644 --- a/test/models/trade_import_test.rb +++ b/test/models/trade_import_test.rb @@ -6,6 +6,8 @@ class TradeImportTest < ActiveSupport::TestCase setup do @subject = @import = imports(:trade) + @provider = mock + Security.stubs(:provider).returns(@provider) end test "imports trades and accounts" do @@ -14,7 +16,7 @@ class TradeImportTest < ActiveSupport::TestCase # We should only hit the provider for GOOGL since AAPL already exists Security.expects(:search_provider).with( - query: "GOOGL", + "GOOGL", exchange_operating_mic: "XNAS" ).returns([ Security.new( diff --git a/test/support/provider_test_helper.rb b/test/support/provider_test_helper.rb new file mode 100644 index 00000000..45c7de2b --- /dev/null +++ b/test/support/provider_test_helper.rb @@ -0,0 +1,17 @@ +module ProviderTestHelper + def provider_success_response(data) + Provider::ProviderResponse.new( + success?: true, + data: data, + error: nil + ) + end + + def provider_error_response(error) + Provider::ProviderResponse.new( + success?: false, + data: nil, + error: error + ) + end +end diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index 85c4707c..bdc50f0e 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -5,6 +5,9 @@ class ImportsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + + # Trade securities will be imported as "offline" tickers + Security.stubs(:provider).returns(nil) end test "transaction import" do @@ -52,8 +55,6 @@ class ImportsTest < ApplicationSystemTestCase end test "trade import" do - Security.stubs(:search_provider).returns([]) - visit new_import_path click_on "Import investments" diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 9d0338b0..dbc8ca99 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -33,6 +33,7 @@ class SettingsTest < ApplicationSystemTestCase test "can update self hosting settings" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + Providers.stubs(:synth).returns(nil) open_settings_from_sidebar assert_selector "li", text: "Self hosting" click_link "Self hosting" diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index a1f19139..cff4a35f 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -10,16 +10,8 @@ class TradesTest < ApplicationSystemTestCase visit_account_portfolio - Security.stubs(:search_provider).returns([ - Security.new( - ticker: "AAPL", - name: "Apple Inc.", - logo_url: "https://logo.synthfinance.com/ticker/AAPL", - exchange_acronym: "NASDAQ", - exchange_mic: "XNAS", - country_code: "US" - ) - ]) + # Disable provider to focus on form testing + Security.stubs(:provider).returns(nil) end test "can create buy transaction" do @@ -28,7 +20,6 @@ class TradesTest < ApplicationSystemTestCase open_new_trade_modal fill_in "Ticker symbol", with: "AAPL" - select_combobox_option("Apple") fill_in "Date", with: Date.current fill_in "Quantity", with: shares_qty fill_in "account_entry[price]", with: 214.23 @@ -50,7 +41,6 @@ class TradesTest < ApplicationSystemTestCase select "Sell", from: "Type" fill_in "Ticker symbol", with: aapl.ticker - select_combobox_option(aapl.security.name) fill_in "Date", with: Date.current fill_in "Quantity", with: aapl.qty fill_in "account_entry[price]", with: 215.33 @@ -81,10 +71,4 @@ class TradesTest < ApplicationSystemTestCase def visit_account_portfolio visit account_path(@account, tab: "holdings") end - - def select_combobox_option(text) - within "#account_entry_ticker-hw-listbox" do - find("li", text: text).click - end - end end diff --git a/test/vcr_cassettes/synth/exchange_rate.yml b/test/vcr_cassettes/synth/exchange_rate.yml index 40fb9995..be1fa77a 100644 --- a/test/vcr_cassettes/synth/exchange_rate.yml +++ b/test/vcr_cassettes/synth/exchange_rate.yml @@ -2,15 +2,19 @@ http_interactions: - request: method: get - uri: https://api.synthfinance.com/rates/historical?date=2024-08-01&from=USD&to=MXN + uri: https://api.synthfinance.com/rates/historical?date=2024-01-01&from=USD&to=GBP body: encoding: US-ASCII string: '' headers: - User-Agent: - - Faraday v2.10.0 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: @@ -21,29 +25,25 @@ http_interactions: message: OK headers: Date: - - Thu, 01 Aug 2024 17:20:28 GMT + - Sat, 15 Mar 2025 22:18:46 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: - - chunked + - chunked Connection: - keep-alive - Cf-Ray: - - 8ac77fbcc9d013ae-CMH - Cf-Cache-Status: - - DYNAMIC Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"668c8ac287a5ff6d6a705c35c69823b1" + - W/"b0b21c870fe53492404cc5ac258fa465" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 44367fcb-e5b4-457d Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - ff56c2fe-6252-4b2c X-Content-Type-Options: - nosniff X-Frame-Options: @@ -53,17 +53,29 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 61992b01-969b-4af5-8119-9b17e385da07 + - 8ce9dc85-afbd-437c-b18d-ec788b712334 X-Runtime: - - '0.369358' + - '0.031963' 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' Server: - cloudflare + Cf-Ray: + - 920f6378fe582237-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" body: encoding: ASCII-8BIT - string: '{"data":{"date":"2024-08-01","source":"USD","rates":{"MXN":18.645877}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":248999}}' - recorded_at: Thu, 01 Aug 2024 17:20:28 GMT -recorded_with: VCR 6.2.0 + 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 +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/exchange_rate_historical.yml b/test/vcr_cassettes/synth/exchange_rate_historical.yml deleted file mode 100644 index 7e34a6ae..00000000 --- a/test/vcr_cassettes/synth/exchange_rate_historical.yml +++ /dev/null @@ -1,213 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=1&to=GBP - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.10.1 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 08 Aug 2024 17:57:48 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8b01640eb8e8451c-TXL - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"fe9bd64a1b712e0577da8fbfd5bad08d" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - d8c1b21e-a6f4-48a0 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 0003eaec-e246-4769-84f5-7a062eef0908 - X-Runtime: - - '0.040177' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - 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}}],"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=2\u0026to=GBP","total_records":213,"current_page":1,"per_page":100,"total_pages":3},"meta":{"credits_used":1,"credits_remaining":53,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Thu, 08 Aug 2024 17:57:48 GMT -- request: - method: get - uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=2&to=GBP - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.10.1 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 08 Aug 2024 17:57:48 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8b016411c8da2685-TXL - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"7617d44c8da4ad2ecd071eae8522f17c" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - a882d8f9-da35-4532 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 06a85aa9-8288-484c-80f2-f90ddd97c36e - X-Runtime: - - '0.026746' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"data":[{"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}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=1\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=3\u0026to=GBP","total_records":213,"current_page":2,"per_page":100,"total_pages":3},"meta":{"credits_used":1,"credits_remaining":52,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Thu, 08 Aug 2024 17:57:48 GMT -- request: - method: get - uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=3&to=GBP - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.10.1 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 08 Aug 2024 17:57:49 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8b016414b8f758de-TXL - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"6efe5a0b3e3e58e3c8d2fa5d6525bd61" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - bcf6b2fc-a331-4293 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 87b3e27e-08bd-4784-8c6f-350622aa08e6 - X-Runtime: - - '0.029529' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"data":[{"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.774027}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773514}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.77348}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775341}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775425}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777798}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777333}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.77693}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.77605}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.77799}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778763}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=2\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":3,"per_page":100,"total_pages":3},"meta":{"credits_used":1,"credits_remaining":51,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Thu, 08 Aug 2024 17:57:49 GMT -recorded_with: VCR 6.2.0 diff --git a/test/vcr_cassettes/synth/exchange_rates.yml b/test/vcr_cassettes/synth/exchange_rates.yml new file mode 100644 index 00000000..ffb7b69a --- /dev/null +++ b/test/vcr_cassettes/synth/exchange_rates.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=1&to=GBP + 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: + - Sat, 15 Mar 2025 21:48: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/"8081859271e9ca46ee021f706a0cc683" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 6d036078-7f2f-4037 + 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: + - 9ec8d111-aa67-4fb9-8885-7de64e1b1219 + X-Runtime: + - '0.025769' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 920f37347b14e7f9-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" + 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 +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/health.yml b/test/vcr_cassettes/synth/health.yml new file mode 100644 index 00000000..4d8ca054 --- /dev/null +++ b/test/vcr_cassettes/synth/health.yml @@ -0,0 +1,82 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/user + 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: + - Sat, 15 Mar 2025 22:18:47 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 0cab64c9-e312-4bec + 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: + - 1958563c-7c18-4201-a03c-a4b343dc68ab + X-Runtime: + - '0.014938' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 920f637aa8cf1152-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" + 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 +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_info.yml b/test/vcr_cassettes/synth/security_info.yml new file mode 100644 index 00000000..8122b882 --- /dev/null +++ b/test/vcr_cassettes/synth/security_info.yml @@ -0,0 +1,105 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/tickers/AAPL?operating_mic=XNAS + 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:04:12 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"a9deeb6437d359f080be449b9b2c547b" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 1e77ae49-050a-45fc + 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: + - 222dacf1-37f3-4eb8-91d5-edf13d732d46 + X-Runtime: + - '0.059222' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 92141c97bfd9124c-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" + 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 + Inc. designs, manufactures, and markets smartphones, personal computers, tablets, + wearables, and accessories worldwide. The company offers iPhone, a line of + smartphones; Mac, a line of personal computers; iPad, a line of multi-purpose + tablets; and wearables, home, and accessories comprising AirPods, Apple TV, + Apple Watch, Beats products, and HomePod. It also provides AppleCare support + and cloud services; and operates various platforms, including the App Store + that allow customers to discover and download applications and digital content, + such as books, music, video, games, and podcasts. In addition, the company + offers various services, such as Apple Arcade, a game subscription service; + Apple Fitness+, a personalized fitness service; Apple Music, which offers + users a curated listening experience with on-demand radio stations; Apple + News+, a subscription news and magazine service; Apple TV+, which offers exclusive + original content; Apple Card, a co-branded credit card; and Apple Pay, a cashless + payment service, as well as licenses its intellectual property. The company + serves consumers, and small and mid-sized businesses; and the education, enterprise, + and government markets. It distributes third-party applications for its products + through the App Store. The company also sells its products through its retail + and online stores, and direct sales force; and third-party cellular network + carriers, wholesalers, retailers, and resellers. Apple Inc. was founded in + 1976 and is headquartered in Cupertino, California.","kind":"common stock","cik":"0000320193","currency":"USD","address":{"country":"USA","address_line1":"One + 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 +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_price.yml b/test/vcr_cassettes/synth/security_price.yml new file mode 100644 index 00000000..2e6d1dfb --- /dev/null +++ b/test/vcr_cassettes/synth/security_price.yml @@ -0,0 +1,83 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=1&start_date=2024-08-01 + 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:08:00 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"cdf04c2cd77e230c03117dd13d0921f9" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - e74b3425-0b7c-447d + 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: + - b906c5e1-18cc-44cc-9085-313ff066a6ce + X-Runtime: + - '0.544708' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 921422292d0feacc-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" + 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 +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 6cdf1fe6..1da82461 100644 --- a/test/vcr_cassettes/synth/security_prices.yml +++ b/test/vcr_cassettes/synth/security_prices.yml @@ -1,135 +1,163 @@ --- http_interactions: - - request: - method: get - uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&page=1&start_date=2024-01-01 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v2.10.0 - Authorization: - - Bearer - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 01 Aug 2024 17:21:42 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8ac781877cbb13ae-CMH - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"c1f8d4686b33c94fa18354b54c960f43" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - e6566c15-53f8-44b0 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 14434e85-b1d4-4c36-a69a-efd14c562649 - X-Runtime: - - '1.397922' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"ticker":"AAPL","prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":81964874},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414460},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71878670},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62371161},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144470},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841809},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46192908},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128408},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40477782},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65076641},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317433},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":77722754},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68887985},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60131852},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355590},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631316},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822126},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44587111},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145622},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55836970},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467803},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885408},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102527680},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69654320},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490759},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53438955},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962046},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155216},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781934},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529529},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54617917},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434496},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49752465},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53574453},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41496371},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52284192},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":44926677},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867421},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318851},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48943139},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682597},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73450582},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81505451},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":94702355},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68568907},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71763761},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76267041},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139473},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59813522},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488692},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913507},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121752699},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75606556},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215244},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423102},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181270},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71146138},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288328},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388449},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60263665},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65671690},{"date":"2024-04-01","open":171.19,"close":170.03,"high":171.25,"low":169.48,"volume":46240500},{"date":"2024-04-02","open":169.08,"close":168.84,"high":169.34,"low":168.23,"volume":49297581},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691715},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53682486},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42104826},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425513},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451209},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49691936},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91053075},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101282386},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":70733115},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":71583932},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":48503680},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":40735511},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":66084170},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":46488244},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":46956672},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":47007455},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":48858902},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44014087},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":66891905},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":64066593},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":48416441},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":91402452},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":160948084},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":75883763},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":74139796},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":43762264},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":47493785},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":48525869},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":68586935},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":50551025},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":67561123},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":51938566},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":39819440},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":43637717},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":41192656},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":33510741},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":48553611}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":248998}}' - recorded_at: Thu, 01 Aug 2024 17:21:42 GMT - - request: - method: get - uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&page=2&start_date=2024-01-01 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v2.10.0 - Authorization: - - Bearer - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 01 Aug 2024 17:21:44 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8ac78191cc326a4c-CMH - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"88e17df1f20118595ae291a4a025291f" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - e8700161-b44e-49ce - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 6072beb1-2e89-4ce0-95e0-72d436adc033 - X-Runtime: - - '1.296361' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"ticker":"AAPL","prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":35429737},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":51021752},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":51934816},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":48211467},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":71937580},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":48702790},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":46573003},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":53100041},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":39591471},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":52508446},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":95034362},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":169677009},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":197067068},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":96562134},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":69150814},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":92964543},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":78534656},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":83863022},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":204018186},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":76303387},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":54266550},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":64531178},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":48631748},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":80927625},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":59475152},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":57112299},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":36707517},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":58287571},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":57456163},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":47531745},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":61539280},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":63197762},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":51621443},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":60513737},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":38468546},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":55878906},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":64346030},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":48020038},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":44958139},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":37919040},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":59687424},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":50451768},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":39827645},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":35153729},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":40681625},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":48422974},{"date":"2024-08-01","open":224.37,"high":224.81,"low":217.78,"volume":25116548}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":248997}}' - recorded_at: Thu, 01 Aug 2024 17:21:44 GMT -recorded_with: VCR 6.2.0 +- request: + method: get + uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=1&start_date=2024-01-01 + 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:02:51 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"eb6f73b7cb267ae753291839d20c72e4" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - e0119cf5-873c-4315 + 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: + - 590af10c-b7c1-47a1-9a9a-7f8a5f031734 + X-Runtime: + - '0.511130' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 92141a99ed232306-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" + 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 +- 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 + 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:02:52 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"78f6663a1523295a82d0ded13df426e4" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - b0cd3704-937c-4017 + 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: + - 59a55ec3-49af-4fa1-a104-77480fa6914e + X-Runtime: + - '0.583469' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 92141a9edbeee15f-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" + 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 +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_search.yml b/test/vcr_cassettes/synth/security_search.yml new file mode 100644 index 00000000..f9504804 --- /dev/null +++ b/test/vcr_cassettes/synth/security_search.yml @@ -0,0 +1,104 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/tickers/search?country_code=US&dataset=limited&limit=25&name=AAPL + 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:01:58 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"3e444869eacbaf17006766a691cc8fdc" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 2effb56b-f67f-402d + 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: + - 33470619-5119-4923-b4e0-e9a0eeb532a1 + X-Runtime: + - '0.453770' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 921419514e0a6399-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" + 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 + (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"APLY","isin":"US88634T8577","name":"YieldMax + AAPL Option Income ETF","logo_url":"https://logo.synthfinance.com/ticker/APLY","currency":"USD","exchange":{"name":"Nyse + Arca","mic_code":"ARCX","operating_mic_code":"XNYS","acronym":"NYSE","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPD","name":"Direxion + Daily AAPL Bear 1X ETF","logo_url":"https://logo.synthfinance.com/ticker/AAPD","currency":"USD","exchange":{"name":"Nasdaq/Nms + (Global Market)","mic_code":"XNMS","operating_mic_code":"XNAS","acronym":"","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPU","isin":"US25461A8743","name":"Direxion + Daily AAPL Bull 2X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPU","currency":"USD","exchange":{"name":"Nasdaq/Nms + (Global Market)","mic_code":"XNMS","operating_mic_code":"XNAS","acronym":"","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPB","isin":"XXXXXXXR8842","name":"GraniteShares + 2x Long AAPL Daily ETF","logo_url":"https://logo.synthfinance.com/ticker/AAPB","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"}},{"symbol":"AAPD","isin":"US25461A3041","name":"Direxion + Daily AAPL Bear 1X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPD","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"}},{"symbol":"AAPU","isin":"US25461A8743","name":"Direxion + Daily AAPL Bull 1.5X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPU","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"}},{"symbol":"AAPJ","isin":"US00037T1034","name":"AAP, + 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_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/transaction_enrich.yml b/test/vcr_cassettes/synth/transaction_enrich.yml new file mode 100644 index 00000000..08463ed7 --- /dev/null +++ b/test/vcr_cassettes/synth/transaction_enrich.yml @@ -0,0 +1,82 @@ +--- +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 new file mode 100644 index 00000000..27e5300f --- /dev/null +++ b/test/vcr_cassettes/synth/usage.yml @@ -0,0 +1,82 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/user + 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: + - Sat, 15 Mar 2025 22:18:47 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 54c8ecf9-6858-4db6 + 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: + - a4112cfb-0eac-4e3e-a880-7536d90dcba0 + X-Runtime: + - '0.007036' + 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}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 920f637d1fe8eb68-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" + 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 +recorded_with: VCR 6.3.1