2025-05-15 10:19:56 -04:00
|
|
|
|
class MarketDataSyncer
|
2025-05-16 14:17:56 -04:00
|
|
|
|
# By default, our graphs show 1M as the view, so by fetching 31 days,
|
|
|
|
|
# we ensure we can always show an accurate default graph
|
|
|
|
|
SNAPSHOT_DAYS = 31
|
|
|
|
|
|
|
|
|
|
InvalidModeError = Class.new(StandardError)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
def initialize(mode: :full, clear_cache: false)
|
|
|
|
|
@mode = set_mode!(mode)
|
|
|
|
|
@clear_cache = clear_cache
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
def sync
|
|
|
|
|
sync_prices
|
|
|
|
|
sync_exchange_rates
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
# Syncs historical security prices (and details)
|
|
|
|
|
def sync_prices
|
|
|
|
|
unless Security.provider
|
|
|
|
|
Rails.logger.warn("No provider configured for MarketDataSyncer.sync_prices, skipping sync")
|
2025-05-15 10:19:56 -04:00
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
Security.where.not(exchange_operating_mic: nil).find_each do |security|
|
|
|
|
|
security.sync_provider_prices(
|
|
|
|
|
start_date: get_first_required_price_date(security),
|
|
|
|
|
end_date: end_date,
|
|
|
|
|
clear_cache: clear_cache
|
|
|
|
|
)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
security.sync_provider_details(clear_cache: clear_cache)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
def sync_exchange_rates
|
|
|
|
|
unless ExchangeRate.provider
|
|
|
|
|
Rails.logger.warn("No provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync")
|
|
|
|
|
return
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
required_exchange_rate_pairs.each do |pair|
|
|
|
|
|
# pair is a Hash with keys :source, :target, and :start_date
|
|
|
|
|
start_date = snapshot? ? default_start_date : pair[:start_date]
|
|
|
|
|
|
|
|
|
|
ExchangeRate.sync_provider_rates(
|
|
|
|
|
from: pair[:source],
|
|
|
|
|
to: pair[:target],
|
|
|
|
|
start_date: start_date,
|
|
|
|
|
end_date: end_date,
|
|
|
|
|
clear_cache: clear_cache
|
|
|
|
|
)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
private
|
2025-05-16 14:17:56 -04:00
|
|
|
|
attr_reader :mode, :clear_cache
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
def snapshot?
|
|
|
|
|
mode.to_sym == :snapshot
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
# Builds a unique list of currency pairs with the earliest date we need
|
|
|
|
|
# exchange rates for.
|
|
|
|
|
#
|
|
|
|
|
# Returns: Array of Hashes – [{ source:, target:, start_date: }, ...]
|
|
|
|
|
def required_exchange_rate_pairs
|
|
|
|
|
pair_dates = {} # { [source, target] => earliest_date }
|
|
|
|
|
|
|
|
|
|
# 1. ENTRY-BASED PAIRS – we need rates from the first entry date
|
|
|
|
|
Entry.joins(:account)
|
|
|
|
|
.where.not("entries.currency = accounts.currency")
|
|
|
|
|
.group("entries.currency", "accounts.currency")
|
|
|
|
|
.minimum("entries.date")
|
|
|
|
|
.each do |(source, target), date|
|
|
|
|
|
key = [ source, target ]
|
|
|
|
|
pair_dates[key] = [ pair_dates[key], date ].compact.min
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
# 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date
|
|
|
|
|
account_first_entry_dates = Entry.group(:account_id).minimum(:date)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
Account.joins(:family)
|
|
|
|
|
.where.not("families.currency = accounts.currency")
|
|
|
|
|
.select("accounts.id, accounts.currency AS source, families.currency AS target")
|
|
|
|
|
.find_each do |account|
|
|
|
|
|
earliest_entry_date = account_first_entry_dates[account.id]
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
chosen_date = [ earliest_entry_date, default_start_date ].compact.min
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
key = [ account.source, account.target ]
|
|
|
|
|
pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
# Convert to array of hashes for ease of use
|
|
|
|
|
pair_dates.map do |(source, target), date|
|
|
|
|
|
{ source: source, target: target, start_date: date }
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
def get_first_required_price_date(security)
|
|
|
|
|
return default_start_date if snapshot?
|
2025-05-15 10:19:56 -04:00
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
Trade.with_entry.where(security: security).minimum(:date)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
2025-05-16 14:17:56 -04:00
|
|
|
|
# An approximation that grabs more than we likely need, but simplifies the logic
|
|
|
|
|
def get_first_required_exchange_rate_date(from_currency:)
|
|
|
|
|
return default_start_date if snapshot?
|
|
|
|
|
|
|
|
|
|
Entry.where(currency: from_currency).minimum(:date)
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def default_start_date
|
2025-05-16 14:17:56 -04:00
|
|
|
|
SNAPSHOT_DAYS.days.ago.to_date
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Since we're querying market data from a US-based API, end date should always be today (EST)
|
|
|
|
|
def end_date
|
|
|
|
|
Date.current.in_time_zone("America/New_York").to_date
|
|
|
|
|
end
|
2025-05-16 14:17:56 -04:00
|
|
|
|
|
|
|
|
|
def set_mode!(mode)
|
|
|
|
|
valid_modes = [ :full, :snapshot ]
|
|
|
|
|
|
|
|
|
|
unless valid_modes.include?(mode.to_sym)
|
|
|
|
|
raise InvalidModeError, "Invalid mode for MarketDataSyncer, can only be :full or :snapshot, but was #{mode}"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
mode.to_sym
|
|
|
|
|
end
|
2025-05-15 10:19:56 -04:00
|
|
|
|
end
|