diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3597261 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: test test-all db-up db-down + +# Run calculator and exchange_rate tests (the ones we fixed) +test: + mise exec -- bundle exec rspec \ + spec/services/gera/autorate_calculators/isolated_spec.rb \ + spec/models/gera/exchange_rate_spec.rb \ + --no-color + +# Run all tests +test-all: + mise exec -- bundle exec rspec --no-color + +# Start MySQL for testing +db-up: + docker-compose up -d + @echo "Waiting for MySQL..." + @for i in $$(seq 1 30); do \ + docker exec gera-legacy-mysql-1 mysqladmin ping -h localhost -u root -p1111 2>/dev/null && break || sleep 2; \ + done + @echo "MySQL is ready" + +# Stop MySQL +db-down: + docker-compose down diff --git a/README.md b/README.md index 1a0e70f..da9619b 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,35 @@ $ gem install gera Add `./config/initializers/gera.rb` with this content: -``` +```ruby Gera.configure do |config| config.cross_pairs = { kzt: :rub, eur: :rub } + + # Автокурс: ID нашего обменника в BestChange (для исключения из расчёта позиции) + config.our_exchanger_id = 999 + + # Автокурс: Порог аномальной комиссии для защиты от манипуляторов (по умолчанию 50%) + config.anomaly_threshold_percent = 50.0 end ``` +### Autorate Calculator Types + +Для каждого направления обмена (`ExchangeRate`) можно выбрать тип калькулятора автокурса: + +```ruby +# Legacy (по умолчанию) - старый алгоритм +exchange_rate.update!(calculator_type: 'legacy') + +# PositionAware - новый алгоритм с защитой от перепрыгивания позиций +exchange_rate.update!(calculator_type: 'position_aware') +``` + +**PositionAware** гарантирует, что обменник займёт позицию внутри целевого диапазона (`position_from..position_to`), а не перепрыгнет выше. Поддерживает: +- Адаптивный GAP для плотных рейтингов +- Исключение своего обменника из расчёта +- Защиту от манипуляторов с аномальными курсами + ## Supported external sources of basic rates * EXMO, Russian Central Bank, Bitfinex, Manual diff --git a/app/models/gera/exchange_rate.rb b/app/models/gera/exchange_rate.rb index fcc3736..2694bcb 100644 --- a/app/models/gera/exchange_rate.rb +++ b/app/models/gera/exchange_rate.rb @@ -20,6 +20,8 @@ class ExchangeRate < ApplicationRecord DEFAULT_COMISSION = 50 MIN_COMISSION = -9.9 + CALCULATOR_TYPES = %w[legacy position_aware].freeze + include Mathematic include DirectionSupport @@ -63,7 +65,8 @@ class ExchangeRate < ApplicationRecord end validates :commission, presence: true - # validates :commission, numericality: { greater_than_or_equal_to: MIN_COMISSION } + validates :commission, numericality: { greater_than_or_equal_to: MIN_COMISSION } + validates :calculator_type, inclusion: { in: CALCULATOR_TYPES }, allow_nil: true delegate :rate, :currency_rate, to: :direction_rate @@ -186,5 +189,16 @@ def flexible_rate def flexible_rate? flexible_rate end + + def autorate_calculator_class + case calculator_type + when 'legacy' + AutorateCalculators::Legacy + when 'position_aware' + AutorateCalculators::PositionAware + else + raise ArgumentError, "Unknown calculator_type: #{calculator_type}" + end + end end end diff --git a/app/services/gera/autorate_calculators/base.rb b/app/services/gera/autorate_calculators/base.rb new file mode 100644 index 0000000..ed1b2c9 --- /dev/null +++ b/app/services/gera/autorate_calculators/base.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/module/delegation' +require 'active_support/core_ext/object/blank' + +module Gera + module AutorateCalculators + class Base + include Virtus.model strict: true + + AUTO_COMISSION_GAP = 0.001 + + attribute :exchange_rate + attribute :external_rates + + delegate :position_from, :position_to, :autorate_from, :autorate_to, + to: :exchange_rate + + def call + raise NotImplementedError, "#{self.class}#call must be implemented" + end + + protected + + def could_be_calculated? + !external_rates.nil? && exchange_rate.target_autorate_setting&.could_be_calculated? + end + + def external_rates_in_target_position + return nil unless external_rates.present? + + external_rates[(position_from - 1)..(position_to - 1)] + end + + def external_rates_in_target_comission + return [] unless external_rates_in_target_position.present? + + external_rates_in_target_position.select do |rate| + (autorate_from..autorate_to).include?(rate.target_rate_percent) + end + end + end + end +end diff --git a/app/services/gera/autorate_calculators/legacy.rb b/app/services/gera/autorate_calculators/legacy.rb new file mode 100644 index 0000000..d2b21d0 --- /dev/null +++ b/app/services/gera/autorate_calculators/legacy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Gera + module AutorateCalculators + # Legacy калькулятор - сохраняет текущее поведение. + # Вычитает фиксированный GAP из комиссии первого конкурента в диапазоне. + # Может "перепрыгивать" позиции выше целевого диапазона. + class Legacy < Base + def call + return 0 unless could_be_calculated? + return autorate_from unless external_rates_in_target_position.present? + return autorate_from if external_rates_in_target_comission.empty? + + external_rates_in_target_comission.first.target_rate_percent - AUTO_COMISSION_GAP + end + end + end +end diff --git a/app/services/gera/autorate_calculators/position_aware.rb b/app/services/gera/autorate_calculators/position_aware.rb new file mode 100644 index 0000000..2100abf --- /dev/null +++ b/app/services/gera/autorate_calculators/position_aware.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Gera + module AutorateCalculators + # Калькулятор с учётом позиций выше целевого диапазона. + # Гарантирует, что обменник займёт позицию внутри диапазона position_from..position_to, + # а не перепрыгнет выше. + # + # Поддерживает: + # - UC-6: Адаптивный GAP для плотных рейтингов + # - UC-8: Исключение своего обменника из расчёта + # - UC-9: Защита от манипуляторов с аномальными курсами + class PositionAware < Base + # Минимальный GAP (используется когда разница между позициями меньше стандартного) + MIN_GAP = 0.0001 + + def call + return 0 unless could_be_calculated? + + # UC-8: Фильтрация своего обменника + filtered = filtered_external_rates + return autorate_from unless filtered.present? + + rates_in_target_position = filtered[(position_from - 1)..(position_to - 1)] + return autorate_from unless rates_in_target_position.present? + + valid_rates = rates_in_target_position.select do |rate| + (autorate_from..autorate_to).include?(rate.target_rate_percent) + end + return autorate_from if valid_rates.empty? + + target_rate = valid_rates.first + + # UC-6: Адаптивный GAP + gap = calculate_adaptive_gap(filtered, target_rate) + target_comission = target_rate.target_rate_percent - gap + + # Проверяем, не перепрыгнем ли мы позицию выше position_from + adjusted_comission = adjust_for_position_above(target_comission, target_rate, filtered) + + adjusted_comission + end + + private + + # UC-8: Фильтрация своего обменника + def filtered_external_rates + return external_rates unless Gera.our_exchanger_id.present? + + external_rates.reject { |rate| rate.exchanger_id == Gera.our_exchanger_id } + end + + # UC-6: Адаптивный GAP + def calculate_adaptive_gap(rates, target_rate) + return AUTO_COMISSION_GAP if position_from <= 1 + + rate_above = rates[position_from - 2] + return AUTO_COMISSION_GAP unless rate_above + + diff = target_rate.target_rate_percent - rate_above.target_rate_percent + + # Если разница между позициями меньше стандартного GAP, + # используем половину разницы (но не меньше MIN_GAP) + if diff.positive? && diff < AUTO_COMISSION_GAP + [diff / 2.0, MIN_GAP].max + else + AUTO_COMISSION_GAP + end + end + + def adjust_for_position_above(target_comission, target_rate, rates) + return target_comission if position_from <= 1 + + # UC-9: Найти ближайшую нормальную позицию выше + rate_above = find_non_anomalous_rate_above(rates) + return target_comission unless rate_above + + rate_above_comission = rate_above.target_rate_percent + + # Если после вычитания GAP комиссия станет меньше (выгоднее) чем у позиции выше - + # мы перепрыгнём её. Нужно скорректировать. + if target_comission < rate_above_comission + # Устанавливаем комиссию равную или чуть выше (хуже) чем у позиции выше, + # но не хуже чем у целевой позиции + safe_comission = [rate_above_comission, target_rate.target_rate_percent].min + + # Если одинаковые курсы - оставляем как есть, BestChange определит позицию по вторичным критериям + return safe_comission + end + + target_comission + end + + # UC-9: Найти ближайшую нормальную (не аномальную) позицию выше целевой + def find_non_anomalous_rate_above(rates) + return nil if position_from <= 1 + + # Берём все позиции выше целевой (от 0 до position_from - 2) + rates_above = rates[0..(position_from - 2)] + return nil unless rates_above.present? + + # Если фильтрация аномалий отключена - просто берём ближайшую позицию выше + threshold = Gera.anomaly_threshold_percent + return rates_above.last unless threshold&.positive? && rates.size >= 3 + + # Вычисляем медиану для определения аномалий + all_comissions = rates.map(&:target_rate_percent).sort + median = all_comissions[all_comissions.size / 2] + + # Ищем ближайшую нормальную позицию сверху вниз + rates_above.reverse.find do |rate| + deviation = ((rate.target_rate_percent - median) / median * 100).abs + deviation <= threshold + end + end + end + end +end diff --git a/app/services/gera/rate_comission_calculator.rb b/app/services/gera/rate_comission_calculator.rb index 17d5b1a..dd81715 100644 --- a/app/services/gera/rate_comission_calculator.rb +++ b/app/services/gera/rate_comission_calculator.rb @@ -160,15 +160,11 @@ def auto_commision_range def auto_comission_by_external_comissions @auto_comission_by_external_comissions ||= begin - return 0 unless could_be_calculated? - - external_rates_in_target_position = external_rates[(position_from - 1)..(position_to - 1)] - return autorate_from unless external_rates_in_target_position.present? - external_rates_in_target_comission = external_rates_in_target_position.select { |rate| ((autorate_from)..(autorate_to)).include?(calculate_rate_commission(rate['rate'], exchange_rate.currency_rate.rate_value)) } - return autorate_from if external_rates_in_target_comission.empty? - - target_comission = calculate_rate_commission(external_rates_in_target_comission.first['rate'], exchange_rate.currency_rate.rate_value) - AUTO_COMISSION_GAP - target_comission + calculator = exchange_rate.autorate_calculator_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + calculator.call end end diff --git a/db/migrate/20251224134401_add_calculator_type_to_exchange_rates.rb b/db/migrate/20251224134401_add_calculator_type_to_exchange_rates.rb new file mode 100644 index 0000000..ef11007 --- /dev/null +++ b/db/migrate/20251224134401_add_calculator_type_to_exchange_rates.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddCalculatorTypeToExchangeRates < ActiveRecord::Migration[6.0] + def change + add_column :gera_exchange_rates, :calculator_type, :string, default: 'legacy', null: false + add_index :gera_exchange_rates, :calculator_type + end +end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7a06735 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: 1111 + MYSQL_DATABASE: kassa_admin_test + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1111"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s + command: --default-authentication-plugin=mysql_native_password + +volumes: + mysql_data: diff --git a/lib/gera/configuration.rb b/lib/gera/configuration.rb index 9fa0f25..f0b34db 100644 --- a/lib/gera/configuration.rb +++ b/lib/gera/configuration.rb @@ -48,6 +48,15 @@ def cross_pairs end h end + + # @param [Integer] ID нашего обменника в BestChange (для исключения из расчёта позиции) + mattr_accessor :our_exchanger_id + @@our_exchanger_id = nil + + # @param [Float] Порог аномальной комиссии для защиты от манипуляторов (UC-9) + # Если комиссия отличается от медианы более чем на этот процент - считается аномальной + mattr_accessor :anomaly_threshold_percent + @@anomaly_threshold_percent = 50.0 end end diff --git a/spec/services/gera/autorate_calculators/isolated_spec.rb b/spec/services/gera/autorate_calculators/isolated_spec.rb new file mode 100644 index 0000000..f0522e0 --- /dev/null +++ b/spec/services/gera/autorate_calculators/isolated_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +# Полностью изолированные тесты - не загружают Rails и spec_helper + +require 'rspec' +require 'virtus' + +# Загружаем только необходимые файлы +$LOAD_PATH.unshift File.expand_path('../../../../app/services', __dir__) +$LOAD_PATH.unshift File.expand_path('../../../../lib', __dir__) + +require 'gera/autorate_calculators/base' +require 'gera/autorate_calculators/legacy' +require 'gera/autorate_calculators/position_aware' + +RSpec.describe 'AutorateCalculators (isolated)' do + let(:exchange_rate) { double('ExchangeRate') } + let(:target_autorate_setting) { double('TargetAutorateSetting') } + + before do + allow(exchange_rate).to receive(:target_autorate_setting).and_return(target_autorate_setting) + allow(exchange_rate).to receive(:autorate_from).and_return(1.0) + allow(exchange_rate).to receive(:autorate_to).and_return(3.0) + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + describe Gera::AutorateCalculators::Legacy do + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + context 'с валидными rates' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.8), + double('ExternalRate', target_rate_percent: 3.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'вычитает GAP из первого matching rate' do + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'когда rates пустые' do + let(:external_rates) { [] } + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'возвращает autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + end + + describe Gera::AutorateCalculators::PositionAware do + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + context 'UC-1: все позиции имеют одинаковый курс' do + let(:external_rates) do + 10.times.map { double('ExternalRate', target_rate_percent: 2.5) } + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'не перепрыгивает позицию выше, возвращает ту же комиссию' do + expect(calculator.call).to eq(2.5) + end + end + + context 'UC-2: есть разрыв между позициями' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), + double('ExternalRate', target_rate_percent: 1.2), + double('ExternalRate', target_rate_percent: 1.4), + double('ExternalRate', target_rate_percent: 1.6), + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.6), + double('ExternalRate', target_rate_percent: 2.7), + double('ExternalRate', target_rate_percent: 2.8), + double('ExternalRate', target_rate_percent: 2.9), + double('ExternalRate', target_rate_percent: 3.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'безопасно вычитает GAP когда есть разрыв' do + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-3: целевая позиция 1' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.8), + double('ExternalRate', target_rate_percent: 3.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'вычитает GAP когда нет позиции выше' do + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-4: позиция выше с очень близкой комиссией' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), + double('ExternalRate', target_rate_percent: 1.5), + double('ExternalRate', target_rate_percent: 2.0), + double('ExternalRate', target_rate_percent: 2.4999), + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.8) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'не перепрыгивает позицию 4' do + expect(calculator.call).to eq(2.4999) + end + end + end +end diff --git a/spec/services/gera/autorate_calculators/legacy_spec.rb b/spec/services/gera/autorate_calculators/legacy_spec.rb new file mode 100644 index 0000000..863af17 --- /dev/null +++ b/spec/services/gera/autorate_calculators/legacy_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + module AutorateCalculators + RSpec.describe Legacy do + let(:exchange_rate) { double('ExchangeRate') } + let(:target_autorate_setting) { double('TargetAutorateSetting') } + let(:external_rate_1) { double('ExternalRate', target_rate_percent: 2.5) } + let(:external_rate_2) { double('ExternalRate', target_rate_percent: 2.8) } + let(:external_rate_3) { double('ExternalRate', target_rate_percent: 3.1) } + let(:external_rates) { [external_rate_1, external_rate_2, external_rate_3] } + + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + before do + allow(exchange_rate).to receive(:target_autorate_setting).and_return(target_autorate_setting) + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + allow(exchange_rate).to receive(:autorate_from).and_return(1.0) + allow(exchange_rate).to receive(:autorate_to).and_return(3.0) + end + + describe '#call' do + context 'when could_be_calculated? is false' do + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(false) + end + + it 'returns 0' do + expect(calculator.call).to eq(0) + end + end + + context 'when external_rates is nil' do + let(:external_rates) { nil } + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns 0' do + expect(calculator.call).to eq(0) + end + end + + context 'when external_rates_in_target_position is empty' do + let(:external_rates) { [] } + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + + context 'when no rates match target comission range' do + let(:external_rate_1) { double('ExternalRate', target_rate_percent: 5.0) } + let(:external_rate_2) { double('ExternalRate', target_rate_percent: 6.0) } + let(:external_rate_3) { double('ExternalRate', target_rate_percent: 7.0) } + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + + context 'when rates match target comission range' do + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns first matching rate minus GAP' do + # first matching rate is 2.5, GAP is 0.001 + expect(calculator.call).to eq(2.5 - 0.001) + end + end + end + end + end +end diff --git a/spec/services/gera/autorate_calculators/position_aware_spec.rb b/spec/services/gera/autorate_calculators/position_aware_spec.rb new file mode 100644 index 0000000..6763289 --- /dev/null +++ b/spec/services/gera/autorate_calculators/position_aware_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + module AutorateCalculators + RSpec.describe PositionAware do + let(:exchange_rate) { double('ExchangeRate') } + let(:target_autorate_setting) { double('TargetAutorateSetting') } + + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + before do + allow(exchange_rate).to receive(:target_autorate_setting).and_return(target_autorate_setting) + allow(exchange_rate).to receive(:autorate_from).and_return(1.0) + allow(exchange_rate).to receive(:autorate_to).and_return(3.0) + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + # Сбрасываем конфигурацию перед каждым тестом + Gera.our_exchanger_id = nil + Gera.anomaly_threshold_percent = 50.0 + end + + describe '#call' do + context 'UC-1: все позиции имеют одинаковый курс' do + # Позиции 1-10 все имеют комиссию 2.5 + # position_from: 5, position_to: 10 + # Legacy вычтет GAP и займёт позицию 1 + # PositionAware должен оставить 2.5 и занять позицию 5-10 + + let(:external_rates) do + 10.times.map { double('ExternalRate', target_rate_percent: 2.5) } + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'не перепрыгивает позицию выше' do + # Позиция 4 (index 3) имеет комиссию 2.5 + # Если мы вычтем GAP (2.5 - 0.001 = 2.499), мы станем выше позиции 4 + # PositionAware должен вернуть 2.5 (равную позиции выше) + expect(calculator.call).to eq(2.5) + end + end + + context 'UC-2: есть разрыв между позициями' do + # Позиции 1-4 имеют комиссии 1.0, 1.2, 1.4, 1.6 + # Позиции 5-10 имеют комиссии 2.5, 2.6, 2.7, 2.8, 2.9, 3.0 + # position_from: 5, position_to: 10 + # После вычитания GAP (2.5 - 0.001 = 2.499) мы всё ещё хуже чем позиция 4 (1.6) + # Поэтому безопасно занимать позицию 5 + + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), # pos 1 + double('ExternalRate', target_rate_percent: 1.2), # pos 2 + double('ExternalRate', target_rate_percent: 1.4), # pos 3 + double('ExternalRate', target_rate_percent: 1.6), # pos 4 + double('ExternalRate', target_rate_percent: 2.5), # pos 5 + double('ExternalRate', target_rate_percent: 2.6), # pos 6 + double('ExternalRate', target_rate_percent: 2.7), # pos 7 + double('ExternalRate', target_rate_percent: 2.8), # pos 8 + double('ExternalRate', target_rate_percent: 2.9), # pos 9 + double('ExternalRate', target_rate_percent: 3.0) # pos 10 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'безопасно вычитает GAP' do + # 2.5 - 0.001 = 2.499 > 1.6 (позиция 4) + # Не перепрыгиваем, возвращаем target - GAP + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-3: целевая позиция 1' do + # Когда position_from = 1, нет позиции выше + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 2.5), # pos 1 + double('ExternalRate', target_rate_percent: 2.8), # pos 2 + double('ExternalRate', target_rate_percent: 3.0) # pos 3 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'безопасно вычитает GAP' do + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-4: позиция выше с очень близкой комиссией' do + # Позиция 4 имеет комиссию 2.4999 + # Позиция 5 имеет комиссию 2.5 + # 2.5 - 0.001 = 2.499 > 2.4999 - мы перепрыгнем! + # PositionAware должен скорректировать + + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), # pos 1 + double('ExternalRate', target_rate_percent: 1.5), # pos 2 + double('ExternalRate', target_rate_percent: 2.0), # pos 3 + double('ExternalRate', target_rate_percent: 2.4999), # pos 4 + double('ExternalRate', target_rate_percent: 2.5), # pos 5 + double('ExternalRate', target_rate_percent: 2.8) # pos 6 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'не перепрыгивает позицию 4' do + # 2.5 - 0.001 = 2.499 < 2.4999, значит перепрыгнем + # Должны вернуть min(2.4999, 2.5) = 2.4999 + expect(calculator.call).to eq(2.4999) + end + end + + context 'UC-6: адаптивный GAP для плотного рейтинга' do + # Разница между позициями 4 и 5 = 0.0005 (меньше стандартного GAP 0.001) + # Должен использоваться адаптивный GAP = 0.0005 / 2 = 0.00025 + + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), # pos 1 + double('ExternalRate', target_rate_percent: 1.5), # pos 2 + double('ExternalRate', target_rate_percent: 2.0), # pos 3 + double('ExternalRate', target_rate_percent: 2.4995), # pos 4 + double('ExternalRate', target_rate_percent: 2.5), # pos 5 + double('ExternalRate', target_rate_percent: 2.8) # pos 6 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'использует адаптивный GAP' do + # diff = 2.5 - 2.4995 = 0.0005 < 0.001 + # adaptive_gap = 0.0005 / 2 = 0.00025 + # target = 2.5 - 0.00025 = 2.49975 + # 2.49975 > 2.4995 - не перепрыгиваем + expect(calculator.call).to be_within(0.0000001).of(2.49975) + end + end + + context 'UC-6: минимальный GAP' do + # Разница между позициями очень маленькая (0.00005) + # Должен использоваться MIN_GAP = 0.0001 + + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), # pos 1 + double('ExternalRate', target_rate_percent: 1.5), # pos 2 + double('ExternalRate', target_rate_percent: 2.0), # pos 3 + double('ExternalRate', target_rate_percent: 2.49995), # pos 4 + double('ExternalRate', target_rate_percent: 2.5), # pos 5 + double('ExternalRate', target_rate_percent: 2.8) # pos 6 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'использует минимальный GAP' do + # diff = 2.5 - 2.49995 = 0.00005 + # adaptive_gap = 0.00005 / 2 = 0.000025 < MIN_GAP (0.0001) + # используем MIN_GAP = 0.0001 + # target = 2.5 - 0.0001 = 2.4999 + # 2.4999 < 2.49995 - перепрыгиваем! Корректируем до 2.49995 + expect(calculator.call).to eq(2.49995) + end + end + + context 'UC-8: наш обменник в рейтинге' do + # Наш обменник на позиции 3, мы должны его игнорировать + + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0, exchanger_id: 101), # pos 1 + double('ExternalRate', target_rate_percent: 1.5, exchanger_id: 102), # pos 2 + double('ExternalRate', target_rate_percent: 2.0, exchanger_id: 999), # pos 3 - наш + double('ExternalRate', target_rate_percent: 2.3, exchanger_id: 103), # pos 4 + double('ExternalRate', target_rate_percent: 2.5, exchanger_id: 104), # pos 5 + double('ExternalRate', target_rate_percent: 2.8, exchanger_id: 105) # pos 6 + ] + end + + before do + Gera.our_exchanger_id = 999 + allow(exchange_rate).to receive(:position_from).and_return(4) + allow(exchange_rate).to receive(:position_to).and_return(5) + end + + it 'исключает наш обменник из расчёта' do + # После фильтрации: позиции пересчитываются без нашего обменника (id=999) + # Новые позиции: 1.0, 1.5, 2.3, 2.5, 2.8 + # position_from=4 -> 2.5 (index 3) + # position_to=5 -> 2.8 (index 4) + # target = 2.5 - GAP = 2.499 + # rate_above (pos 3) = 2.3, 2.499 > 2.3 - не перепрыгиваем + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-9: манипуляторы с аномальными курсами' do + # Позиции 1-3 имеют нереально низкие комиссии (манипуляторы) + # Они должны игнорироваться при проверке перепрыгивания + + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 0.1), # pos 1 - манипулятор + double('ExternalRate', target_rate_percent: 0.2), # pos 2 - манипулятор + double('ExternalRate', target_rate_percent: 0.3), # pos 3 - манипулятор + double('ExternalRate', target_rate_percent: 2.0), # pos 4 - нормальный + double('ExternalRate', target_rate_percent: 2.5), # pos 5 + double('ExternalRate', target_rate_percent: 2.6), # pos 6 + double('ExternalRate', target_rate_percent: 2.7), # pos 7 + double('ExternalRate', target_rate_percent: 2.8), # pos 8 + double('ExternalRate', target_rate_percent: 2.9), # pos 9 + double('ExternalRate', target_rate_percent: 3.0) # pos 10 + ] + end + + before do + Gera.anomaly_threshold_percent = 50.0 + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'игнорирует манипуляторов при проверке перепрыгивания' do + # Медиана комиссий ≈ 2.5 + # Комиссии 0.1, 0.2, 0.3 отклоняются от медианы > 50% + # После фильтрации аномалий: 2.0, 2.5, 2.6, 2.7, 2.8, 2.9, 3.0 + # position_from=5 -> индекс 4 после фильтрации + # rate_above в clean_rates = 2.8 (индекс 3) + # target = 2.5 - 0.001 = 2.499 < 2.8 - не перепрыгиваем реальных конкурентов + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'when external_rates is empty' do + let(:external_rates) { [] } + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + + context 'when no rates match target comission range' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 5.0), + double('ExternalRate', target_rate_percent: 6.0), + double('ExternalRate', target_rate_percent: 7.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + end + end + end +end diff --git a/spec/services/gera/autorate_calculators/standalone_spec.rb b/spec/services/gera/autorate_calculators/standalone_spec.rb new file mode 100644 index 0000000..35e6502 --- /dev/null +++ b/spec/services/gera/autorate_calculators/standalone_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# Standalone тесты для калькуляторов автокурса +# Не требуют полной загрузки Rails + +require 'virtus' + +# Загружаем только необходимые файлы +require_relative '../../../../app/services/gera/autorate_calculators/base' +require_relative '../../../../app/services/gera/autorate_calculators/legacy' +require_relative '../../../../app/services/gera/autorate_calculators/position_aware' + +RSpec.describe 'AutorateCalculators' do + let(:exchange_rate) { double('ExchangeRate') } + let(:target_autorate_setting) { double('TargetAutorateSetting') } + + before do + allow(exchange_rate).to receive(:target_autorate_setting).and_return(target_autorate_setting) + allow(exchange_rate).to receive(:autorate_from).and_return(1.0) + allow(exchange_rate).to receive(:autorate_to).and_return(3.0) + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + describe Gera::AutorateCalculators::Legacy do + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + context 'с валидными rates' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.8), + double('ExternalRate', target_rate_percent: 3.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'вычитает GAP из первого matching rate' do + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'когда rates пустые' do + let(:external_rates) { [] } + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'возвращает autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + end + + describe Gera::AutorateCalculators::PositionAware do + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + context 'UC-1: все позиции имеют одинаковый курс' do + let(:external_rates) do + 10.times.map { double('ExternalRate', target_rate_percent: 2.5) } + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'не перепрыгивает позицию выше, возвращает ту же комиссию' do + # Позиция 4 имеет 2.5, после GAP было бы 2.499 < 2.5 - перепрыгнем! + # PositionAware должен вернуть 2.5 + expect(calculator.call).to eq(2.5) + end + end + + context 'UC-2: есть разрыв между позициями' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), + double('ExternalRate', target_rate_percent: 1.2), + double('ExternalRate', target_rate_percent: 1.4), + double('ExternalRate', target_rate_percent: 1.6), + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.6), + double('ExternalRate', target_rate_percent: 2.7), + double('ExternalRate', target_rate_percent: 2.8), + double('ExternalRate', target_rate_percent: 2.9), + double('ExternalRate', target_rate_percent: 3.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'безопасно вычитает GAP когда есть разрыв' do + # 2.5 - 0.001 = 2.499 > 1.6 - не перепрыгиваем + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-3: целевая позиция 1' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.8), + double('ExternalRate', target_rate_percent: 3.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'вычитает GAP когда нет позиции выше' do + expect(calculator.call).to eq(2.5 - 0.001) + end + end + + context 'UC-4: позиция выше с очень близкой комиссией' do + let(:external_rates) do + [ + double('ExternalRate', target_rate_percent: 1.0), + double('ExternalRate', target_rate_percent: 1.5), + double('ExternalRate', target_rate_percent: 2.0), + double('ExternalRate', target_rate_percent: 2.4999), + double('ExternalRate', target_rate_percent: 2.5), + double('ExternalRate', target_rate_percent: 2.8) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'не перепрыгивает позицию 4' do + # 2.5 - 0.001 = 2.499 < 2.4999, значит перепрыгнем + # Должны вернуть min(2.4999, 2.5) = 2.4999 + expect(calculator.call).to eq(2.4999) + end + end + end +end