diff --git a/CLAUDE.md b/CLAUDE.md index f534c10..249b680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,12 @@ Gera is a Rails engine for generating and managing currency exchange rates for c - **CurrencyPair** - Utility class for currency pair operations - **Universe** - Central repository pattern for accessing rate data -### Worker Architecture -- **RatesWorker** concern for fetching external rates -- Individual workers for each rate source (ExmoRatesWorker, BitfinexRatesWorker, etc.) -- **CurrencyRatesWorker** - Builds currency rate matrix from external rates -- **DirectionsRatesWorker** - Calculates final direction rates with commissions -- **CreateHistory_intervalsWorker** - Aggregates historical data +### Job Architecture (ActiveJob/SolidQueue) +- **RatesJob** concern for fetching external rates +- Individual jobs for each rate source (ExmoRatesJob, BitfinexRatesJob, etc.) +- **CurrencyRatesJob** - Builds currency rate matrix from external rates +- **DirectionsRatesJob** - Calculates final direction rates with commissions +- **CreateHistoryIntervalsJob** - Aggregates historical data ## Development Commands @@ -100,7 +100,7 @@ RUB, USD, BTC, LTC, ETH, DSH, KZT, XRP, ETC, XMR, BCH, EUR, NEO, ZEC ## File Organization - `app/models/gera/` - Core domain models -- `app/workers/gera/` - Background job workers +- `app/jobs/gera/` - Background jobs (ActiveJob/SolidQueue) - `lib/gera/` - Core engine logic and utilities - `lib/builders/` - Rate calculation builders - `spec/` - Test suite with dummy app diff --git a/app/jobs/gera/directions_rates_job.rb b/app/jobs/gera/directions_rates_job.rb index be970c5..c45f2ee 100644 --- a/app/jobs/gera/directions_rates_job.rb +++ b/app/jobs/gera/directions_rates_job.rb @@ -4,6 +4,7 @@ module Gera class DirectionsRatesJob < ApplicationJob include ActiveSupport::Callbacks include AutoLogger + include Mathematic Error = Class.new StandardError @@ -19,9 +20,8 @@ def perform(*_args) # exchange_rate_id: nil) run_callbacks :perform do Gera::DirectionRateSnapshot.transaction do - Gera::ExchangeRate.includes(:payment_system_from, :payment_system_to).find_each do |exchange_rate| - safe_create(exchange_rate) - end + records = build_direction_rate_records + Gera::DirectionRate.insert_all!(records) if records.any? end end logger.info 'finish' @@ -29,21 +29,50 @@ def perform(*_args) # exchange_rate_id: nil) private - delegate :direction_rates, to: :snapshot - def snapshot @snapshot ||= Gera::DirectionRateSnapshot.create! end - def safe_create(exchange_rate) - direction_rates.create!( - snapshot: snapshot, - exchange_rate: exchange_rate, - currency_rate: Gera::Universe.currency_rates_repository.find_currency_rate_by_pair(exchange_rate.currency_pair) - ) - rescue Gera::CurrencyRatesRepository::UnknownPair => err - rescue Gera::DirectionRate::UnknownExchangeRate, ActiveRecord::RecordInvalid => err - logger.error err + def currency_rates_cache + @currency_rates_cache ||= Gera::Universe.currency_rates_repository + .snapshot + .rates + .index_by(&:currency_pair) + end + + def build_direction_rate_records + current_time = Time.current + exchange_rates = Gera::ExchangeRate.includes(:payment_system_from, :payment_system_to).to_a + + exchange_rates.filter_map do |exchange_rate| + build_direction_rate_hash(exchange_rate, current_time) + end + end + + def build_direction_rate_hash(exchange_rate, current_time) + currency_rate = currency_rates_cache[exchange_rate.currency_pair] + return nil unless currency_rate + + rate_percent = exchange_rate.final_rate_percents + return nil if rate_percent.nil? + + base_rate_value = currency_rate.rate_value + rate_value = calculate_finite_rate(base_rate_value, rate_percent) + + { + snapshot_id: snapshot.id, + exchange_rate_id: exchange_rate.id, + currency_rate_id: currency_rate.id, + ps_from_id: exchange_rate.income_payment_system_id, + ps_to_id: exchange_rate.outcome_payment_system_id, + base_rate_value: base_rate_value, + rate_percent: rate_percent, + rate_value: rate_value, + created_at: current_time + } + rescue StandardError => e + logger.error "Failed to build direction rate for exchange_rate #{exchange_rate.id}: #{e.message}" + nil end end end diff --git a/app/services/gera/rate_comission_calculator.rb b/app/services/gera/rate_comission_calculator.rb index 17d5b1a..f7a35ca 100644 --- a/app/services/gera/rate_comission_calculator.rb +++ b/app/services/gera/rate_comission_calculator.rb @@ -54,7 +54,11 @@ def auto_rate_by_reserve_to def current_base_rate return 1.0 if same_currencies? - @current_base_rate ||= Gera::CurrencyRateHistoryInterval.where(cur_from_id: in_currency.local_id, cur_to_id: out_currency.local_id).last.avg_rate + @current_base_rate ||= Gera::CurrencyRateHistoryInterval + .where(cur_from_id: in_currency.local_id, cur_to_id: out_currency.local_id) + .order(:interval_from) + .last + .avg_rate end def average_base_rate diff --git a/spec/jobs/gera/directions_rates_job_spec.rb b/spec/jobs/gera/directions_rates_job_spec.rb index 68afcfa..98528a3 100644 --- a/spec/jobs/gera/directions_rates_job_spec.rb +++ b/spec/jobs/gera/directions_rates_job_spec.rb @@ -40,6 +40,58 @@ def rows_without_kassa; []; end expect(subject).to receive(:logger).at_least(:twice).and_return(double(info: nil)) subject.perform end + + it 'creates direction rate with correct attributes' do + subject.perform + direction_rate = DirectionRate.last + + expect(direction_rate.exchange_rate_id).to eq(exchange_rate.id) + expect(direction_rate.currency_rate_id).to eq(currency_rate.id) + expect(direction_rate.ps_from_id).to eq(payment_system_from.id) + expect(direction_rate.ps_to_id).to eq(payment_system_to.id) + expect(direction_rate.base_rate_value).to eq(currency_rate.rate_value) + expect(direction_rate.rate_percent).to be_present + expect(direction_rate.rate_value).to be_present + end + + context 'with multiple exchange rates' do + let!(:payment_system_eur) { create(:gera_payment_system, currency: Money::Currency.find('EUR')) } + let!(:exchange_rate2) do + create(:gera_exchange_rate, + payment_system_from: payment_system_to, + payment_system_to: payment_system_eur) + end + let!(:currency_rate2) do + create(:currency_rate, + snapshot: currency_rate_snapshot, + cur_from: Money::Currency.find('RUB'), + cur_to: Money::Currency.find('EUR'), + currency_pair: Gera::CurrencyPair.new(Money::Currency.find('RUB'), Money::Currency.find('EUR'))) + end + + it 'creates direction rates for all exchange rates with matching currency rates' do + expect { subject.perform }.to change(DirectionRate, :count).by(2) + end + + it 'uses batch insert (single SQL INSERT)' do + # Verify that insert_all! is called instead of individual creates + expect(DirectionRate).to receive(:insert_all!).once.and_call_original + subject.perform + end + end + + context 'when currency rate is missing for an exchange rate' do + let!(:payment_system_btc) { create(:gera_payment_system, currency: Money::Currency.find('BTC')) } + let!(:exchange_rate_no_currency_rate) do + create(:gera_exchange_rate, + payment_system_from: payment_system_from, + payment_system_to: payment_system_btc) + end + + it 'skips exchange rates without matching currency rates' do + expect { subject.perform }.to change(DirectionRate, :count).by(1) + end + end end describe 'queue configuration' do