Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion app/models/gera/exchange_rate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
44 changes: 44 additions & 0 deletions app/services/gera/autorate_calculators/base.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions app/services/gera/autorate_calculators/legacy.rb
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions app/services/gera/autorate_calculators/position_aware.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 5 additions & 9 deletions app/services/gera/rate_comission_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
9 changes: 9 additions & 0 deletions lib/gera/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Добавь в README пример как пользоваться этой конфигурацией

end
end

Expand Down
Loading
Loading