1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-24 15:49:39 +02:00
Maybe/app/models/market_data_syncer.rb
Zach Gollwitzer 6dc1d22672
Market data sync refinements (#2252)
* Exchange rate syncer implementation

* Security price syncer

* Fix issues with provider API

* Add back prod schedule

* Add back price and exchange rate syncs to account syncs

* Remove unused stock_exchanges table
2025-05-16 14:17:56 -04:00

132 lines
4 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class MarketDataSyncer
# 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)
def initialize(mode: :full, clear_cache: false)
@mode = set_mode!(mode)
@clear_cache = clear_cache
end
def sync
sync_prices
sync_exchange_rates
end
# Syncs historical security prices (and details)
def sync_prices
unless Security.provider
Rails.logger.warn("No provider configured for MarketDataSyncer.sync_prices, skipping sync")
return
end
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
)
security.sync_provider_details(clear_cache: clear_cache)
end
end
def sync_exchange_rates
unless ExchangeRate.provider
Rails.logger.warn("No provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync")
return
end
required_exchange_rate_pairs.each do |pair|
# pair is a Hash with keys :source, :target, and :start_date
start_date = snapshot? ? default_start_date : pair[:start_date]
ExchangeRate.sync_provider_rates(
from: pair[:source],
to: pair[:target],
start_date: start_date,
end_date: end_date,
clear_cache: clear_cache
)
end
end
private
attr_reader :mode, :clear_cache
def snapshot?
mode.to_sym == :snapshot
end
# Builds a unique list of currency pairs with the earliest date we need
# exchange rates for.
#
# Returns: Array of Hashes [{ source:, target:, start_date: }, ...]
def required_exchange_rate_pairs
pair_dates = {} # { [source, target] => earliest_date }
# 1. ENTRY-BASED PAIRS we need rates from the first entry date
Entry.joins(:account)
.where.not("entries.currency = accounts.currency")
.group("entries.currency", "accounts.currency")
.minimum("entries.date")
.each do |(source, target), date|
key = [ source, target ]
pair_dates[key] = [ pair_dates[key], date ].compact.min
end
# 2. ACCOUNT-BASED PAIRS use the account's oldest entry date
account_first_entry_dates = Entry.group(:account_id).minimum(:date)
Account.joins(:family)
.where.not("families.currency = accounts.currency")
.select("accounts.id, accounts.currency AS source, families.currency AS target")
.find_each do |account|
earliest_entry_date = account_first_entry_dates[account.id]
chosen_date = [ earliest_entry_date, default_start_date ].compact.min
key = [ account.source, account.target ]
pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min
end
# Convert to array of hashes for ease of use
pair_dates.map do |(source, target), date|
{ source: source, target: target, start_date: date }
end
end
def get_first_required_price_date(security)
return default_start_date if snapshot?
Trade.with_entry.where(security: security).minimum(:date)
end
# 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)
end
def default_start_date
SNAPSHOT_DAYS.days.ago.to_date
end
# Since we're querying market data from a US-based API, end date should always be today (EST)
def end_date
Date.current.in_time_zone("America/New_York").to_date
end
def set_mode!(mode)
valid_modes = [ :full, :snapshot ]
unless valid_modes.include?(mode.to_sym)
raise InvalidModeError, "Invalid mode for MarketDataSyncer, can only be :full or :snapshot, but was #{mode}"
end
mode.to_sym
end
end