diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml new file mode 100644 index 00000000..616e29ba --- /dev/null +++ b/.github/workflows/rspec.yml @@ -0,0 +1,71 @@ +name: RSpec Tests + +on: + push: + branches: [ master, main, develop ] + pull_request: + branches: [ master, main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: gera_test + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + ports: + - 3306:3306 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.7 + bundler-cache: true + cache-version: 2 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y mysql-client + bundle install --jobs 4 --retry 3 + + - name: Setup database + env: + RAILS_ENV: test + MYSQL_HOST: localhost + MYSQL_USER: root + MYSQL_PASSWORD: root + MYSQL_DATABASE: gera_test + run: | + cd spec/dummy + bundle exec rake db:create + bundle exec rake db:schema:load + + - name: Run RSpec tests + env: + RAILS_ENV: test + MYSQL_HOST: localhost + MYSQL_USER: root + MYSQL_PASSWORD: root + MYSQL_DATABASE: gera_test + run: | + bundle exec rspec + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage/coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 9fa7c878..0ba45e21 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ spec/dummy/tmp/ *.log .yardoc/ .byebug_history +.claude/settings.local.json diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..2aa51319 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.7 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f534c10a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Gera is a Rails engine for generating and managing currency exchange rates for crypto changers and markets. It collects rates from external sources, builds currency rate matrices, and calculates final rates for payment systems with commissions. + +## Core Architecture + +### Rate Flow Hierarchy +1. **ExternalRate** - Raw rates from external sources (EXMO, Bitfinex, Binance, CBR, etc.) +2. **CurrencyRate** - Basic currency rates calculated from external rates using different modes (direct, inverse, cross) +3. **DirectionRate** - Final rates for specific payment system pairs with commissions applied +4. **ExchangeRate** - Configuration for commissions between payment systems + +### Key Models +- **RateSource** - External rate providers with STI subclasses (RateSourceExmo, RateSourceBitfinex, etc.) +- **PaymentSystem** - Payment systems with currencies and commissions +- **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 + +## Development Commands + +### Running Tests +```bash +# Run all tests +bundle exec rake spec + +# Run specific test file +bundle exec rspec spec/models/gera/currency_rate_spec.rb + +# Run with focus +bundle exec rspec --tag focus +``` + +### Building and Development +```bash +# Install dependencies +bundle install + +# Run dummy app for testing +cd spec/dummy && rails server + +# Generate documentation +bundle exec yard + +# Clean database between tests (uses DatabaseRewinder) +``` + +### Code Quality +```bash +# Lint code +bundle exec rubocop + +# Auto-correct linting issues +bundle exec rubocop -a +``` + +## Configuration + +Create `./config/initializers/gera.rb`: +```ruby +Gera.configure do |config| + config.cross_pairs = { kzt: :rub, eur: :rub } + config.default_cross_currency = :usd +end +``` + +## Key Business Logic + +### Rate Calculation Modes +- **direct** - Direct rate from external source +- **inverse** - Inverted rate (1/rate) +- **same** - Same currency (rate = 1) +- **cross** - Calculated through intermediate currency + +### Supported Currencies +RUB, USD, BTC, LTC, ETH, DSH, KZT, XRP, ETC, XMR, BCH, EUR, NEO, ZEC + +### External Rate Sources +- EXMO, Bitfinex, Binance, GarantexIO +- Russian Central Bank (CBR) +- Manual rates and FF (fixed/float) sources + +## Testing Notes + +- Uses dummy Rails app in `spec/dummy/` +- Factory Bot for test data in `factories/` +- VCR for HTTP request mocking +- Database Rewinder for fast test cleanup + +## File Organization + +- `app/models/gera/` - Core domain models +- `app/workers/gera/` - Background job workers +- `lib/gera/` - Core engine logic and utilities +- `lib/builders/` - Rate calculation builders +- `spec/` - Test suite with dummy app diff --git a/Gemfile b/Gemfile index dbc9f486..2ff2ce9e 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } # development dependencies will be added by default to the :development group. gemspec -gem 'rails', '~> 6.0.6' +gem 'rails', '~> 8.0' gem 'dapi-archivable', '~> 0.1.2', require: 'archivable' gem 'active_link_to', github: 'BrandyMint/active_link_to' gem 'noty_flash', github: 'BrandyMint/noty_flash' diff --git a/Gemfile.lock b/Gemfile.lock index 90799180..847be7f0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,97 +15,120 @@ GIT PATH remote: . specs: - gera (0.3.3) + gera (1.2.0) active_link_to + alias_association authority auto_logger (~> 0.1.4) best_in_place breadcrumbs_on_rails business_time dapi-archivable - draper (~> 3.1.0) + draper kaminari money money-rails noty_flash percentable - psych - rails (~> 6.0.6) + psych (~> 3.1.0) + rails request_store require_all rest-client (~> 2.0) - sidekiq simple_form + solid_queue virtus GEM remote: https://rubygems.org/ specs: - actioncable (6.0.6.1) - actionpack (= 6.0.6.1) + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.6.1) - actionpack (= 6.0.6.1) - activejob (= 6.0.6.1) - activerecord (= 6.0.6.1) - activestorage (= 6.0.6.1) - activesupport (= 6.0.6.1) - mail (>= 2.7.1) - actionmailer (6.0.6.1) - actionpack (= 6.0.6.1) - actionview (= 6.0.6.1) - activejob (= 6.0.6.1) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.0.6.1) - actionview (= 6.0.6.1) - activesupport (= 6.0.6.1) - rack (~> 2.0, >= 2.0.8) + zeitwerk (~> 2.6) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.6.1) - actionpack (= 6.0.6.1) - activerecord (= 6.0.6.1) - activestorage (= 6.0.6.1) - activesupport (= 6.0.6.1) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.0.6.1) - activesupport (= 6.0.6.1) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.6.1) - activesupport (= 6.0.6.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - activemodel (6.0.6.1) - activesupport (= 6.0.6.1) - activemodel-serializers-xml (1.0.2) - activemodel (> 5.x) - activesupport (> 5.x) + activemodel (8.1.1) + activesupport (= 8.1.1) + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) builder (~> 3.1) - activerecord (6.0.6.1) - activemodel (= 6.0.6.1) - activesupport (= 6.0.6.1) - activestorage (6.0.6.1) - actionpack (= 6.0.6.1) - activejob (= 6.0.6.1) - activerecord (= 6.0.6.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) + timeout (>= 0.4.0) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (6.0.6.1) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2, >= 2.2.2) - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - ast (2.4.2) + activesupport (8.1.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + alias_association (1.0.0) + activerecord (>= 6.0) + activesupport (>= 6.0) + ast (2.4.3) authority (3.3.0) activesupport (>= 3.0.0) - auto_logger (0.1.7) + auto_logger (0.1.8) activesupport beautiful-log awesome_print (1.8.0) @@ -113,62 +136,75 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + base64 (0.3.0) beautiful-log (0.2.2) awesome_print (~> 1.8.0) colorize (~> 0.8.1) - best_in_place (3.1.1) - actionpack (>= 3.2) - railties (>= 3.2) + best_in_place (4.0.0) + actionpack (>= 7.0) + railties (>= 7.0) + bigdecimal (3.3.1) breadcrumbs_on_rails (4.1.0) railties (>= 5.0) - builder (3.2.4) + builder (3.3.0) business_time (0.13.0) activesupport (>= 3.2.0) tzinfo - byebug (11.1.3) + byebug (12.0.0) coderay (1.1.3) coercible (1.0.0) descendants_tracker (~> 0.0.1) colorize (0.8.1) - concurrent-ruby (1.2.2) - connection_pool (2.4.1) - crack (0.4.5) + concurrent-ruby (1.3.5) + connection_pool (2.5.5) + crack (1.0.1) + bigdecimal rexml crass (1.0.6) dapi-archivable (0.1.3) activerecord activesupport - database_rewinder (0.9.8) - date (3.3.3) + database_rewinder (1.1.0) + date (3.5.0) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) - diff-lcs (1.5.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - draper (3.1.0) + diff-lcs (1.6.2) + domain_name (0.6.20240107) + draper (4.0.6) actionpack (>= 5.0) activemodel (>= 5.0) activemodel-serializers-xml (>= 1.0) activesupport (>= 5.0) request_store (>= 1.0) - erubi (1.12.0) - factory_bot (6.2.1) - activesupport (>= 5.0.0) - ffi (1.15.5) - formatador (1.1.0) - globalid (1.1.0) - activesupport (>= 5.0) - guard (2.18.0) + ruby2_keywords + drb (2.2.3) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + factory_bot (6.5.6) + activesupport (>= 6.1.0) + ffi (1.17.2) + ffi (1.17.2-x86_64-linux-gnu) + formatador (1.2.3) + reline + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + guard (2.19.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) + logger (~> 1.6) lumberjack (>= 1.0.12, < 2.0) nenv (~> 0.1) notiffany (~> 0.0) + ostruct (~> 0.6) pry (>= 0.13.0) shellany (~> 0.0) thor (>= 0.18.1) - guard-bundler (2.2.1) - bundler (>= 1.3.0, < 3) + guard-bundler (3.0.1) + bundler (>= 2.1, < 3) guard (~> 2.2) guard-compat (~> 1.1) guard-compat (1.2.1) @@ -182,14 +218,19 @@ GEM guard-rubocop (1.5.0) guard (~> 2.0) rubocop (< 2.0) - hashdiff (1.0.1) + hashdiff (1.2.1) http-accept (1.7.0) - http-cookie (1.0.5) + http-cookie (1.1.0) domain_name (~> 0.5) - i18n (1.13.0) + i18n (1.14.7) concurrent-ruby (~> 1.0) ice_nine (0.11.2) - json (2.6.3) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.16.0) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -202,112 +243,133 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - listen (3.8.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.21.3) + logger (1.7.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lumberjack (1.2.8) - mail (2.8.1) + lumberjack (1.4.2) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.2) - method_source (1.0.0) - mime-types (3.4.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2023.0218.1) - mini_mime (1.1.2) - mini_portile2 (2.8.2) - minitest (5.18.0) - monetize (1.12.0) + marcel (1.1.0) + method_source (1.1.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0924) + mini_mime (1.1.5) + minitest (5.26.2) + monetize (1.13.0) money (~> 6.12) - money (6.16.0) + money (6.19.0) i18n (>= 0.6.4, <= 2) money-rails (1.15.0) activesupport (>= 3.0) monetize (~> 1.9) money (~> 6.13) railties (>= 3.0) - mysql2 (0.5.5) + mysql2 (0.5.7) + bigdecimal nenv (0.3.0) - net-imap (0.3.4) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-smtp (0.3.3) + net-smtp (0.5.1) net-protocol netrc (0.11.0) - nio4r (2.5.9) - nokogiri (1.15.2) - mini_portile2 (~> 2.8.2) + nio4r (2.7.5) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) - parallel (1.23.0) - parser (3.2.2.1) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.0) ast (~> 2.4.1) + racc percentable (1.1.2) - pg (1.5.3) - pry (0.14.2) + pg (1.6.2) + pg (1.6.2-x86_64-linux) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.10.1) - byebug (~> 11.0) - pry (>= 0.13, < 0.15) - pry-doc (1.4.0) + pry-byebug (3.11.0) + byebug (~> 12.0) + pry (>= 0.13, < 0.16) + pry-doc (1.6.0) pry (~> 0.11) yard (~> 0.9.11) - pry-rails (0.3.9) - pry (>= 0.10.4) - psych (5.1.0) - stringio - public_suffix (5.0.1) - racc (1.6.2) - rack (2.2.7) - rack-test (2.1.0) + pry-rails (0.3.11) + pry (>= 0.13.0) + psych (3.1.0) + public_suffix (7.0.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) rack (>= 1.3) - rails (6.0.6.1) - actioncable (= 6.0.6.1) - actionmailbox (= 6.0.6.1) - actionmailer (= 6.0.6.1) - actionpack (= 6.0.6.1) - actiontext (= 6.0.6.1) - actionview (= 6.0.6.1) - activejob (= 6.0.6.1) - activemodel (= 6.0.6.1) - activerecord (= 6.0.6.1) - activestorage (= 6.0.6.1) - activesupport (= 6.0.6.1) - bundler (>= 1.3.0) - railties (= 6.0.6.1) - sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rackup (2.2.1) + rack (>= 3) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + bundler (>= 1.15.0) + railties (= 8.1.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - railties (6.0.6.1) - actionpack (= 6.0.6.1) - activesupport (= 6.0.6.1) - method_source - rake (>= 0.8.7) - thor (>= 0.20.3, < 2.0) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.0.6) + rake (13.3.1) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) - redis-client (0.14.1) - connection_pool - regexp_parser (2.8.0) - request_store (1.5.1) + rdoc (6.3.4.1) + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + request_store (1.7.0) rack (>= 1.4) require_all (3.0.0) rest-client (2.1.0) @@ -315,95 +377,93 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.2.5) - rspec (3.9.0) - rspec-core (~> 3.9.0) - rspec-expectations (~> 3.9.0) - rspec-mocks (~> 3.9.0) - rspec-core (3.9.3) - rspec-support (~> 3.9.3) - rspec-expectations (3.9.4) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-mocks (3.9.1) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-rails (3.9.1) - actionpack (>= 3.0) - activesupport (>= 3.0) - railties (>= 3.0) - rspec-core (~> 3.9.0) - rspec-expectations (~> 3.9.0) - rspec-mocks (~> 3.9.0) - rspec-support (~> 3.9.0) - rspec-support (3.9.4) - rubocop (1.51.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.6) + rubocop (1.81.7) json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.1) - parser (>= 3.2.1.0) - rubocop-capybara (2.18.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.23.1) - rubocop (~> 1.33) - rubocop-rspec (2.22.0) - rubocop (~> 1.33) - rubocop-capybara (~> 2.17) - rubocop-factory_bot (~> 2.22) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-rspec (3.8.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + securerandom (0.4.1) shellany (0.0.1) - sidekiq (7.1.1) - concurrent-ruby (< 2) - connection_pool (>= 2.3.0) - rack (>= 2.2.4) - redis-client (>= 0.14.0) - simple_form (5.2.0) - actionpack (>= 5.2) - activemodel (>= 5.2) - sprockets (4.2.0) - concurrent-ruby (~> 1.0) - rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - sprockets (>= 3.0.0) - stringio (3.0.6) - thor (1.2.2) + simple_form (5.4.0) + actionpack (>= 7.0) + activemodel (>= 7.0) + solid_queue (1.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.8.0-x86_64-linux-gnu) + thor (1.4.0) thread_safe (0.3.6) - timecop (0.9.6) - timeout (0.3.2) - tzinfo (1.2.11) - thread_safe (~> 0.1) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.4.2) - vcr (6.1.0) + timecop (0.9.10) + timeout (0.4.4) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) + useragent (0.16.11) + vcr (6.3.1) + base64 virtus (2.0.0) axiom-types (~> 0.1) coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) - webmock (3.18.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.5) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - yard (0.9.34) + yard (0.9.37) yard-rspec (0.1) yard - zeitwerk (2.6.8) + zeitwerk (2.7.3) PLATFORMS - ruby + x86_64-linux DEPENDENCIES active_link_to! @@ -423,10 +483,11 @@ DEPENDENCIES pry-byebug pry-doc pry-rails - rails (~> 6.0.6) - rspec-rails (~> 3.7) + rails (~> 8.0) + rspec-rails (~> 6.0) rubocop rubocop-rspec + sqlite3 timecop vcr webmock @@ -434,4 +495,4 @@ DEPENDENCIES yard-rspec BUNDLED WITH - 1.17.3 + 2.7.2 diff --git a/README.md b/README.md index 61b3a975..1a0e70f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Gera -[![Build Status](https://travis-ci.org/finfex/gera.svg?branch=master)](https://travis-ci.org/finfex/gera) +[![RSpec Tests](https://github.com/alfagen/gera/actions/workflows/rspec.yml/badge.svg)](https://github.com/alfagen/gera/actions/workflows/rspec.yml) Multiple rates generator for crypto changers and markets. diff --git a/app/controllers/gera/currency_rate_mode_snapshots_controller.rb b/app/controllers/gera/currency_rate_mode_snapshots_controller.rb index 3cc2d023..cc98d614 100644 --- a/app/controllers/gera/currency_rate_mode_snapshots_controller.rb +++ b/app/controllers/gera/currency_rate_mode_snapshots_controller.rb @@ -37,7 +37,7 @@ def activate CurrencyRateModeSnapshot.status_active.update_all status: :deactive snapshot.update status: :active end - CurrencyRatesWorker.perform_async if Rails.env.production? + CurrencyRatesJob.perform_later if Rails.env.production? flash[:success] = 'Режимы активированы' redirect_to currency_rate_mode_snapshot_path snapshot end diff --git a/app/controllers/gera/direction_rate_history_intervals_controller.rb b/app/controllers/gera/direction_rate_history_intervals_controller.rb index 6228d348..1314165c 100644 --- a/app/controllers/gera/direction_rate_history_intervals_controller.rb +++ b/app/controllers/gera/direction_rate_history_intervals_controller.rb @@ -6,6 +6,7 @@ class DirectionRateHistoryIntervalsController < ApplicationController authorize_actions_for DirectionRate helper_method :payment_system_from, :payment_system_to helper_method :filter + helper_method :history_intervals_enabled? def index respond_to do |format| @@ -58,5 +59,9 @@ def intervals raise "Unknown value_type #{filter.value_type}" end end + + def history_intervals_enabled? + Gera.enable_direction_rate_history_intervals + end end end diff --git a/app/controllers/gera/external_rate_snapshots_controller.rb b/app/controllers/gera/external_rate_snapshots_controller.rb index 686e6cd9..b0aa450e 100644 --- a/app/controllers/gera/external_rate_snapshots_controller.rb +++ b/app/controllers/gera/external_rate_snapshots_controller.rb @@ -5,7 +5,7 @@ module Gera class ExternalRateSnapshotsController < ApplicationController authorize_actions_for ExchangeRate - PER_PAGE = 200 + PER_PAGE = 25 helper_method :rate_source def index diff --git a/app/jobs/concerns/gera/rates_job.rb b/app/jobs/concerns/gera/rates_job.rb new file mode 100644 index 00000000..f86d1bc6 --- /dev/null +++ b/app/jobs/concerns/gera/rates_job.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'rest-client' + +module Gera + module RatesJob + extend ActiveSupport::Concern + + Error = Class.new(StandardError) + + def perform + logger.debug "RatesJob: before perform for #{rate_source.class.name}" + ActiveRecord::Base.connection.clear_query_cache + + @rates = load_rates + create_rate_source_snapshot + save_all_rates + rate_source_snapshot.id + rescue ActiveRecord::RecordNotUnique, RestClient::TooManyRequests => error + raise error if Rails.env.test? + + logger.error error + Bugsnag.notify(error) do |b| + b.severity = :warning + b.meta_data = { error: error } + end + end + + private + + attr_reader :rate_source_snapshot, :rates + delegate :actual_for, to: :rate_source_snapshot + + def create_rate_source_snapshot + @rate_source_snapshot ||= rate_source.snapshots.create!(actual_for: Time.zone.now) + end + + def save_all_rates + batched_rates = rates.each_with_object({}) do |(pair, data), hash| + buy_key, sell_key = rate_keys.values_at(:buy, :sell) + + buy_price = data.is_a?(Array) ? data[buy_key] : data[buy_key.to_s] + sell_price = data.is_a?(Array) ? data[sell_key] : data[sell_key.to_s] + + next unless buy_price && sell_price + + # Convert CurrencyPair to string for JSON serialization + pair_str = pair.respond_to?(:to_str) ? pair.to_str : pair.to_s + hash[pair_str] = { 'buy' => buy_price.to_f, 'sell' => sell_price.to_f } + end + + ExternalRatesBatchJob.perform_later( + rate_source_snapshot.id, + rate_source.id, + batched_rates + ) + end + + def rate_keys + raise NotImplementedError, 'You must define #rate_keys in your job' + end + end +end diff --git a/app/jobs/gera/application_job.rb b/app/jobs/gera/application_job.rb index e1637ab4..52d9f522 100644 --- a/app/jobs/gera/application_job.rb +++ b/app/jobs/gera/application_job.rb @@ -2,5 +2,7 @@ module Gera class ApplicationJob < ActiveJob::Base + # SolidQueue's engine automatically includes ActiveJob::ConcurrencyControls + # which provides limits_concurrency method end end diff --git a/app/jobs/gera/binance_rates_job.rb b/app/jobs/gera/binance_rates_job.rb new file mode 100644 index 00000000..7c1d3272 --- /dev/null +++ b/app/jobs/gera/binance_rates_job.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Gera + class BinanceRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + limits_concurrency to: 1, key: ->(*) { 'gera_binance_rates' }, duration: 1.minute + + def perform + # Check if we should approve new rates based on count + unless should_approve_new_rates? + logger.debug "BinanceRatesJob: Rate counts don't match, skipping" + return nil + end + + super + end + + private + + def rate_source + @rate_source ||= Gera::RateSourceBinance.get! + end + + def load_rates + Gera::BinanceFetcher.new.perform + end + + def rate_keys + { buy: 'bidPrice', sell: 'askPrice' } + end + + def should_approve_new_rates? + # Always approve if no current snapshot + return true unless rate_source.actual_snapshot_id + + current_rates_count = rate_source.actual_snapshot.external_rates.count + new_rates_count = load_rates.size + + logger.info "BinanceRatesJob: current_rates_count=#{current_rates_count}, new_rates_count=#{new_rates_count}" + + # Only approve if counts match + current_rates_count == new_rates_count + end + end +end diff --git a/app/workers/gera/bitfinex_rates_worker.rb b/app/jobs/gera/bitfinex_rates_job.rb similarity index 51% rename from app/workers/gera/bitfinex_rates_worker.rb rename to app/jobs/gera/bitfinex_rates_job.rb index 52dd5797..49f331ca 100644 --- a/app/workers/gera/bitfinex_rates_worker.rb +++ b/app/jobs/gera/bitfinex_rates_job.rb @@ -3,26 +3,24 @@ module Gera # Import rates from Bitfinex # - class BitfinexRatesWorker - include Sidekiq::Worker + class BitfinexRatesJob < ApplicationJob include AutoLogger - prepend RatesWorker - - # sidekiq_options lock: :until_executed + include RatesJob private def rate_source - @rate_source ||= RateSourceBitfinex.get! + @rate_source ||= Gera::RateSourceBitfinex.get! end - # ["tXMRBTC", 0.0023815, 1026.97384923, 0.0023839, 954.7667526, -0.0000029, -0.00121619, 0.0023816, 3944.20608752, 0.0024229, 0.0022927] - def save_rate(pair, data) - create_external_rates pair, data, sell_price: data[7], buy_price: data[7] + def load_rates + Gera::BitfinexFetcher.new.perform end - def load_rates - BitfinexFetcher.new.perform + # ["tXMRBTC", 0.0023815, 1026.97384923, 0.0023839, 954.7667526, -0.0000029, -0.00121619, 0.0023816, 3944.20608752, 0.0024229, 0.0022927] + + def rate_keys + { buy: 7, sell: 7 } end end end diff --git a/app/jobs/gera/bybit_rates_job.rb b/app/jobs/gera/bybit_rates_job.rb new file mode 100644 index 00000000..29ec1db3 --- /dev/null +++ b/app/jobs/gera/bybit_rates_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Gera + # Import rates from Bybit + # + class BybitRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + private + + def rate_source + @rate_source ||= Gera::RateSourceBybit.get! + end + + def load_rates + Gera::BybitFetcher.new.perform + end + + def rate_keys + { buy: 'price', sell: 'price' } + end + end +end diff --git a/app/workers/gera/cbr_avg_rates_worker.rb b/app/jobs/gera/cbr_avg_rates_job.rb similarity index 71% rename from app/workers/gera/cbr_avg_rates_worker.rb rename to app/jobs/gera/cbr_avg_rates_job.rb index a1ddb5d2..20165cb4 100644 --- a/app/workers/gera/cbr_avg_rates_worker.rb +++ b/app/jobs/gera/cbr_avg_rates_job.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true module Gera - class CbrAvgRatesWorker - include Sidekiq::Worker + class CbrAvgRatesJob < ApplicationJob include AutoLogger - sidekiq_options lock: :until_executed + limits_concurrency to: 1, key: ->(*) { 'gera_cbr_avg_rates' }, duration: 1.minute def perform ActiveRecord::Base.connection.clear_query_cache @@ -20,7 +19,7 @@ def perform private def source - @source ||= RateSourceCbrAvg.get! + @source ||= Gera::RateSourceCbrAvg.get! end def snapshot @@ -28,11 +27,11 @@ def snapshot end def create_rate(pair) - er = RateSource.cbr.find_rate_by_currency_pair pair + er = Gera::RateSource.cbr.find_rate_by_currency_pair pair price = (er.sell_price + er.buy_price) / 2.0 - ExternalRate.create!( + Gera::ExternalRate.create!( source: source, snapshot: snapshot, currency_pair: pair, diff --git a/app/workers/gera/cbr_rates_worker.rb b/app/jobs/gera/cbr_rates_job.rb similarity index 80% rename from app/workers/gera/cbr_rates_worker.rb rename to app/jobs/gera/cbr_rates_job.rb index 86e93b8e..42203ae9 100644 --- a/app/workers/gera/cbr_rates_worker.rb +++ b/app/jobs/gera/cbr_rates_job.rb @@ -1,19 +1,17 @@ # frozen_string_literal: true -require 'open-uri' require 'business_time' module Gera # Import rates from Russian Central Bank # http://www.cbr.ru/scripts/XML_daily.asp?date_req=08/04/2018 # - class CbrRatesWorker - include Sidekiq::Worker + class CbrRatesJob < ApplicationJob include AutoLogger - # sidekiq_options lock: :until_executed + # limits_concurrency to: 1, key: -> { 'gera_cbr_rates' }, duration: 1.minute - CURRENCIES = %w[USD KZT EUR UAH UZS AZN BYN TRY].freeze + CURRENCIES = %w[USD KZT EUR UAH UZS AZN BYN TRY THB IDR].freeze CBR_IDS = { 'USD' => 'R01235', @@ -23,7 +21,9 @@ class CbrRatesWorker 'UZS' => 'R01717', 'AZN' => 'R01020A', 'BYN' => 'R01090B', - 'TRY' => 'R01700J' + 'TRY' => 'R01700J', + 'THB' => 'R01675', + 'IDR' => 'R01280' }.freeze ROUND = 15 @@ -34,18 +34,18 @@ class CbrRatesWorker URL = 'https://pay.hub.pp.ru/api/cbr' def perform - logger.debug 'CbrRatesWorker: before perform' + logger.debug 'CbrRatesJob: before perform' ActiveRecord::Base.connection.clear_query_cache rates_by_date = load_rates - logger.debug 'CbrRatesWorker: before transaction' + logger.debug 'CbrRatesJob: before transaction' ActiveRecord::Base.transaction do rates_by_date.each do |date, rates| save_rates(date, rates) end end - logger.debug 'CbrRatesWorker: after transaction' + logger.debug 'CbrRatesJob: after transaction' make_snapshot - logger.debug 'CbrRatesWorker: after perform' + logger.debug 'CbrRatesJob: after perform' end private @@ -74,9 +74,9 @@ def save_snapshot_rates end def save_snapshot_rate(cur_from, cur_to) - pair = CurrencyPair.new cur_from, cur_to + pair = Gera::CurrencyPair.new cur_from, cur_to - min_rate, max_rate = CbrExternalRate + min_rate, max_rate = Gera::CbrExternalRate .where(cur_from: cur_from.iso_code, cur_to: cur_to.iso_code) .order('date asc') .last(2) @@ -85,14 +85,14 @@ def save_snapshot_rate(cur_from, cur_to) raise "No minimal rate #{cur_from}, #{cur_to}" unless min_rate raise "No maximal rate #{cur_from}, #{cur_to}" unless max_rate - ExternalRate.create!( + Gera::ExternalRate.create!( source: cbr, snapshot: snapshot, currency_pair: pair, rate_value: min_rate.rate ) - ExternalRate.create!( + Gera::ExternalRate.create!( source: cbr, snapshot: snapshot, currency_pair: pair.inverse, @@ -101,14 +101,14 @@ def save_snapshot_rate(cur_from, cur_to) avg_rate = (max_rate.rate + min_rate.rate) / 2.0 - ExternalRate.create!( + Gera::ExternalRate.create!( source: cbr_avg, snapshot: avg_snapshot, currency_pair: pair, rate_value: avg_rate ) - ExternalRate.create!( + Gera::ExternalRate.create!( source: cbr_avg, snapshot: avg_snapshot, currency_pair: pair.inverse, @@ -117,11 +117,11 @@ def save_snapshot_rate(cur_from, cur_to) end def cbr_avg - @cbr_avg ||= RateSourceCbrAvg.get! + @cbr_avg ||= Gera::RateSourceCbrAvg.get! end def cbr - @cbr ||= RateSourceCbr.get! + @cbr ||= Gera::RateSourceCbr.get! end def days @@ -144,11 +144,11 @@ def load_rates rates_by_date[date] = fetch_rates(date) rescue WrongDate => err logger.warn err - + # HTTP redirection loop: http://www.cbr.ru/scripts/XML_daily.asp?date_req=09/01/2019 rescue RuntimeError => err raise err unless err.message.include? 'HTTP redirection loop' - + logger.error err end rates_by_date @@ -160,7 +160,7 @@ def fetch_rates(date) logger.info "fetch rates for #{date} from #{uri}" - doc = Nokogiri::XML open uri + doc = Nokogiri::XML URI.open(uri) root = doc.xpath('/ValCurs') root_date = root.attr('Date').text @@ -172,10 +172,10 @@ def fetch_rates(date) end def save_rates(date, rates) - return if CbrExternalRate.where(date: date, cur_from: currencies.map(&:iso_code)).count == currencies.count + return if Gera::CbrExternalRate.where(date: date, cur_from: currencies.map(&:iso_code)).count == currencies.count currencies.each do |cur| - save_rate get_rate(rates, CBR_IDS[cur.iso_code]), cur, date unless CbrExternalRate.where(date: date, cur_from: cur.iso_code).exists? + save_rate get_rate(rates, CBR_IDS[cur.iso_code]), cur, date unless Gera::CbrExternalRate.where(date: date, cur_from: cur.iso_code).exists? end end @@ -192,7 +192,7 @@ def save_rate(rate_struct, cur, date) rate = (original_rate / nominal).round(ROUND) - CbrExternalRate.create!( + Gera::CbrExternalRate.create!( cur_from: cur.iso_code, cur_to: RUB.iso_code, rate: rate, diff --git a/app/jobs/gera/create_history_intervals_job.rb b/app/jobs/gera/create_history_intervals_job.rb new file mode 100644 index 00000000..d90560e7 --- /dev/null +++ b/app/jobs/gera/create_history_intervals_job.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gera + class CreateHistoryIntervalsJob < ApplicationJob + include AutoLogger + + limits_concurrency to: 1, key: ->(*) { 'gera_create_history_intervals' }, duration: 1.hour + + MAXIMAL_DATE = 30.minutes + MINIMAL_DATE = Time.parse('13-07-2018 18:00') + + def perform + if Gera::DirectionRateHistoryInterval.table_exists? + if Gera.enable_direction_rate_history_intervals + save_direction_rate_history_intervals + else + logger.info 'Skipping direction_rate_history_intervals creation (disabled by config)' + end + end + save_currency_rate_history_intervals if Gera::CurrencyRateHistoryInterval.table_exists? + end + + private + + def save_direction_rate_history_intervals + last_saved_interval = Gera::DirectionRateHistoryInterval.maximum(:interval_to) + + from = last_saved_interval || MINIMAL_DATE + logger.info "start save_direction_rate_history_intervals from #{from}" + Gera::DirectionRateHistoryInterval.create_multiple_intervals_from! from, MAXIMAL_DATE.ago + end + + def save_currency_rate_history_intervals + last_saved_interval = Gera::CurrencyRateHistoryInterval.maximum(:interval_to) + + from = last_saved_interval || MINIMAL_DATE + logger.info "start save_currency_rate_history_intervals from #{from}" + Gera::CurrencyRateHistoryInterval.create_multiple_intervals_from! from, MAXIMAL_DATE.ago + end + end +end diff --git a/app/jobs/gera/cryptomus_rates_job.rb b/app/jobs/gera/cryptomus_rates_job.rb new file mode 100644 index 00000000..abc7e100 --- /dev/null +++ b/app/jobs/gera/cryptomus_rates_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gera + class CryptomusRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + private + + def rate_source + @rate_source ||= Gera::RateSourceCryptomus.get! + end + + def load_rates + Gera::CryptomusFetcher.new.perform + end + + def rate_keys + { buy: 'course', sell: 'course' } + end + end +end diff --git a/app/workers/gera/currency_rates_worker.rb b/app/jobs/gera/currency_rates_job.rb similarity index 51% rename from app/workers/gera/currency_rates_worker.rb rename to app/jobs/gera/currency_rates_job.rb index 0c805041..bf49a330 100644 --- a/app/workers/gera/currency_rates_worker.rb +++ b/app/jobs/gera/currency_rates_job.rb @@ -4,57 +4,56 @@ module Gera # # Build currency rates on base of imported rates and calculation modes # - class CurrencyRatesWorker - include Sidekiq::Worker + class CurrencyRatesJob < ApplicationJob include AutoLogger - Error = Class.new StandardError + queue_as :default def perform logger.info 'start' - CurrencyRate.transaction do + Gera::CurrencyRate.transaction do snapshot = create_snapshot - CurrencyPair.all.each { |pair| create_rate(pair: pair, snapshot: snapshot) } + Gera::CurrencyPair.all.each { |pair| create_rate(pair: pair, snapshot: snapshot) } end logger.info 'finish' - DirectionsRatesWorker.perform_async + Gera::DirectionsRatesJob.perform_later true end private def create_snapshot - CurrencyRateSnapshot.create!(currency_rate_mode_snapshot: currency_rates.snapshot) + Gera::CurrencyRateSnapshot.create!(currency_rate_mode_snapshot: currency_rates.snapshot) end def currency_rates - Universe.currency_rate_modes_repository + Gera::Universe.currency_rate_modes_repository end def create_rate(pair:, snapshot:) currency_rate_mode = find_currency_rate_mode_by_pair(pair) logger.debug "build_rate(#{pair}, #{currency_rate_mode})" currency_rate = currency_rate_mode.build_currency_rate - raise Error, "Unable to calculate rate for #{pair} and mode '#{currency_rate_mode.mode}'" unless currency_rate.present? + + unless currency_rate.present? + logger.warn "Unable to calculate rate for #{pair} and mode '#{currency_rate_mode.mode}'" + return + end currency_rate.snapshot = snapshot currency_rate.save! - rescue RateSource::RateNotFound => err - logger.error err + rescue Gera::RateSource::RateNotFound => err + logger.warn err rescue StandardError => err - raise err if !err.is_a?(Error) && Rails.env.test? - logger.error err + raise err if Rails.env.test? - if defined? Bugsnag - Bugsnag.notify err do |b| - b.meta_data = { pair: pair } - end - end + logger.error err + Bugsnag.notify(err) { |b| b.meta_data = { pair: pair } } if defined? Bugsnag end def find_currency_rate_mode_by_pair(pair) currency_rates.find_currency_rate_mode_by_pair(pair) || - CurrencyRateMode.default_for_pair(pair).freeze + Gera::CurrencyRateMode.default_for_pair(pair).freeze end end end diff --git a/app/workers/gera/directions_rates_worker.rb b/app/jobs/gera/directions_rates_job.rb similarity index 52% rename from app/workers/gera/directions_rates_worker.rb rename to app/jobs/gera/directions_rates_job.rb index 7ba2ed5b..be970c59 100644 --- a/app/workers/gera/directions_rates_worker.rb +++ b/app/jobs/gera/directions_rates_job.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true module Gera - class DirectionsRatesWorker + class DirectionsRatesJob < ApplicationJob include ActiveSupport::Callbacks - include Sidekiq::Worker include AutoLogger Error = Class.new StandardError - sidekiq_options queue: :critical, lock: :until_executed + queue_as :critical + limits_concurrency to: 1, key: ->(*) { 'gera_directions_rates' }, duration: 5.minutes + define_callbacks :perform # exchange_rate_id - ID of changes exchange_rate @@ -16,10 +17,9 @@ class DirectionsRatesWorker def perform(*_args) # exchange_rate_id: nil) logger.info 'start' - run_callbacks :perform do - DirectionRateSnapshot.transaction do - ExchangeRate.includes(:payment_system_from, :payment_system_to).find_each do |exchange_rate| + Gera::DirectionRateSnapshot.transaction do + Gera::ExchangeRate.includes(:payment_system_from, :payment_system_to).find_each do |exchange_rate| safe_create(exchange_rate) end end @@ -32,17 +32,17 @@ def perform(*_args) # exchange_rate_id: nil) delegate :direction_rates, to: :snapshot def snapshot - @snapshot ||= DirectionRateSnapshot.create! + @snapshot ||= Gera::DirectionRateSnapshot.create! end def safe_create(exchange_rate) direction_rates.create!( snapshot: snapshot, exchange_rate: exchange_rate, - currency_rate: Universe.currency_rates_repository.find_currency_rate_by_pair(exchange_rate.currency_pair) + currency_rate: Gera::Universe.currency_rates_repository.find_currency_rate_by_pair(exchange_rate.currency_pair) ) - rescue CurrencyRatesRepository::UnknownPair => err - rescue DirectionRate::UnknownExchangeRate, ActiveRecord::RecordInvalid => err + rescue Gera::CurrencyRatesRepository::UnknownPair => err + rescue Gera::DirectionRate::UnknownExchangeRate, ActiveRecord::RecordInvalid => err logger.error err end end diff --git a/app/jobs/gera/exchange_rate_updater_job.rb b/app/jobs/gera/exchange_rate_updater_job.rb new file mode 100644 index 00000000..dd9bffb4 --- /dev/null +++ b/app/jobs/gera/exchange_rate_updater_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gera + class ExchangeRateUpdaterJob < ApplicationJob + include AutoLogger + + queue_as :exchange_rates + + def perform(exchange_rate_id, attributes) + increment_exchange_rate_touch_metric + Gera::ExchangeRate.where(id: exchange_rate_id).update_all(attributes) + end + + private + + def increment_exchange_rate_touch_metric + Yabeda.exchange.exchange_rate_touch_count.increment({ + action: 'update', + source: 'Gera::ExchangeRateUpdaterJob' + }) + end + end +end diff --git a/app/jobs/gera/exmo_rates_job.rb b/app/jobs/gera/exmo_rates_job.rb new file mode 100644 index 00000000..1d3ac051 --- /dev/null +++ b/app/jobs/gera/exmo_rates_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gera + class ExmoRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + private + + def rate_source + @rate_source ||= Gera::RateSourceExmo.get! + end + + def load_rates + Gera::ExmoFetcher.new.perform + end + + def rate_keys + { buy: 'buy_price', sell: 'sell_price' } + end + end +end diff --git a/app/workers/gera/external_rate_saver_worker.rb b/app/jobs/gera/external_rate_saver_job.rb similarity index 75% rename from app/workers/gera/external_rate_saver_worker.rb rename to app/jobs/gera/external_rate_saver_job.rb index b6112de0..eb1f7308 100644 --- a/app/workers/gera/external_rate_saver_worker.rb +++ b/app/jobs/gera/external_rate_saver_job.rb @@ -1,19 +1,18 @@ # frozen_string_literal: true module Gera - class ExternalRateSaverWorker - include Sidekiq::Worker + class ExternalRateSaverJob < ApplicationJob include AutoLogger - sidekiq_options queue: :external_rates + queue_as :external_rates def perform(currency_pair, snapshot_id, rate, source_rates_count) rate_source = find_rate_source(rate) - snapshot = ExternalRateSnapshot.find(snapshot_id) + snapshot = Gera::ExternalRateSnapshot.find(snapshot_id) create_external_rate( rate_source: rate_source, snapshot: snapshot, - currency_pair: CurrencyPair.new(currency_pair), + currency_pair: Gera::CurrencyPair.new(currency_pair), rate_value: rate['value'] ) update_actual_snapshot( @@ -31,7 +30,7 @@ def find_rate_source(rate) end def create_external_rate(rate_source:, snapshot:, currency_pair:, rate_value:) - ExternalRate.create!( + Gera::ExternalRate.create!( currency_pair: currency_pair, snapshot: snapshot, source: rate_source, @@ -40,15 +39,11 @@ def create_external_rate(rate_source:, snapshot:, currency_pair:, rate_value:) end def update_actual_snapshot(rate_source:, snapshot:) - update_actual_snapshot(snapshot: snapshot, rate_source: rate_source) + rate_source.update!(actual_snapshot_id: snapshot.id) end def snapshot_filled_up?(snapshot:, source_rates_count:) snapshot.external_rates.count == source_rates_count * 2 end - - def update_actual_snapshot(snapshot:, rate_source:) - rate_source.update!(actual_snapshot_id: snapshot.id) - end end end diff --git a/app/jobs/gera/external_rates_batch_job.rb b/app/jobs/gera/external_rates_batch_job.rb new file mode 100644 index 00000000..41c3948c --- /dev/null +++ b/app/jobs/gera/external_rates_batch_job.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gera + class ExternalRatesBatchJob < ApplicationJob + queue_as :default + + def perform(snapshot_id, rate_source_id, rates) + snapshot = Gera::ExternalRateSnapshot.find(snapshot_id) + rate_source = Gera::RateSource.find(rate_source_id) + + values = rates.flat_map do |pair, prices| + cur_from, cur_to = pair.split('/') + + buy = prices[:buy] || prices['buy'] + sell = prices[:sell] || prices['sell'] + + next if buy.nil? || sell.nil? + + buy = buy.to_f + sell = sell.to_f + next if buy <= 0 || sell <= 0 + + [ + { + snapshot_id: snapshot.id, + source_id: rate_source.id, + cur_from: cur_from, + cur_to: cur_to, + rate_value: buy + }, + { + snapshot_id: snapshot.id, + source_id: rate_source.id, + cur_from: cur_to, + cur_to: cur_from, + rate_value: (1.0 / sell) + } + ] + end.compact + + Gera::ExternalRate.insert_all(values) if values.any? + rate_source.update!(actual_snapshot_id: snapshot.id) + end + end +end diff --git a/app/jobs/gera/ff_fixed_rates_job.rb b/app/jobs/gera/ff_fixed_rates_job.rb new file mode 100644 index 00000000..d906365e --- /dev/null +++ b/app/jobs/gera/ff_fixed_rates_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gera + class FfFixedRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + private + + def rate_source + @rate_source ||= Gera::RateSourceFfFixed.get! + end + + def load_rates + Gera::FfFixedFetcher.new.perform + end + + def rate_keys + { buy: 'out', sell: 'out' } + end + end +end diff --git a/app/jobs/gera/ff_float_rates_job.rb b/app/jobs/gera/ff_float_rates_job.rb new file mode 100644 index 00000000..bc5354d1 --- /dev/null +++ b/app/jobs/gera/ff_float_rates_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gera + class FfFloatRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + private + + def rate_source + @rate_source ||= Gera::RateSourceFfFloat.get! + end + + def load_rates + Gera::FfFloatFetcher.new.perform + end + + def rate_keys + { buy: 'out', sell: 'out' } + end + end +end diff --git a/app/jobs/gera/garantexio_rates_job.rb b/app/jobs/gera/garantexio_rates_job.rb new file mode 100644 index 00000000..561c14c9 --- /dev/null +++ b/app/jobs/gera/garantexio_rates_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gera + class GarantexioRatesJob < ApplicationJob + include AutoLogger + include RatesJob + + private + + def rate_source + @rate_source ||= Gera::RateSourceGarantexio.get! + end + + def load_rates + Gera::GarantexioFetcher.new.perform + end + + def rate_keys + { buy: 'last_price', sell: 'last_price' } + end + end +end diff --git a/app/models/gera/currency_rate.rb b/app/models/gera/currency_rate.rb index 6c67c5bf..b1dbd3d8 100644 --- a/app/models/gera/currency_rate.rb +++ b/app/models/gera/currency_rate.rb @@ -19,7 +19,7 @@ class CurrencyRate < ApplicationRecord scope :by_exchange_rate, ->(er) { by_currency_pair er.currency_pair } - enum mode: %i[direct inverse same cross], _prefix: true + enum :mode, %i[direct inverse same cross], prefix: true before_save do raise("У кросс-курса (#{currency_pair}) должен быть минимум 1 external_rates (#{external_rates.count})") if mode_cross? && external_rates.blank? diff --git a/app/models/gera/currency_rate_mode.rb b/app/models/gera/currency_rate_mode.rb index 44627759..316fd9cc 100644 --- a/app/models/gera/currency_rate_mode.rb +++ b/app/models/gera/currency_rate_mode.rb @@ -11,7 +11,7 @@ class CurrencyRateMode < ApplicationRecord # Тут режими из ключей rate_source # TODO выделить привязку к rate_source в отедельную ассоциацию - enum mode: %i[auto cbr cbr_avg exmo cross bitfinex], _prefix: true + enum :mode, %i[auto cbr cbr_avg exmo cross bitfinex], prefix: true accepts_nested_attributes_for :cross_rate_modes, reject_if: :all_blank, allow_destroy: true diff --git a/app/models/gera/currency_rate_mode_snapshot.rb b/app/models/gera/currency_rate_mode_snapshot.rb index 2ea55969..0e9aec48 100644 --- a/app/models/gera/currency_rate_mode_snapshot.rb +++ b/app/models/gera/currency_rate_mode_snapshot.rb @@ -6,7 +6,7 @@ class CurrencyRateModeSnapshot < ApplicationRecord scope :ordered, -> { order('status desc').order('created_at desc') } - enum status: %i[draft active deactive], _prefix: true + enum :status, %i[draft active deactive], prefix: true accepts_nested_attributes_for :currency_rate_modes diff --git a/app/models/gera/direction_rate.rb b/app/models/gera/direction_rate.rb index cc6da967..d188d9b1 100644 --- a/app/models/gera/direction_rate.rb +++ b/app/models/gera/direction_rate.rb @@ -8,6 +8,7 @@ class DirectionRate < ApplicationRecord include AutoLogger include DirectionSupport include Authority::Abilities + include AliasAssociation UnknownExchangeRate = Class.new StandardError @@ -28,10 +29,10 @@ class DirectionRate < ApplicationRecord validates :rate_percent, presence: true validates :finite_rate, presence: true - alias_attribute :payment_system_from, :ps_from - alias_attribute :payment_system_to, :ps_to - alias_attribute :income_payment_system, :ps_from - alias_attribute :outcome_payment_system, :ps_to + alias_association :payment_system_from, :ps_from + alias_association :payment_system_to, :ps_to + alias_association :income_payment_system, :ps_from + alias_association :outcome_payment_system, :ps_to alias_attribute :income_payment_system_id, :ps_from_id alias_attribute :outcome_payment_system_id, :ps_to_id diff --git a/app/models/gera/exchange_rate.rb b/app/models/gera/exchange_rate.rb index a0265aec..fcc3736f 100644 --- a/app/models/gera/exchange_rate.rb +++ b/app/models/gera/exchange_rate.rb @@ -15,6 +15,7 @@ module Gera class ExchangeRate < ApplicationRecord include Authority::Abilities + include AliasAssociation DEFAULT_COMISSION = 50 MIN_COMISSION = -9.9 @@ -24,7 +25,13 @@ class ExchangeRate < ApplicationRecord belongs_to :payment_system_from, foreign_key: :income_payment_system_id, class_name: 'Gera::PaymentSystem' belongs_to :payment_system_to, foreign_key: :outcome_payment_system_id, class_name: 'Gera::PaymentSystem' - has_one :target_autorate_setting, class_name: 'TargetAutorateSetting' + + has_many :direction_rates, class_name: 'Gera::DirectionRate', dependent: :delete_all + + # NOTE: These tables are optional and may be defined in host application + # dependent: :delete not used because tables may not exist + has_one :target_autorate_setting, class_name: 'Gera::TargetAutorateSetting' + has_one :exchange_rate_limit, class_name: 'Gera::ExchangeRateLimit' scope :ordered, -> { order :id } scope :enabled, -> { where is_enabled: true } @@ -40,9 +47,14 @@ class ExchangeRate < ApplicationRecord .where("#{PaymentSystem.table_name}.income_enabled and payment_system_tos_gera_exchange_rates.outcome_enabled") .where("#{table_name}.income_payment_system_id <> #{table_name}.outcome_payment_system_id") } - scope :with_auto_rates, -> { where(auto_rate: true) } - after_commit :update_direction_rates, if: -> { previous_changes.key?('value') } + scope :available_for_parser, lambda { + with_payment_systems + .enabled + .where("#{PaymentSystem.table_name}.income_enabled and payment_system_tos_gera_exchange_rates.outcome_enabled") + } + + scope :with_auto_rates, -> { where(auto_rate: true) } before_create do self.in_cur = payment_system_from.currency.to_s @@ -51,7 +63,7 @@ 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 } delegate :rate, :currency_rate, to: :direction_rate @@ -60,9 +72,11 @@ class ExchangeRate < ApplicationRecord :current_base_rate, :average_base_rate, :auto_comission_from, :auto_comission_to, :bestchange_delta, to: :rate_comission_calculator - delegate :position_from, :position_to, + delegate :position_from, :position_to, :autorate_from, :autorate_to, to: :target_autorate_setting, allow_nil: true + delegate :min_amount, :max_amount, to: :exchange_rate_limit, allow_nil: true + alias_attribute :ps_from_id, :income_payment_system_id alias_attribute :ps_to_id, :outcome_payment_system_id alias_attribute :payment_system_from_id, :income_payment_system_id @@ -73,8 +87,8 @@ class ExchangeRate < ApplicationRecord alias_attribute :comission_percents, :value alias_attribute :fixed_comission, :value - alias_attribute :income_payment_system, :payment_system_from - alias_attribute :outcome_payment_system, :payment_system_to + alias_association :income_payment_system, :payment_system_from + alias_association :outcome_payment_system, :payment_system_to monetize :minamount_cents, as: :minamount monetize :maxamount_cents, as: :maxamount @@ -92,9 +106,9 @@ def available? def update_finite_rate!(finite_rate) logger = Logger.new("#{Rails.root}/log/call_exchange_rate_updater_worker.log") - logger.info("Calls perform_async from update_finite_rate Gera::ExchangeRate") + logger.info("Calls perform_later from update_finite_rate Gera::ExchangeRate") - ExchangeRateUpdaterWorker.perform_async(id, { comission: calculate_comission(finite_rate, currency_rate.rate_value) }) + ExchangeRateUpdaterJob.perform_later(id, { comission: calculate_comission(finite_rate, currency_rate.rate_value) }) end def custom_inspect @@ -145,7 +159,16 @@ def final_rate_percents end def update_direction_rates - DirectionsRatesWorker.perform_async(exchange_rate_id: id) + DirectionsRatesJob.perform_later(exchange_rate_id: id) + end + + def bestchange_key + return '' if payment_system_from.nil? || payment_system_to.nil? + + from_id = payment_system_from.read_attribute(:id_b) + to_id = payment_system_to.read_attribute(:id_b) + + [from_id, to_id].join('-') end def rate_comission_calculator @@ -153,7 +176,7 @@ def rate_comission_calculator end def external_rates - @external_rates ||= BestChange::Service.new(exchange_rate: self).rows_without_kassa + @external_rates ||= Gera.manul_client&.top_exchangers(bestchange_key) || [] end def flexible_rate diff --git a/app/models/gera/exchange_rate_limit.rb b/app/models/gera/exchange_rate_limit.rb new file mode 100644 index 00000000..aed9349e --- /dev/null +++ b/app/models/gera/exchange_rate_limit.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Gera + class ExchangeRateLimit < ApplicationRecord + belongs_to :exchange_rate, class_name: 'Gera::ExchangeRate' + end +end diff --git a/app/models/gera/payment_system.rb b/app/models/gera/payment_system.rb index 8430d391..00e9bda1 100644 --- a/app/models/gera/payment_system.rb +++ b/app/models/gera/payment_system.rb @@ -6,14 +6,38 @@ class PaymentSystem < ApplicationRecord include Gera::Mathematic include Authority::Abilities + has_many :exchange_rates_as_income, + class_name: 'Gera::ExchangeRate', + foreign_key: :income_payment_system_id, + dependent: :delete_all, + inverse_of: :payment_system_from + + has_many :exchange_rates_as_outcome, + class_name: 'Gera::ExchangeRate', + foreign_key: :outcome_payment_system_id, + dependent: :delete_all, + inverse_of: :payment_system_to + + has_many :direction_rates_as_from, + class_name: 'Gera::DirectionRate', + foreign_key: :ps_from_id, + dependent: :delete_all, + inverse_of: :ps_from + + has_many :direction_rates_as_to, + class_name: 'Gera::DirectionRate', + foreign_key: :ps_to_id, + dependent: :delete_all, + inverse_of: :ps_to + scope :ordered, -> { order :priority } scope :enabled, -> { where 'income_enabled>0 or outcome_enabled>0' } scope :disabled, -> { where income_enabled: false, outcome_enabled: false } scope :available, -> { where is_available: true } # TODO: move to kassa-admin - enum total_computation_method: %i[regular_fee reverse_fee] - enum transfer_comission_payer: %i[user shop], _prefix: :transfer_comission_payer + enum :total_computation_method, %i[regular_fee reverse_fee] + enum :transfer_comission_payer, %i[user shop], prefix: :transfer_comission_payer validates :name, presence: true, uniqueness: { case_sensitive: true } validates :currency, presence: true diff --git a/app/models/gera/rate_source_cbr.rb b/app/models/gera/rate_source_cbr.rb index 48a3f573..e34e9e12 100644 --- a/app/models/gera/rate_source_cbr.rb +++ b/app/models/gera/rate_source_cbr.rb @@ -3,11 +3,11 @@ module Gera class RateSourceCbr < RateSource def self.supported_currencies - %i[RUB KZT USD EUR UAH UZS AZN BYN TRY].map { |m| Money::Currency.find! m } + %i[RUB KZT USD EUR UAH UZS AZN BYN TRY THB IDR].map { |m| Money::Currency.find! m } end def self.available_pairs - ['KZT/RUB', 'USD/RUB', 'EUR/RUB', 'UAH/RUB', 'UZS/RUB', 'AZN/RUB', 'BYN/RUB', 'TRY/RUB'].map { |cp| Gera::CurrencyPair.new cp }.freeze + ['KZT/RUB', 'USD/RUB', 'EUR/RUB', 'UAH/RUB', 'UZS/RUB', 'AZN/RUB', 'BYN/RUB', 'TRY/RUB', 'THB/RUB', 'IDR/RUB'].map { |cp| Gera::CurrencyPair.new cp }.freeze end end end diff --git a/app/models/gera/rate_source_ff_fixed.rb b/app/models/gera/rate_source_ff_fixed.rb new file mode 100644 index 00000000..e5a96984 --- /dev/null +++ b/app/models/gera/rate_source_ff_fixed.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Gera + class RateSourceFfFixed < RateSource + def self.supported_currencies + %i[BTC BCH DSH ETH ETC LTC XRP XMR ZEC NEO EOS ADA XEM WAVES TRX DOGE BNB XLM DOT USDT UNI LINK SOL USDC MATIC TON].map { |m| Money::Currency.find! m } + end + end +end diff --git a/app/models/gera/rate_source_ff_float.rb b/app/models/gera/rate_source_ff_float.rb new file mode 100644 index 00000000..aa0ba899 --- /dev/null +++ b/app/models/gera/rate_source_ff_float.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Gera + class RateSourceFfFloat < RateSource + def self.supported_currencies + %i[BTC BCH DSH ETH ETC LTC XRP XMR ZEC NEO EOS ADA XEM WAVES TRX DOGE BNB XLM DOT USDT UNI LINK SOL USDC MATIC TON].map { |m| Money::Currency.find! m } + end + end +end diff --git a/app/services/gera/rate_comission_calculator.rb b/app/services/gera/rate_comission_calculator.rb index 8cac57e4..17d5b1a2 100644 --- a/app/services/gera/rate_comission_calculator.rb +++ b/app/services/gera/rate_comission_calculator.rb @@ -4,7 +4,7 @@ module Gera class RateComissionCalculator include Virtus.model strict: true - AUTO_COMISSION_GAP = 0.001 + AUTO_COMISSION_GAP = 0.01 NOT_ALLOWED_COMISSION_RANGE = (0.7..1.4) EXCLUDED_PS_IDS = [54, 56] @@ -164,10 +164,10 @@ def auto_comission_by_external_comissions 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?(rate.target_rate_percent) } + 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 = external_rates_in_target_comission.first.target_rate_percent - AUTO_COMISSION_GAP + target_comission = calculate_rate_commission(external_rates_in_target_comission.first['rate'], exchange_rate.currency_rate.rate_value) - AUTO_COMISSION_GAP target_comission end end @@ -179,5 +179,14 @@ def calculate_allowed_comission(comission) def same_currencies? in_currency == out_currency end + + def calculate_rate_commission(finite_rate, base_rate) + finite = finite_rate.to_f + base = base_rate.to_f + + normalized_finite = finite < 1 && base > 1 ? 1.0 / finite : finite + + ((base - normalized_finite) / base) * 100 + end end end diff --git a/app/views/gera/direction_rate_history_intervals/index.slim b/app/views/gera/direction_rate_history_intervals/index.slim index 44f1114e..cf0da108 100644 --- a/app/views/gera/direction_rate_history_intervals/index.slim +++ b/app/views/gera/direction_rate_history_intervals/index.slim @@ -1,5 +1,11 @@ = render 'filter' +- unless history_intervals_enabled? + .alert.alert-warning + strong Внимание! + | Сбор данных для графиков направлений отключен (enable_direction_rate_history_intervals = false). + | Отображаются только исторические данные. + #container style="height: 400px; min-width: 310px" = javascript_include_tag 'https://code.highcharts.com/stock/highstock.js' diff --git a/app/workers/concerns/gera/rates_worker.rb b/app/workers/concerns/gera/rates_worker.rb deleted file mode 100644 index a95cb563..00000000 --- a/app/workers/concerns/gera/rates_worker.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'open-uri' -require 'rest-client' - -module Gera - # Import rates from all sources - # - module RatesWorker - Error = Class.new StandardError - - def perform - logger.debug 'RatesWorker: before perform' - # Alternative approach is `Model.uncached do` - ActiveRecord::Base.connection.clear_query_cache - - @rates = load_rates # Load before a transaction - logger.debug 'RatesWorker: before transaction' - create_snapshot - rates.each { |currency_pair, data| save_rate(currency_pair, data) } - snapshot.id - # ExmoRatesWorker::Error: Error 40016: Maintenance work in progress - rescue ActiveRecord::RecordNotUnique, RestClient::TooManyRequests => error - raise error if Rails.env.test? - - logger.error error - Bugsnag.notify error do |b| - b.severity = :warning - b.meta_data = { error: error } - end - end - - private - - attr_reader :snapshot, :rates - delegate :actual_for, to: :snapshot - - def create_snapshot - @snapshot ||= rate_source.snapshots.create! actual_for: Time.zone.now - end - - def create_external_rates(currency_pair, data, sell_price:, buy_price:) - rate = { source_class_name: rate_source.class.name, source_id: rate_source.id, value: buy_price.to_f } - ExternalRateSaverWorker.perform_async(currency_pair, snapshot.id, rate, rates.count) - rate[:value] = 1.0 / sell_price.to_f - ExternalRateSaverWorker.perform_async(currency_pair.inverse, snapshot.id, rate, rates.count) - end - end -end diff --git a/app/workers/gera/binance_rates_worker.rb b/app/workers/gera/binance_rates_worker.rb deleted file mode 100644 index f2e9f192..00000000 --- a/app/workers/gera/binance_rates_worker.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Gera - # Import rates from Binance - # - class BinanceRatesWorker - include Sidekiq::Worker - include AutoLogger - - prepend RatesWorker - - sidekiq_options lock: :until_executed - - private - - def rate_source - @rate_source ||= RateSourceBinance.get! - end - - def save_rate(currency_pair, data) - create_external_rates(currency_pair, data, sell_price: data['askPrice'], buy_price: data['bidPrice']) - end - - def load_rates - BinanceFetcher.new.perform - end - end -end diff --git a/app/workers/gera/bybit_rates_worker.rb b/app/workers/gera/bybit_rates_worker.rb deleted file mode 100644 index 8802d146..00000000 --- a/app/workers/gera/bybit_rates_worker.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Gera - # Import rates from Garantexio - # - class BybitRatesWorker - include Sidekiq::Worker - include AutoLogger - - prepend RatesWorker - - private - - def rate_source - @rate_source ||= RateSourceBybit.get! - end - - def save_rate(pair, data) - create_external_rates pair, data, sell_price: data['price'].to_f, buy_price: data['price'].to_f - end - - def load_rates - BybitFetcher.new.perform - end - end -end diff --git a/app/workers/gera/create_history_intervals_worker.rb b/app/workers/gera/create_history_intervals_worker.rb deleted file mode 100644 index 9ccd89ac..00000000 --- a/app/workers/gera/create_history_intervals_worker.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Gera - class CreateHistoryIntervalsWorker - include Sidekiq::Worker - include AutoLogger - - sidekiq_options lock: :until_executed - - MAXIMAL_DATE = 30.minutes - MINIMAL_DATE = Time.parse('13-07-2018 18:00') - - def perform - save_direction_rate_history_intervals if DirectionRateHistoryInterval.table_exists? - save_currency_rate_history_intervals if CurrencyRateHistoryInterval.table_exists? - end - - private - - def lock_timeout - 1.hours * 1000 - end - - def save_direction_rate_history_intervals - last_saved_interval = DirectionRateHistoryInterval.maximum(:interval_to) - - from = last_saved_interval || MINIMAL_DATE - logger.info "start save_direction_rate_history_intervals from #{from}" - DirectionRateHistoryInterval.create_multiple_intervals_from! from, MAXIMAL_DATE.ago - end - - def save_currency_rate_history_intervals - last_saved_interval = CurrencyRateHistoryInterval.maximum(:interval_to) - - from = last_saved_interval || MINIMAL_DATE - logger.info "start save_currency_rate_history_intervals from #{from}" - CurrencyRateHistoryInterval.create_multiple_intervals_from! from, MAXIMAL_DATE.ago - end - end -end diff --git a/app/workers/gera/cryptomus_rates_worker.rb b/app/workers/gera/cryptomus_rates_worker.rb deleted file mode 100644 index ffdae637..00000000 --- a/app/workers/gera/cryptomus_rates_worker.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Gera - # Import rates from Garantexio - # - class CryptomusRatesWorker - include Sidekiq::Worker - include AutoLogger - - prepend RatesWorker - - private - - def rate_source - @rate_source ||= RateSourceCryptomus.get! - end - - def save_rate(pair, data) - create_external_rates pair, data, sell_price: data['course'], buy_price: data['course'] - end - - def load_rates - CryptomusFetcher.new.perform - end - end -end diff --git a/app/workers/gera/exchange_rate_updater_worker.rb b/app/workers/gera/exchange_rate_updater_worker.rb deleted file mode 100644 index 80e92c78..00000000 --- a/app/workers/gera/exchange_rate_updater_worker.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Gera - class ExchangeRateUpdaterWorker - include Sidekiq::Worker - include AutoLogger - - sidekiq_options queue: :exchange_rates - - def perform(exchange_rate_id, attributes) - ExchangeRate.find(exchange_rate_id).update(attributes) - end - end -end diff --git a/app/workers/gera/exmo_rates_worker.rb b/app/workers/gera/exmo_rates_worker.rb deleted file mode 100644 index 5ba99b5b..00000000 --- a/app/workers/gera/exmo_rates_worker.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Gera - # Import rates from EXMO - # - class ExmoRatesWorker - include Sidekiq::Worker - include AutoLogger - - prepend RatesWorker - - # sidekiq_options lock: :until_executed - - private - - def rate_source - @rate_source ||= RateSourceExmo.get! - end - - # data contains - # {"buy_price"=>"8734.99986728", - # "sell_price"=>"8802.299431", - # "last_trade"=>"8789.71226599", - # "high"=>"9367.055011", - # "low"=>"8700.00000001", - # "avg"=>"8963.41293922", - # "vol"=>"330.70358291", - # "vol_curr"=>"2906789.33918745", - # "updated"=>1520415288}, - - def save_rate(currency_pair, data) - create_external_rates(currency_pair, data, sell_price: data['sell_price'], buy_price: data['buy_price']) - end - - def load_rates - ExmoFetcher.new.perform - end - end -end diff --git a/app/workers/gera/garantexio_rates_worker.rb b/app/workers/gera/garantexio_rates_worker.rb deleted file mode 100644 index 5d94669a..00000000 --- a/app/workers/gera/garantexio_rates_worker.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Gera - # Import rates from Garantexio - # - class GarantexioRatesWorker - include Sidekiq::Worker - include AutoLogger - - prepend RatesWorker - - private - - def rate_source - @rate_source ||= RateSourceGarantexio.get! - end - - def save_rate(pair, data) - create_external_rates pair, data, sell_price: data['last_price'], buy_price: data['last_price'] - end - - def load_rates - GarantexioFetcher.new.perform - end - end -end diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 00000000..93e191c2 --- /dev/null +++ b/bin/rspec @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/config/currencies.yml b/config/currencies.yml index 25229805..695ed776 100644 --- a/config/currencies.yml +++ b/config/currencies.yml @@ -912,3 +912,57 @@ matic: # минимальная сумма валюты на выдачу (из minGetSumOut) minimal_output_value: 1 + +thb: + priority: 35 + iso_code: THB + name: Thai baht + symbol: '฿' + alternate_symbols: [] + subunit: Satang + subunit_to_unit: 100 + symbol_first: false + html_entity: '' + decimal_mark: "," + thousands_separator: "." + iso_numeric: '764' + smallest_denomination: 1 + is_crypto: false + + # Местные настройки + # + # Идентфикатор в type_cy + local_id: 37 + + # минимальная сумма валюты на прием (из minGetSum) + minimal_input_value: 10 + + # минимальная сумма валюты на выдачу (из minGetSumOut) + minimal_output_value: 10 + +idr: + priority: 36 + iso_code: IDR + name: Indonesian rupiah + symbol: 'Rp' + alternate_symbols: [] + subunit: Sen + subunit_to_unit: 100 + symbol_first: false + html_entity: '' + decimal_mark: "," + thousands_separator: "." + iso_numeric: '360' + smallest_denomination: 1 + is_crypto: false + + # Местные настройки + # + # Идентфикатор в type_cy + local_id: 38 + + # минимальная сумма валюты на прием (из minGetSum) + minimal_input_value: 1000 + + # минимальная сумма валюты на выдачу (из minGetSumOut) + minimal_output_value: 1000 diff --git a/config/initializers/money.rb b/config/initializers/money.rb new file mode 100644 index 00000000..0b60aa38 --- /dev/null +++ b/config/initializers/money.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +if defined?(MoneyRails) + MoneyRails.configure do |config| + # To set the default currency + # + # config.default_currency = :usd + + # Set default bank object + # + # Example: + # config.default_bank = EuCentralBank.new + + # Add exchange rates to current money bank object + # + # config.add_rate = "USD", "CAD", 1.24500 + # config.add_rate = "CAD", "USD", 0.803225 + + # To handle the inclusion of validations for monetized fields + # + # config.include_validations = true + + # Default ActiveRecord migration configuration values for columns + # + # config.amount_column = { prefix: '', # column name prefix + # postfix: '_cents', # column name postfix + # column_name: nil, # full column name (overrides prefix, postfix and accessor name) + # type: :integer, # column type + # present: true, # column will be created + # null: false, # other options will be treated as column options + # default: 0 + # } + # + # config.currency_column = { prefix: '', + # postfix: '_currency', + # column_name: nil, + # type: :string, + # present: true, + # null: false, + # default: nil + # } + + # Register a custom currency + # + # config.register_currency = { priority: 1, + # iso_code: "BTC", + # name: "Bitcoin", + # symbol: "BTC", + # symbol_first: true, + # subunit: "Satoshi", + # subunit_to_unit: 100000000, + # thousands_separator: ',', + # decimal_mark: "." + # } + + # Specify a rounding mode + # + # Any rounding mode from the Ruby BigDecimal library is supported + # :default, :half_up, :half_down, :half_even, :banker, :truncate, :floor, :ceiling + # + # config.rounding_mode = BigDecimal::ROUND_HALF_EVEN + + # Set default money format globally + # + # config.default_format = { + # no_cents_if_whole: nil, + # symbol: nil, + # sign_before_symbol: nil + # } + + # If you would like to use i18n localization (formatting depends on the + # locale): + # config.locale_backend = :i18n + # + # Example (using default locale from config.i18n.default_locale): + # Money.new(10_00, 'USD').format # => "$10.00" + # Money.new(10_00, 'EUR').format # => "10,00 €" + # + # Example (using locale from I18n.locale): + # I18n.locale = :de + # Money.new(10_00, 'USD').format # => "10,00 $" + # Money.new(10_00, 'EUR').format # => "10,00 €" + # + # Example (using a custom locale): + # Money.new(10_00, 'USD').format(locale: :fr) #=> "10,00 $US" + # + # For legacy behaviour of :locale => false (no localization), set locale_backend to :legacy + # config.locale_backend = :legacy + + # Set default raise_error_on_money_parsing option + # When set to true, will raise an error if parsing invalid money strings + # + # config.raise_error_on_money_parsing = false + + # Configuration for货币化 + config.no_cents_if_whole = true + config.symbol = true + end +end \ No newline at end of file diff --git a/db/migrate/20251019143420_add_enum_fields_to_payment_systems.rb b/db/migrate/20251019143420_add_enum_fields_to_payment_systems.rb new file mode 100644 index 00000000..afaf4e68 --- /dev/null +++ b/db/migrate/20251019143420_add_enum_fields_to_payment_systems.rb @@ -0,0 +1,11 @@ +class AddEnumFieldsToPaymentSystems < ActiveRecord::Migration[8.0] + def change + add_column :gera_payment_systems, :total_computation_method, :integer, default: 0 + add_column :gera_payment_systems, :transfer_comission_payer, :integer, default: 0 + + # Add missing columns for ExchangeRate + add_column :gera_exchange_rates, :minamount_cents, :integer, default: 0 + add_column :gera_exchange_rates, :maxamount_cents, :integer, default: 0 + add_column :gera_exchange_rates, :auto_rate, :boolean, default: false + end +end diff --git a/db/migrate/20251128131814_ensure_cascade_delete_on_foreign_keys.rb b/db/migrate/20251128131814_ensure_cascade_delete_on_foreign_keys.rb new file mode 100644 index 00000000..7fd7c590 --- /dev/null +++ b/db/migrate/20251128131814_ensure_cascade_delete_on_foreign_keys.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Ensures all foreign keys have ON DELETE CASCADE +# This migration is idempotent - safe to run multiple times +class EnsureCascadeDeleteOnForeignKeys < ActiveRecord::Migration[7.0] + def up + # ExchangeRate → PaymentSystem + update_foreign_key(:gera_exchange_rates, :gera_payment_systems, :income_payment_system_id) + update_foreign_key(:gera_exchange_rates, :gera_payment_systems, :outcome_payment_system_id) + + # DirectionRate → ExchangeRate + update_foreign_key(:gera_direction_rates, :gera_exchange_rates, :exchange_rate_id) + + # DirectionRate → PaymentSystem + update_foreign_key(:gera_direction_rates, :gera_payment_systems, :ps_from_id) + update_foreign_key(:gera_direction_rates, :gera_payment_systems, :ps_to_id) + + # DirectionRate → CurrencyRate + update_foreign_key(:gera_direction_rates, :gera_currency_rates, :currency_rate_id) + + # DirectionRateHistoryInterval → PaymentSystem + update_foreign_key(:gera_direction_rate_history_intervals, :gera_payment_systems, :payment_system_from_id) + update_foreign_key(:gera_direction_rate_history_intervals, :gera_payment_systems, :payment_system_to_id) + end + + def down + # No-op: we don't want to remove cascade on rollback + end + + private + + def update_foreign_key(from_table, to_table, column) + # Check if foreign key exists + fk = foreign_keys(from_table).find { |k| k.column == column.to_s } + + return unless fk + + # Skip if already has cascade + return if fk.options[:on_delete] == :cascade + + # Remove and recreate with cascade + remove_foreign_key(from_table, column: column) + add_foreign_key(from_table, to_table, column: column, on_delete: :cascade) + end +end diff --git a/factories/cbr_external_rates.rb b/factories/cbr_external_rates.rb new file mode 100644 index 00000000..9a22c61d --- /dev/null +++ b/factories/cbr_external_rates.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :cbr_external_rate, class: Gera::CbrExternalRate do + rate { 95.0 } + end +end diff --git a/factories/cross_rate_modes.rb b/factories/cross_rate_modes.rb new file mode 100644 index 00000000..3f0d430a --- /dev/null +++ b/factories/cross_rate_modes.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :cross_rate_mode, class: Gera::CrossRateMode do + cur_from { 'BTC' } + cur_to { 'USD' } + association :currency_rate_mode + rate_source { nil } + end +end diff --git a/factories/currency_rate_modes.rb b/factories/currency_rate_modes.rb index 96730d64..644e5f48 100644 --- a/factories/currency_rate_modes.rb +++ b/factories/currency_rate_modes.rb @@ -1,8 +1,8 @@ FactoryBot.define do - factory :currency_rate_mode do + factory :currency_rate_mode, class: Gera::CurrencyRateMode do cur_from { USD } cur_to { RUB } mode { :auto } - association :currency_rate_mode_snapshot + association :snapshot, factory: :currency_rate_mode_snapshot end end diff --git a/factories/currency_rates.rb b/factories/currency_rates.rb index 6c0d736f..374983c0 100644 --- a/factories/currency_rates.rb +++ b/factories/currency_rates.rb @@ -5,5 +5,6 @@ rate_value { 60 } association :snapshot, factory: :currency_rate_snapshot mode { :direct } + currency_pair { Gera::CurrencyPair.new(USD, RUB) } end end diff --git a/factories/exchange_rate_limits.rb b/factories/exchange_rate_limits.rb new file mode 100644 index 00000000..d7f6a1b7 --- /dev/null +++ b/factories/exchange_rate_limits.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :exchange_rate_limit, class: Gera::ExchangeRateLimit do + association :exchange_rate, factory: :gera_exchange_rate + end +end diff --git a/factories/exchange_rates.rb b/factories/exchange_rates.rb index 748f8bdc..066edce8 100644 --- a/factories/exchange_rates.rb +++ b/factories/exchange_rates.rb @@ -9,7 +9,7 @@ association :payment_system_from, factory: :gera_payment_system, currency: Money::Currency.find('USD') association :payment_system_to, factory: :gera_payment_system, currency: Money::Currency.find('RUB') value { 10 } - is_enabled { true } + end end diff --git a/factories/payment_systems.rb b/factories/payment_systems.rb index 394b9366..13dbe8c2 100644 --- a/factories/payment_systems.rb +++ b/factories/payment_systems.rb @@ -5,5 +5,6 @@ income_enabled { true } outcome_enabled { true } sequence(:name) { |n| "name#{n}" } + # bestchange_id { 1 } end end diff --git a/factories/rate_sources.rb b/factories/rate_sources.rb index 9f92e01e..9513cf2b 100644 --- a/factories/rate_sources.rb +++ b/factories/rate_sources.rb @@ -18,4 +18,9 @@ factory :rate_source_exmo, parent: :rate_source, class: Gera::RateSourceExmo factory :rate_source_bitfinex, parent: :rate_source, class: Gera::RateSourceBitfinex factory :rate_source_binance, parent: :rate_source, class: Gera::RateSourceBinance + factory :rate_source_bybit, parent: :rate_source, class: Gera::RateSourceBybit + factory :rate_source_garantexio, parent: :rate_source, class: Gera::RateSourceGarantexio + factory :rate_source_cryptomus, parent: :rate_source, class: Gera::RateSourceCryptomus + factory :rate_source_ff_fixed, parent: :rate_source, class: Gera::RateSourceFfFixed + factory :rate_source_ff_float, parent: :rate_source, class: Gera::RateSourceFfFloat end diff --git a/factories/target_autorate_settings.rb b/factories/target_autorate_settings.rb new file mode 100644 index 00000000..da4435d2 --- /dev/null +++ b/factories/target_autorate_settings.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :target_autorate_setting, class: Gera::TargetAutorateSetting do + association :exchange_rate, factory: :gera_exchange_rate + position_from { 1 } + position_to { 10 } + autorate_from { 0.5 } + autorate_to { 1.5 } + end +end diff --git a/gera.gemspec b/gera.gemspec index acaf9535..15f9a23f 100644 --- a/gera.gemspec +++ b/gera.gemspec @@ -17,13 +17,13 @@ Gem::Specification.new do |s| s.files = Dir["{app,config,db,lib}/**/*", "LICENSE", "Rakefile", "README.md"] s.add_dependency 'simple_form' - s.add_dependency "rails", "~> 6.0.6" + s.add_dependency "rails" s.add_dependency 'best_in_place' s.add_dependency 'virtus' s.add_dependency 'kaminari' s.add_dependency 'require_all' s.add_dependency 'rest-client', '~> 2.0' - s.add_dependency 'sidekiq' + s.add_dependency 'solid_queue' s.add_dependency 'auto_logger', '~> 0.1.4' s.add_dependency 'request_store' s.add_dependency 'business_time' @@ -33,10 +33,11 @@ Gem::Specification.new do |s| s.add_dependency 'money' s.add_dependency 'money-rails' s.add_dependency 'percentable' - s.add_dependency 'draper', '~> 3.1.0' + s.add_dependency 'draper' s.add_dependency 'active_link_to' s.add_dependency 'breadcrumbs_on_rails' s.add_dependency 'noty_flash' + s.add_dependency 'alias_association' # s.add_development_dependency 'rails-erd' # s.add_development_dependency 'railroady' @@ -52,10 +53,11 @@ Gem::Specification.new do |s| s.add_development_dependency 'pry-rails' s.add_development_dependency 'pry-byebug' s.add_development_dependency 'factory_bot' - s.add_development_dependency 'rspec-rails', '~> 3.7' + s.add_development_dependency 'rspec-rails', '~> 6.0' s.add_development_dependency 'database_rewinder' s.add_development_dependency 'mysql2' s.add_development_dependency 'pg' + s.add_development_dependency 'sqlite3' s.add_development_dependency 'vcr' s.add_development_dependency 'webmock' s.add_development_dependency 'timecop' diff --git a/lib/gera.rb b/lib/gera.rb index 8ca857ba..70ba5e38 100644 --- a/lib/gera.rb +++ b/lib/gera.rb @@ -1,8 +1,12 @@ require 'money' +require 'money-rails' require 'require_all' require 'percentable' +require 'alias_association' +require 'open-uri' +require 'net/http' -require 'sidekiq' +require 'solid_queue' require 'auto_logger' require "gera/version" @@ -16,6 +20,8 @@ require 'gera/garantexio_fetcher' require 'gera/bybit_fetcher' require 'gera/cryptomus_fetcher' +require 'gera/ff_fixed_fetcher' +require 'gera/ff_float_fetcher' require 'gera/currency_pair' require 'gera/rate' require 'gera/money_support' diff --git a/lib/gera/bybit_fetcher.rb b/lib/gera/bybit_fetcher.rb index 5d6aa777..b99dc72d 100644 --- a/lib/gera/bybit_fetcher.rb +++ b/lib/gera/bybit_fetcher.rb @@ -3,7 +3,7 @@ require 'rest-client' module Gera - class BybitFetcher < PaymentServices::Base::Client + class BybitFetcher API_URL = 'https://api2.bytick.com/fiat/otc/item/online' Error = Class.new StandardError @@ -20,20 +20,27 @@ def perform private def rates - items = safely_parse(http_request( + response = RestClient::Request.execute( url: API_URL, - method: :POST, - body: params.to_json, - headers: build_headers - )).dig('result', 'items') - + method: :post, + payload: params.to_json, + headers: { + 'Content-Type' => 'application/json', + 'Host' => 'api2.bytick.com' + }, + verify_ssl: true + ) + + raise Error, "HTTP #{response.code}" unless response.code == 200 + + items = JSON.parse(response.body).dig('result', 'items') rate = items[2] || items[1] || raise(Error, 'No rates') [rate] end def params - { + { userId: '', tokenId: 'USDT', currencyId: 'RUB', @@ -50,13 +57,5 @@ def params def supported_currencies @supported_currencies ||= RateSourceBybit.supported_currencies end - - def build_headers - { - 'Content-Type' => 'application/json', - 'Host' => 'api2.bytick.com', - 'Content-Length' => '182' - } - end end end diff --git a/lib/gera/configuration.rb b/lib/gera/configuration.rb index 2ef7c39f..9fa0f251 100644 --- a/lib/gera/configuration.rb +++ b/lib/gera/configuration.rb @@ -29,9 +29,17 @@ def default_cross_currency end # @param [Hash] Набор кросс-валют для расчета - mattr_accessor :cross_pairs + # @param [Object] HTTP клиент для работы с Manul API (BestChange rates fetcher) + mattr_accessor :cross_pairs, :manul_client # В данном примере курс к KZT считать через RUB @@cross_pairs = { kzt: :rub } + @@manul_client = nil + + # @param [Boolean] Включение/отключение создания direction_rate_history_intervals + # По умолчанию true для обратной совместимости + # Таблица занимает ~42GB и используется только для графиков в админке + mattr_accessor :enable_direction_rate_history_intervals + @@enable_direction_rate_history_intervals = true def cross_pairs h = {} diff --git a/lib/gera/cryptomus_fetcher.rb b/lib/gera/cryptomus_fetcher.rb index 10447ade..c58a3609 100644 --- a/lib/gera/cryptomus_fetcher.rb +++ b/lib/gera/cryptomus_fetcher.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +require 'rest-client' + module Gera - class CryptomusFetcher < PaymentServices::Base::Client + class CryptomusFetcher API_URL = 'https://api.cryptomus.com/v1/exchange-rate' Error = Class.new StandardError @@ -22,7 +24,7 @@ def perform def rates data = supported_currencies.map(&:iso_code).map { |code| rate(currency: code) }.flatten.filter { |rate| rate['from'] != rate['to'] } unique_pairs = Set.new - filtered_data = data.select do |hash| + filtered_data = data.reverse.select do |hash| pair = [hash['from'], hash['to']].sort unique_pairs.add?(pair) ? true : false end @@ -32,21 +34,20 @@ def rates def rate(currency:) currency = 'DASH' if currency == 'DSH' - safely_parse(http_request( + response = RestClient::Request.execute( url: "#{API_URL}/#{currency}/list", - method: :GET, - headers: build_headers - )).dig('result') + method: :get, + headers: { 'Content-Type' => 'application/json' }, + verify_ssl: true + ) + + raise Error, "HTTP #{response.code}" unless response.code == 200 + + JSON.parse(response.body).dig('result') end def supported_currencies @supported_currencies ||= RateSourceCryptomus.supported_currencies end - - def build_headers - { - 'Content-Type' => 'application/json' - } - end end end diff --git a/lib/gera/currencies_purger.rb b/lib/gera/currencies_purger.rb index 1020d7bd..fe766a3f 100644 --- a/lib/gera/currencies_purger.rb +++ b/lib/gera/currencies_purger.rb @@ -3,12 +3,6 @@ module CurrenciesPurger def self.purge_all(env) raise unless env == Rails.env - if Rails.env.prodiction? - puts 'Disable all sidekiqs' - Sidekiq::Cron::Job.all.each(&:disable!) - sleep 2 - end - DirectionRateSnapshot.batch_purge if DirectionRateSnapshot.table_exists? DirectionRate.batch_purge @@ -18,11 +12,6 @@ def self.purge_all(env) CurrencyRate.batch_purge RateSource.update_all actual_snapshot_id: nil CurrencyRateSnapshot.batch_purge - - if Rails.env.prodiction? - puts 'Enable all sidekiqs' - Sidekiq::Cron::Job.all.each(&:enable!) - end end end end diff --git a/lib/gera/engine.rb b/lib/gera/engine.rb index fc14a4bf..18ed3e4e 100644 --- a/lib/gera/engine.rb +++ b/lib/gera/engine.rb @@ -10,5 +10,11 @@ class Engine < ::Rails::Engine initializer 'gera.factories', after: 'factory_bot.set_factory_paths' do FactoryBot.definition_file_paths << File.expand_path('../../../factories', __FILE__) if defined?(FactoryBot) end + + config.generators do |g| + g.test_framework :rspec + g.fixture_replacement :factory_bot + g.factory_bot dir: 'factories' + end end end diff --git a/lib/gera/exmo_fetcher.rb b/lib/gera/exmo_fetcher.rb index ca598371..ee9491c2 100644 --- a/lib/gera/exmo_fetcher.rb +++ b/lib/gera/exmo_fetcher.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'uri' -require 'net/http' - module Gera class ExmoFetcher URL = 'https://api.exmo.me/v1/ticker/' # https://api.exmo.com/v1/ticker/ @@ -38,8 +35,9 @@ def find_currency(key) end def load_rates - url = URI.parse(URL) - result = JSON.parse(open(url).read) + uri = URI.parse(URL) + response = Net::HTTP.get(uri) + result = JSON.parse(response) raise Error, 'Result is not a hash' unless result.is_a?(Hash) raise Error, result['error'] if result['error'].present? diff --git a/lib/gera/ff_fixed_fetcher.rb b/lib/gera/ff_fixed_fetcher.rb new file mode 100644 index 00000000..a194338f --- /dev/null +++ b/lib/gera/ff_fixed_fetcher.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Gera + class FfFixedFetcher + API_URL = 'https://ff.io/rates/fixed.xml' + Error = Class.new(StandardError) + + def perform + result = {} + raw_rates = rates + + raw_rates.each do |raw_rate| + rate = raw_rate.transform_keys(&:to_s) + + cur_from = rate['from'] + cur_to = rate['to'] + + cur_from = 'BNB' if cur_from == 'BSC' + cur_to = 'BNB' if cur_to == 'BSC' + + next unless supported_currencies.include?(cur_from) + next unless supported_currencies.include?(cur_to) + + pair = Gera::CurrencyPair.new(cur_from: cur_from, cur_to: cur_to) + reverse_pair = Gera::CurrencyPair.new(cur_from: cur_to, cur_to: cur_from) + + result[pair] = rate unless result.key?(reverse_pair) + end + + result + end + + private + + def rates + xml_data = URI.open(API_URL).read + doc = Nokogiri::XML(xml_data) + + doc.xpath('//item').map do |item| + { + from: item.at('from')&.text, + to: item.at('to')&.text, + in: item.at('in')&.text.to_f, + out: item.at('out')&.text.to_f, + amount: item.at('amount')&.text.to_f, + tofee: item.at('tofee')&.text, + minamount: item.at('minamount')&.text, + maxamount: item.at('maxamount')&.text + } + end + end + + def supported_currencies + @supported_currencies ||= RateSourceFfFixed.supported_currencies + end + end +end diff --git a/lib/gera/ff_float_fetcher.rb b/lib/gera/ff_float_fetcher.rb new file mode 100644 index 00000000..ed0f618d --- /dev/null +++ b/lib/gera/ff_float_fetcher.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Gera + class FfFloatFetcher + API_URL = 'https://ff.io/rates/float.xml' + Error = Class.new(StandardError) + + def perform + result = {} + raw_rates = rates + + raw_rates.each do |raw_rate| + rate = raw_rate.transform_keys(&:to_s) + + cur_from = rate['from'] + cur_to = rate['to'] + + cur_from = 'BNB' if cur_from == 'BSC' + cur_to = 'BNB' if cur_to == 'BSC' + + next unless supported_currencies.include?(cur_from) + next unless supported_currencies.include?(cur_to) + + pair = Gera::CurrencyPair.new(cur_from: cur_from, cur_to: cur_to) + reverse_pair = Gera::CurrencyPair.new(cur_from: cur_to, cur_to: cur_from) + + result[pair] = rate unless result.key?(reverse_pair) + end + + result + end + + private + + def rates + xml_data = URI.open(API_URL).read + doc = Nokogiri::XML(xml_data) + + doc.xpath('//item').map do |item| + { + from: item.at('from')&.text, + to: item.at('to')&.text, + in: item.at('in')&.text.to_f, + out: item.at('out')&.text.to_f, + amount: item.at('amount')&.text.to_f, + tofee: item.at('tofee')&.text, + minamount: item.at('minamount')&.text, + maxamount: item.at('maxamount')&.text + } + end + end + + def supported_currencies + @supported_currencies ||= RateSourceFfFloat.supported_currencies + end + end +end diff --git a/lib/gera/repositories/direction_rates_repository.rb b/lib/gera/repositories/direction_rates_repository.rb index c1c51810..66cb37da 100644 --- a/lib/gera/repositories/direction_rates_repository.rb +++ b/lib/gera/repositories/direction_rates_repository.rb @@ -29,9 +29,23 @@ def get_matrix private + # Строит матрицу direction_rates для быстрого доступа по [ps_from_id][ps_to_id]. + # + # Использует includes(:exchange_rate) для eager loading связанных ExchangeRate. + # Это позволяет избежать N+1 запросов при доступе к dr.exchange_rate в views. + # + # Оптимизация (issue #1691): + # - DirectionRate содержит предвычисленный rate_percent (комиссия) + # - ExchangeRate содержит is_enabled?, auto_rate? (настройки направления) + # - Вместо вызова er.final_rate_percents (4 DB запроса каждый раз) + # используется dr.rate_percent (уже сохранено, 0 запросов) + # - includes загружает все exchange_rates за 1 дополнительный запрос + # + # Было: N×M × 4 запроса при отображении матрицы курсов + # Стало: 2 запроса (direction_rates + exchange_rates) def build_matrix hash = {} - snapshot.direction_rates.each do |dr| + snapshot.direction_rates.includes(:exchange_rate).each do |dr| hash[dr.ps_from_id] ||= {} hash[dr.ps_from_id][dr.ps_to_id] = dr end diff --git a/lib/gera/version.rb b/lib/gera/version.rb index b32e0cf8..a00db959 100644 --- a/lib/gera/version.rb +++ b/lib/gera/version.rb @@ -1,3 +1,3 @@ module Gera - VERSION = '0.3.3' + VERSION = '1.2.0' end diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..410eac8b --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "latest" +ruby = "3.4.7" diff --git a/spec/dummy/.rspec b/spec/dummy/.rspec new file mode 100644 index 00000000..c99d2e73 --- /dev/null +++ b/spec/dummy/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/spec/dummy/.ruby-version b/spec/dummy/.ruby-version index ab6d2789..f092941a 100644 --- a/spec/dummy/.ruby-version +++ b/spec/dummy/.ruby-version @@ -1 +1 @@ -2.4.4 \ No newline at end of file +3.2.8 diff --git a/spec/dummy/bin/dev b/spec/dummy/bin/dev new file mode 100755 index 00000000..5f91c205 --- /dev/null +++ b/spec/dummy/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/spec/dummy/bin/rubocop b/spec/dummy/bin/rubocop new file mode 100755 index 00000000..40330c0f --- /dev/null +++ b/spec/dummy/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index c8630666..d4907028 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -2,7 +2,17 @@ require_relative 'boot' -require 'rails/all' +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_view/railtie" +# require "action_cable/engine" +# require "sprockets/railtie" +require "rails/test_unit/railtie" Bundler.require(*Rails.groups) require 'gera' diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml index fec07317..c039fb9a 100644 --- a/spec/dummy/config/database.yml +++ b/spec/dummy/config/database.yml @@ -1,8 +1,11 @@ test: - adapter: mysql2 - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + adapter: sqlite3 + database: db/test.sqlite3 + pool: 5 + timeout: 5000 + +development: + adapter: sqlite3 + database: db/development.sqlite3 + pool: 5 timeout: 5000 - host: localhost - username: root - password: 1111 - database: kassa_admin_test diff --git a/spec/dummy/config/environments/development.rb b/spec/dummy/config/environments/development.rb index da0edff2..0341ecab 100644 --- a/spec/dummy/config/environments/development.rb +++ b/spec/dummy/config/environments/development.rb @@ -30,7 +30,7 @@ end # Store uploaded files on the local file system (see config/storage.yml for options) - config.active_storage.service = :local + # config.active_storage.service = :local if defined?(ActiveStorage) # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false @@ -49,10 +49,10 @@ # Debug mode disables concatenation and preprocessing of assets. # This option may cause significant delays in view rendering with a large # number of complex assets. - config.assets.debug = true + # config.assets.debug = true # Suppress logger output for asset requests. - config.assets.quiet = true + # config.assets.quiet = true # Raises error for missing translations # config.action_view.raise_on_missing_translations = true diff --git a/spec/dummy/config/environments/test.rb b/spec/dummy/config/environments/test.rb index 3091ac4b..f400fae0 100644 --- a/spec/dummy/config/environments/test.rb +++ b/spec/dummy/config/environments/test.rb @@ -31,7 +31,7 @@ config.action_controller.allow_forgery_protection = false # Store uploaded files on the local file system in a temporary directory - config.active_storage.service = :test + # config.active_storage.service = :test config.action_mailer.perform_caching = false diff --git a/spec/dummy/config/initializers/assets.rb b/spec/dummy/config/initializers/assets.rb.disabled similarity index 100% rename from spec/dummy/config/initializers/assets.rb rename to spec/dummy/config/initializers/assets.rb.disabled diff --git a/spec/dummy/config/initializers/deprecation_fixes.rb b/spec/dummy/config/initializers/deprecation_fixes.rb new file mode 100644 index 00000000..3e17436d --- /dev/null +++ b/spec/dummy/config/initializers/deprecation_fixes.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Fix Rails 8.1 deprecation warnings +Rails.application.configure do + # Fix to_time deprecation warning + config.active_support.to_time_preserves_timezone = :zone +end + +# Fix Money gem warnings (after Rails config is loaded) +ActiveSupport.on_load(:after_initialize) do + Money.rounding_mode = BigDecimal::ROUND_HALF_UP + + # Set default currency to avoid warning + Money.default_currency = Money::Currency.new('USD') if Money.respond_to?(:default_currency=) +end \ No newline at end of file diff --git a/spec/dummy/config/initializers/new_framework_defaults_8_0.rb b/spec/dummy/config/initializers/new_framework_defaults_8_0.rb new file mode 100644 index 00000000..92efa951 --- /dev/null +++ b/spec/dummy/config/initializers/new_framework_defaults_8_0.rb @@ -0,0 +1,30 @@ +# Be sure to restart your server when you modify this file. +# +# This file eases your Rails 8.0 framework defaults upgrade. +# +# Uncomment each configuration one by one to switch to the new default. +# Once your application is ready to run with all new defaults, you can remove +# this file and set the `config.load_defaults` to `8.0`. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. +# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html + +### +# Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. +# If set to `:zone`, `to_time` methods will use the timezone of their receivers. +# If set to `:offset`, `to_time` methods will use the UTC offset. +# If `false`, `to_time` methods will convert to the local system UTC offset instead. +#++ +# Rails.application.config.active_support.to_time_preserves_timezone = :zone + +### +# When both `If-Modified-Since` and `If-None-Match` are provided by the client +# only consider `If-None-Match` as specified by RFC 7232 Section 6. +# If set to `false` both conditions need to be satisfied. +#++ +# Rails.application.config.action_dispatch.strict_freshness = true + +### +# Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks. +#++ +# Regexp.timeout = 1 diff --git a/spec/dummy/db/development.sqlite3 b/spec/dummy/db/development.sqlite3 index 01b224e2..77d04f44 100644 Binary files a/spec/dummy/db/development.sqlite3 and b/spec/dummy/db/development.sqlite3 differ diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index b2144d32..e25e86f0 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -2,19 +2,15 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_03_15_113046) do - - # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" - +ActiveRecord::Schema[8.0].define(version: 2025_10_19_143420) do create_table "gera_cbr_external_rates", force: :cascade do |t| t.date "date", null: false t.string "cur_from", null: false @@ -22,8 +18,8 @@ t.float "rate", null: false t.float "original_rate", null: false t.integer "nominal", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["cur_from", "cur_to", "date"], name: "index_cbr_external_rates_on_cur_from_and_cur_to_and_date", unique: true end @@ -32,27 +28,27 @@ t.string "cur_from", null: false t.string "cur_to", null: false t.bigint "rate_source_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["currency_rate_mode_id"], name: "index_cross_rate_modes_on_currency_rate_mode_id" t.index ["rate_source_id"], name: "index_cross_rate_modes_on_rate_source_id" end create_table "gera_currency_rate_history_intervals", force: :cascade do |t| - t.integer "cur_from_id", limit: 2, null: false - t.integer "cur_to_id", limit: 2, null: false + t.integer "cur_from_id", limit: 1, null: false + t.integer "cur_to_id", limit: 1, null: false t.float "min_rate", null: false t.float "avg_rate", null: false t.float "max_rate", null: false - t.datetime "interval_from", default: -> { "now()" }, null: false - t.datetime "interval_to", null: false + t.datetime "interval_from", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "interval_to", precision: nil, null: false t.index ["cur_from_id", "cur_to_id", "interval_from"], name: "crhi_unique_index", unique: true t.index ["interval_from"], name: "index_currency_rate_history_intervals_on_interval_from" end create_table "gera_currency_rate_mode_snapshots", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "status", default: 0, null: false t.string "title" t.text "details" @@ -64,8 +60,8 @@ t.string "cur_from", null: false t.string "cur_to", null: false t.integer "mode", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.bigint "currency_rate_mode_snapshot_id", null: false t.string "cross_currency1" t.bigint "cross_rate_source1_id" @@ -80,7 +76,7 @@ end create_table "gera_currency_rate_snapshots", force: :cascade do |t| - t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "created_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "currency_rate_mode_snapshot_id", null: false t.index ["currency_rate_mode_snapshot_id"], name: "fk_rails_456167e2a9" end @@ -88,10 +84,10 @@ create_table "gera_currency_rates", force: :cascade do |t| t.string "cur_from", null: false t.string "cur_to", null: false - t.float "rate_value", null: false + t.float "rate_value", limit: 53, null: false t.bigint "snapshot_id", null: false t.json "metadata", null: false - t.datetime "created_at" + t.datetime "created_at", precision: nil t.bigint "external_rate_id" t.integer "mode", null: false t.bigint "rate_source_id" @@ -112,8 +108,8 @@ t.float "max_rate", null: false t.float "min_comission", null: false t.float "max_comission", null: false - t.datetime "interval_from", default: -> { "now()" }, null: false - t.datetime "interval_to", null: false + t.datetime "interval_from", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "interval_to", precision: nil, null: false t.bigint "payment_system_to_id", null: false t.bigint "payment_system_from_id", null: false t.float "avg_rate", null: false @@ -123,17 +119,17 @@ end create_table "gera_direction_rate_snapshots", force: :cascade do |t| - t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "created_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false end create_table "gera_direction_rates", force: :cascade do |t| t.bigint "ps_from_id", null: false t.bigint "ps_to_id", null: false t.bigint "currency_rate_id", null: false - t.float "rate_value", null: false - t.float "base_rate_value", null: false + t.float "rate_value", limit: 53, null: false + t.float "base_rate_value", limit: 53, null: false t.float "rate_percent", null: false - t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "created_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "exchange_rate_id", null: false t.boolean "is_used", default: false, null: false t.bigint "snapshot_id" @@ -152,8 +148,11 @@ t.bigint "outcome_payment_system_id", null: false t.float "value", null: false t.boolean "is_enabled", default: false, null: false - t.datetime "updated_at", default: -> { "now()" }, null: false - t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "updated_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "created_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.integer "minamount_cents", default: 0 + t.integer "maxamount_cents", default: 0 + t.boolean "auto_rate", default: false t.index ["income_payment_system_id", "outcome_payment_system_id"], name: "exchange_rate_unique_index", unique: true t.index ["is_enabled"], name: "index_exchange_rates_on_is_enabled" t.index ["outcome_payment_system_id"], name: "fk_rails_ef77ea3609" @@ -161,8 +160,8 @@ create_table "gera_external_rate_snapshots", force: :cascade do |t| t.bigint "rate_source_id", null: false - t.datetime "actual_for", default: -> { "now()" }, null: false - t.datetime "created_at", null: false + t.datetime "actual_for", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "created_at", precision: nil, null: false t.index ["rate_source_id", "actual_for"], name: "index_external_rate_snapshots_on_rate_source_id_and_actual_for", unique: true t.index ["rate_source_id"], name: "index_external_rate_snapshots_on_rate_source_id" end @@ -171,25 +170,28 @@ t.bigint "source_id", null: false t.string "cur_from", null: false t.string "cur_to", null: false - t.float "rate_value" + t.float "rate_value", limit: 53 t.bigint "snapshot_id", null: false - t.datetime "created_at" + t.datetime "created_at", precision: nil t.index ["snapshot_id", "cur_from", "cur_to"], name: "index_external_rates_on_snapshot_id_and_cur_from_and_cur_to", unique: true t.index ["source_id"], name: "index_external_rates_on_source_id" end create_table "gera_payment_systems", force: :cascade do |t| t.string "name", limit: 60 - t.integer "priority", limit: 2 + t.integer "priority", limit: 1 t.string "img" t.integer "type_cy", null: false t.boolean "income_enabled", default: false, null: false t.boolean "outcome_enabled", default: false, null: false - t.datetime "deleted_at" - t.datetime "updated_at", default: -> { "now()" }, null: false - t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "deleted_at", precision: nil + t.datetime "updated_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "created_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.boolean "is_available", default: true, null: false t.string "icon_url" + t.float "commission", default: 0.0, null: false + t.integer "total_computation_method", default: 0 + t.integer "transfer_comission_payer", default: 0 t.index ["income_enabled"], name: "index_payment_systems_on_income_enabled" t.index ["outcome_enabled"], name: "index_payment_systems_on_outcome_enabled" end @@ -197,8 +199,8 @@ create_table "gera_rate_sources", force: :cascade do |t| t.string "title", null: false t.string "type", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "key", null: false t.bigint "actual_snapshot_id" t.integer "priority", default: 0, null: false @@ -208,27 +210,27 @@ t.index ["title"], name: "index_rate_sources_on_title", unique: true end - add_foreign_key "gera_cross_rate_modes", "gera_currency_rate_modes", column: "currency_rate_mode_id" - add_foreign_key "gera_cross_rate_modes", "gera_rate_sources", column: "rate_source_id" - add_foreign_key "gera_currency_rate_modes", "gera_currency_rate_mode_snapshots", column: "currency_rate_mode_snapshot_id" - add_foreign_key "gera_currency_rate_modes", "gera_rate_sources", column: "cross_rate_source1_id" - add_foreign_key "gera_currency_rate_modes", "gera_rate_sources", column: "cross_rate_source2_id" - add_foreign_key "gera_currency_rate_modes", "gera_rate_sources", column: "cross_rate_source3_id" - add_foreign_key "gera_currency_rate_snapshots", "gera_currency_rate_mode_snapshots", column: "currency_rate_mode_snapshot_id" + add_foreign_key "gera_cross_rate_modes", "gera_currency_rate_modes", column: "currency_rate_mode_id", on_delete: :cascade + add_foreign_key "gera_cross_rate_modes", "gera_rate_sources", column: "rate_source_id", on_delete: :cascade + add_foreign_key "gera_currency_rate_modes", "gera_currency_rate_mode_snapshots", column: "currency_rate_mode_snapshot_id", on_delete: :cascade + add_foreign_key "gera_currency_rate_modes", "gera_rate_sources", column: "cross_rate_source1_id", on_delete: :cascade + add_foreign_key "gera_currency_rate_modes", "gera_rate_sources", column: "cross_rate_source2_id", on_delete: :cascade + add_foreign_key "gera_currency_rate_modes", "gera_rate_sources", column: "cross_rate_source3_id", on_delete: :cascade + add_foreign_key "gera_currency_rate_snapshots", "gera_currency_rate_mode_snapshots", column: "currency_rate_mode_snapshot_id", on_delete: :cascade add_foreign_key "gera_currency_rates", "gera_currency_rate_snapshots", column: "snapshot_id", on_delete: :cascade - add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate1_id" - add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate2_id" - add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate3_id" + add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate1_id", on_delete: :cascade + add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate2_id", on_delete: :cascade + add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate3_id", on_delete: :cascade add_foreign_key "gera_currency_rates", "gera_external_rates", column: "external_rate_id", on_delete: :nullify - add_foreign_key "gera_currency_rates", "gera_rate_sources", column: "rate_source_id" - add_foreign_key "gera_direction_rate_history_intervals", "gera_payment_systems", column: "payment_system_from_id" - add_foreign_key "gera_direction_rate_history_intervals", "gera_payment_systems", column: "payment_system_to_id" + add_foreign_key "gera_currency_rates", "gera_rate_sources", column: "rate_source_id", on_delete: :cascade + add_foreign_key "gera_direction_rate_history_intervals", "gera_payment_systems", column: "payment_system_from_id", on_delete: :cascade + add_foreign_key "gera_direction_rate_history_intervals", "gera_payment_systems", column: "payment_system_to_id", on_delete: :cascade add_foreign_key "gera_direction_rates", "gera_currency_rates", column: "currency_rate_id", on_delete: :cascade - add_foreign_key "gera_direction_rates", "gera_exchange_rates", column: "exchange_rate_id" - add_foreign_key "gera_direction_rates", "gera_payment_systems", column: "ps_from_id" - add_foreign_key "gera_direction_rates", "gera_payment_systems", column: "ps_to_id" - add_foreign_key "gera_exchange_rates", "gera_payment_systems", column: "income_payment_system_id" - add_foreign_key "gera_exchange_rates", "gera_payment_systems", column: "outcome_payment_system_id" + add_foreign_key "gera_direction_rates", "gera_exchange_rates", column: "exchange_rate_id", on_delete: :cascade + add_foreign_key "gera_direction_rates", "gera_payment_systems", column: "ps_from_id", on_delete: :cascade + add_foreign_key "gera_direction_rates", "gera_payment_systems", column: "ps_to_id", on_delete: :cascade + add_foreign_key "gera_exchange_rates", "gera_payment_systems", column: "income_payment_system_id", on_delete: :cascade + add_foreign_key "gera_exchange_rates", "gera_payment_systems", column: "outcome_payment_system_id", on_delete: :cascade add_foreign_key "gera_external_rates", "gera_external_rate_snapshots", column: "snapshot_id", on_delete: :cascade - add_foreign_key "gera_external_rates", "gera_rate_sources", column: "source_id" + add_foreign_key "gera_external_rates", "gera_rate_sources", column: "source_id", on_delete: :cascade end diff --git a/spec/dummy/db/test.sqlite3 b/spec/dummy/db/test.sqlite3 index 01b224e2..6b53e9e4 100644 Binary files a/spec/dummy/db/test.sqlite3 and b/spec/dummy/db/test.sqlite3 differ diff --git a/spec/dummy/public/400.html b/spec/dummy/public/400.html new file mode 100644 index 00000000..282dbc8c --- /dev/null +++ b/spec/dummy/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/spec/dummy/public/406-unsupported-browser.html b/spec/dummy/public/406-unsupported-browser.html new file mode 100644 index 00000000..9532a9cc --- /dev/null +++ b/spec/dummy/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/spec/dummy/public/icon.png b/spec/dummy/public/icon.png new file mode 100644 index 00000000..c4c9dbfb Binary files /dev/null and b/spec/dummy/public/icon.png differ diff --git a/spec/dummy/public/icon.svg b/spec/dummy/public/icon.svg new file mode 100644 index 00000000..04b34bf8 --- /dev/null +++ b/spec/dummy/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/spec/dummy/public/robots.txt b/spec/dummy/public/robots.txt new file mode 100644 index 00000000..c19f78ab --- /dev/null +++ b/spec/dummy/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/spec/dummy/spec/spec_helper.rb b/spec/dummy/spec/spec_helper.rb new file mode 100644 index 00000000..251aa510 --- /dev/null +++ b/spec/dummy/spec/spec_helper.rb @@ -0,0 +1,100 @@ +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/spec/fixtures/gera_currency_rates.yml b/spec/fixtures/gera_currency_rates.yml new file mode 100644 index 00000000..b074790c --- /dev/null +++ b/spec/fixtures/gera_currency_rates.yml @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +usd_rub: + currency_from: "USD" + currency_to: "RUB" + rate: 60.5 + rate_source: cbr + external_rate_snapshot: cbr_snapshot + mode: "direct" + +btc_usd: + currency_from: "BTC" + currency_to: "USD" + rate: 45000.0 + rate_source: exmo + external_rate_snapshot: exmo_snapshot + mode: "direct" + +rub_usd: + currency_from: "RUB" + currency_to: "USD" + rate: 0.0165 + rate_source: cbr + external_rate_snapshot: cbr_snapshot + mode: "inverse" \ No newline at end of file diff --git a/spec/fixtures/gera_exchange_rates.yml b/spec/fixtures/gera_exchange_rates.yml new file mode 100644 index 00000000..12b17771 --- /dev/null +++ b/spec/fixtures/gera_exchange_rates.yml @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +one: + income_payment_system: one + outcome_payment_system: two + value: 1.5 + is_enabled: true + auto_rate: false + +btc_to_usd: + income_payment_system: btc + outcome_payment_system: usd + value: 2.0 + is_enabled: true + auto_rate: false + +usd_to_btc: + income_payment_system: usd + outcome_payment_system: btc + value: 1.8 + is_enabled: true + auto_rate: false \ No newline at end of file diff --git a/spec/fixtures/gera_external_rate_snapshots.yml b/spec/fixtures/gera_external_rate_snapshots.yml new file mode 100644 index 00000000..f4e7c5f1 --- /dev/null +++ b/spec/fixtures/gera_external_rate_snapshots.yml @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +one: + rate_source: one + created_at: <%= 1.hour.ago %> + +cbr_snapshot: + rate_source: cbr + created_at: <%= 30.minutes.ago %> + +exmo_snapshot: + rate_source: exmo + created_at: <%= 15.minutes.ago %> \ No newline at end of file diff --git a/spec/fixtures/gera_external_rates.yml b/spec/fixtures/gera_external_rates.yml new file mode 100644 index 00000000..e132db78 --- /dev/null +++ b/spec/fixtures/gera_external_rates.yml @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +usd_rub: + snapshot: cbr_snapshot + source: cbr + currency_from: "USD" + currency_to: "RUB" + rate: 60.5 + created_at: <%= 30.minutes.ago %> + +btc_usd: + snapshot: exmo_snapshot + source: exmo + currency_from: "BTC" + currency_to: "USD" + rate: 45000.0 + created_at: <%= 15.minutes.ago %> + +eth_usd: + snapshot: exmo_snapshot + source: exmo + currency_from: "ETH" + currency_to: "USD" + rate: 3000.0 + created_at: <%= 15.minutes.ago %> \ No newline at end of file diff --git a/spec/fixtures/gera_payment_systems.yml b/spec/fixtures/gera_payment_systems.yml new file mode 100644 index 00000000..821b7ed5 --- /dev/null +++ b/spec/fixtures/gera_payment_systems.yml @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +one: + name: "Yandex Money" + currency: "RUB" + income_enabled: true + outcome_enabled: true + is_available: true + commission: 5.0 + icon_url: "https://example.com/yandex.png" + +two: + name: "Qiwi" + currency: "RUB" + income_enabled: true + outcome_enabled: true + is_available: true + commission: 3.0 + icon_url: "https://example.com/qiwi.png" + +btc: + name: "Bitcoin" + currency: "BTC" + income_enabled: true + outcome_enabled: true + is_available: true + commission: 1.0 + icon_url: "https://example.com/btc.png" + +usd: + name: "Perfect Money" + currency: "USD" + income_enabled: true + outcome_enabled: true + is_available: true + commission: 2.5 + icon_url: "https://example.com/pm.png" \ No newline at end of file diff --git a/spec/fixtures/gera_rate_sources.yml b/spec/fixtures/gera_rate_sources.yml new file mode 100644 index 00000000..99625ebe --- /dev/null +++ b/spec/fixtures/gera_rate_sources.yml @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +one: + name: "Test Source 1" + type: "Gera::RateSourceManual" + is_enabled: true + +cbr: + name: "Central Bank of Russia" + type: "Gera::RateSourceCbr" + is_enabled: true + +exmo: + name: "EXMO" + type: "Gera::RateSourceExmo" + is_enabled: true + +binance: + name: "Binance" + type: "Gera::RateSourceBinance" + is_enabled: true \ No newline at end of file diff --git a/spec/gera_spec.rb b/spec/gera_spec.rb index ce93bc37..a5f7d261 100644 --- a/spec/gera_spec.rb +++ b/spec/gera_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'spec_helper' + RSpec.describe Gera do it 'has a version number' do expect(Gera::VERSION).not_to be nil diff --git a/spec/jobs/concerns/gera/rates_job_spec.rb b/spec/jobs/concerns/gera/rates_job_spec.rb new file mode 100644 index 00000000..6dab9def --- /dev/null +++ b/spec/jobs/concerns/gera/rates_job_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RatesJob do + # Create a test job class that includes RatesJob + let(:test_job_class) do + Class.new(ApplicationJob) do + include Gera::RatesJob + + attr_accessor :test_rate_source, :test_rates + + def rate_source + test_rate_source + end + + def load_rates + test_rates + end + + def rate_keys + { buy: 'buy_price', sell: 'sell_price' } + end + end + end + + let(:job) { test_job_class.new } + let!(:rate_source) { create(:rate_source_exmo) } + + before do + job.test_rate_source = rate_source + end + + describe '#perform' do + context 'with valid rates' do + before do + job.test_rates = { + 'BTC/USD' => { 'buy_price' => 50000.0, 'sell_price' => 50100.0 }, + 'ETH/USD' => { 'buy_price' => 3000.0, 'sell_price' => 3010.0 } + } + end + + it 'creates a rate source snapshot' do + expect { job.perform }.to change(ExternalRateSnapshot, :count).by(1) + end + + it 'returns snapshot id' do + result = job.perform + expect(result).to be_a(Integer) + end + + it 'enqueues ExternalRatesBatchJob' do + expect(ExternalRatesBatchJob).to receive(:perform_later) + .with(kind_of(Integer), rate_source.id, kind_of(Hash)) + job.perform + end + end + + context 'with empty rates' do + before do + job.test_rates = {} + end + + it 'creates a snapshot even with empty rates' do + expect { job.perform }.to change(ExternalRateSnapshot, :count).by(1) + end + end + + context 'with array-based rate data' do + let(:array_job_class) do + Class.new(ApplicationJob) do + include Gera::RatesJob + + attr_accessor :test_rate_source, :test_rates + + def rate_source + test_rate_source + end + + def load_rates + test_rates + end + + def rate_keys + { buy: 7, sell: 7 } + end + end + end + + let(:array_job) { array_job_class.new } + + before do + array_job.test_rate_source = rate_source + array_job.test_rates = { + 'BTC/USD' => [nil, nil, nil, nil, nil, nil, nil, 50000.0] + } + end + + it 'handles array-based rate data' do + expect { array_job.perform }.to change(ExternalRateSnapshot, :count).by(1) + end + end + end + + describe 'Error constant' do + it 'defines Error class' do + expect(described_class::Error).to be < StandardError + end + end + end +end diff --git a/spec/jobs/gera/binance_rates_job_spec.rb b/spec/jobs/gera/binance_rates_job_spec.rb new file mode 100644 index 00000000..e222d803 --- /dev/null +++ b/spec/jobs/gera/binance_rates_job_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe BinanceRatesJob do + let!(:rate_source) { create(:rate_source_binance) } + + describe '#perform' do + it 'uses BinanceFetcher to load rates' do + mock_fetcher = instance_double(BinanceFetcher) + allow(BinanceFetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:perform).and_return({}) + + job = described_class.new + job.perform + + expect(BinanceFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + + context 'with VCR cassette' do + it 'creates external rates from API response' do + VCR.use_cassette :binance_with_two_external_rates, allow_playback_repeats: true do + expect { described_class.new.perform }.to change(ExternalRateSnapshot, :count).by(1) + end + end + end + end + + describe '#rate_keys' do + it 'returns bidPrice and askPrice keys' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'bidPrice', sell: 'askPrice' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceBinance' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/bitfinex_rates_job_spec.rb b/spec/jobs/gera/bitfinex_rates_job_spec.rb new file mode 100644 index 00000000..a469faea --- /dev/null +++ b/spec/jobs/gera/bitfinex_rates_job_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe BitfinexRatesJob do + let!(:rate_source) { create(:rate_source_bitfinex) } + + describe '#perform' do + it 'uses BitfinexFetcher to load rates' do + mock_fetcher = instance_double(BitfinexFetcher) + allow(BitfinexFetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:perform).and_return({}) + + job = described_class.new + job.perform + + expect(BitfinexFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns array index 7 for both buy and sell' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 7, sell: 7 }) + end + end + + describe '#rate_source' do + it 'returns RateSourceBitfinex' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/bybit_rates_job_spec.rb b/spec/jobs/gera/bybit_rates_job_spec.rb new file mode 100644 index 00000000..60adbfa2 --- /dev/null +++ b/spec/jobs/gera/bybit_rates_job_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe BybitRatesJob do + let!(:rate_source) { create(:rate_source_bybit) } + + # Stub BybitFetcher class which may have external dependencies + before do + stub_const('Gera::BybitFetcher', Class.new do + def perform + {} + end + end) + end + + describe '#perform' do + it 'uses BybitFetcher to load rates' do + mock_fetcher = double('BybitFetcher', perform: {}) + allow(Gera::BybitFetcher).to receive(:new).and_return(mock_fetcher) + + job = described_class.new + job.perform + + expect(Gera::BybitFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns price for both buy and sell' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'price', sell: 'price' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceBybit' do + # rate_source method does RateSourceBybit.get! which requires DB record + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/cbr_avg_rates_job_spec.rb b/spec/jobs/gera/cbr_avg_rates_job_spec.rb new file mode 100644 index 00000000..0dbaa2c5 --- /dev/null +++ b/spec/jobs/gera/cbr_avg_rates_job_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CbrAvgRatesJob do + let!(:cbr_avg_source) { create(:rate_source_cbr_avg) } + let!(:cbr_source) { create(:rate_source_cbr) } + + describe '#perform' do + context 'with empty available_pairs' do + before do + # Stub the instance methods instead of class methods + allow_any_instance_of(RateSourceCbrAvg).to receive(:available_pairs).and_return([]) + end + + it 'creates a new snapshot' do + expect { subject.perform }.to change(ExternalRateSnapshot, :count).by(1) + end + + it 'updates actual_snapshot_id' do + subject.perform + expect(cbr_avg_source.reload.actual_snapshot_id).not_to be_nil + end + end + end + + describe 'concurrency limits' do + it 'has limits_concurrency configured' do + # ActiveJob with Solid Queue uses limits_concurrency + expect(described_class).to respond_to(:queue_name) + end + end + end +end diff --git a/spec/jobs/gera/cbr_rates_job_spec.rb b/spec/jobs/gera/cbr_rates_job_spec.rb new file mode 100644 index 00000000..2278adf9 --- /dev/null +++ b/spec/jobs/gera/cbr_rates_job_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'stringio' +require 'ostruct' + +module Gera + RSpec.describe CbrRatesJob do + before do + create :rate_source_exmo + create :rate_source_cbr_avg + create :rate_source_cbr + + # Mock the external HTTP request to avoid VCR/network issues + mock_cbr_response + end + + let(:today) { Date.parse '13/03/2018' } + + it do + expect(ExternalRate.count).to be_zero + + # На teamcity почему-то дата возвращается как 2018-03-12 + allow(Date).to receive(:today).and_return today + Timecop.freeze(today) do + expect(CbrRatesJob.new.perform).to be_truthy + end + + expect(ExternalRate.count).to be > 0 + end + + private + + def mock_cbr_response + # Mock the entire fetch_rates method to return XML root node + today = Date.parse('13/03/2018') + job = CbrRatesJob.new + + # Create mock XML root node + root = double('XML root') + + # Mock fetch_rates to return XML root for each date + allow(job).to receive(:fetch_rates) do |date| + next if date != today # Only return data for the target date + root + end + + # Mock get_rate to return rate data + allow(job).to receive(:get_rate) do |xml_root, currency_id| + rate_data = { + 'R01235' => 56.7594, # USD + 'R01335' => 1.67351, # KZT (100 -> 16.7351) + 'R01239' => 70.1974, # EUR + 'R01720' => 2.03578, # UAH (10 -> 20.3578) + 'R01717' => 0.0068372, # UZS (1000 -> 6.8372) + 'R01020A' => 33.4799, # AZN + 'R01090B' => 28.6515, # BYN + 'R01700J' => 14.0985, # TRY + 'R01675' => 1.79972, # THB (10 -> 17.9972) + 'R01280' => 0.00041809 # IDR (10000 -> 4.1809) + } + + rate = rate_data[currency_id] + OpenStruct.new(original_rate: rate, nominal: 1.0) if rate + end + + allow(CbrRatesJob).to receive(:new).and_return(job) + end + end +end diff --git a/spec/jobs/gera/create_history_intervals_job_spec.rb b/spec/jobs/gera/create_history_intervals_job_spec.rb new file mode 100644 index 00000000..06435464 --- /dev/null +++ b/spec/jobs/gera/create_history_intervals_job_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CreateHistoryIntervalsJob do + describe 'constants' do + it 'defines MAXIMAL_DATE as 30 minutes' do + expect(described_class::MAXIMAL_DATE).to eq(30.minutes) + end + + it 'defines MINIMAL_DATE' do + expect(described_class::MINIMAL_DATE).to be_a(Time) + end + end + + describe '#perform' do + context 'when tables exist and enable_direction_rate_history_intervals is true' do + before do + allow(Gera).to receive(:enable_direction_rate_history_intervals).and_return(true) + end + + it 'calls save_direction_rate_history_intervals' do + expect(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(true) + expect(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(true) + + job = described_class.new + # Stub the actual save methods to avoid complex setup + allow(job).to receive(:save_direction_rate_history_intervals) + allow(job).to receive(:save_currency_rate_history_intervals) + + job.perform + + expect(job).to have_received(:save_direction_rate_history_intervals) + expect(job).to have_received(:save_currency_rate_history_intervals) + end + end + + context 'when tables exist but enable_direction_rate_history_intervals is false' do + before do + allow(Gera).to receive(:enable_direction_rate_history_intervals).and_return(false) + end + + it 'skips save_direction_rate_history_intervals but saves currency_rate_history_intervals' do + expect(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(true) + expect(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(true) + + job = described_class.new + allow(job).to receive(:save_direction_rate_history_intervals) + allow(job).to receive(:save_currency_rate_history_intervals) + + job.perform + + expect(job).not_to have_received(:save_direction_rate_history_intervals) + expect(job).to have_received(:save_currency_rate_history_intervals) + end + end + + context 'when tables do not exist' do + it 'skips saving intervals' do + allow(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(false) + allow(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(false) + + expect(DirectionRateHistoryInterval).not_to receive(:create_multiple_intervals_from!) + expect(CurrencyRateHistoryInterval).not_to receive(:create_multiple_intervals_from!) + + subject.perform + end + end + end + + describe 'concurrency limits' do + it 'has limits_concurrency configured' do + # ActiveJob with Solid Queue uses limits_concurrency + expect(described_class).to respond_to(:queue_name) + end + end + end +end diff --git a/spec/jobs/gera/cryptomus_rates_job_spec.rb b/spec/jobs/gera/cryptomus_rates_job_spec.rb new file mode 100644 index 00000000..b755233c --- /dev/null +++ b/spec/jobs/gera/cryptomus_rates_job_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CryptomusRatesJob do + let!(:rate_source) { create(:rate_source_cryptomus) } + + # Stub CryptomusFetcher class which has external dependencies (PaymentServices) + before do + stub_const('Gera::CryptomusFetcher', Class.new do + def perform + {} + end + end) + end + + describe '#perform' do + it 'uses CryptomusFetcher to load rates' do + mock_fetcher = double('CryptomusFetcher', perform: {}) + allow(Gera::CryptomusFetcher).to receive(:new).and_return(mock_fetcher) + + job = described_class.new + job.perform + + expect(Gera::CryptomusFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns course for both buy and sell' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'course', sell: 'course' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceCryptomus' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/currency_rates_job_spec.rb b/spec/jobs/gera/currency_rates_job_spec.rb new file mode 100644 index 00000000..d8cc360f --- /dev/null +++ b/spec/jobs/gera/currency_rates_job_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRatesJob do + it do + expect(CurrencyRatesJob.new.perform).to be_truthy + end + + describe 'graceful handling when rate cannot be calculated' do + let(:job) { CurrencyRatesJob.new } + let(:pair) { CurrencyPair.new(cur_from: Money::Currency.find(:usd), cur_to: Money::Currency.find(:rub)) } + let(:currency_rate_mode) { instance_double(CurrencyRateMode, mode: 'auto', build_currency_rate: nil) } + let(:snapshot) { instance_double(CurrencyRateSnapshot) } + let(:logger) { instance_double(Logger) } + + before do + allow(job).to receive(:find_currency_rate_mode_by_pair).with(pair).and_return(currency_rate_mode) + allow(job).to receive(:logger).and_return(logger) + allow(logger).to receive(:debug) + allow(logger).to receive(:warn) + end + + it 'logs warning and continues without raising error' do + expect(logger).to receive(:warn).with(/Unable to calculate rate for.*auto/) + + job.send(:create_rate, pair: pair, snapshot: snapshot) + end + + it 'does not notify Bugsnag for missing rates' do + if defined?(Bugsnag) + expect(Bugsnag).not_to receive(:notify) + end + + job.send(:create_rate, pair: pair, snapshot: snapshot) + end + end + end +end diff --git a/spec/jobs/gera/directions_rates_job_spec.rb b/spec/jobs/gera/directions_rates_job_spec.rb new file mode 100644 index 00000000..68afcfa9 --- /dev/null +++ b/spec/jobs/gera/directions_rates_job_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe DirectionsRatesJob do + # Stub BestChange::Service which is defined in host app + before do + stub_const('BestChange::Service', Class.new do + def initialize(exchange_rate:); end + def rows_without_kassa; []; end + end) + end + + let!(:currency_rate_snapshot) { create(:currency_rate_snapshot) } + let!(:payment_system_from) { create(:gera_payment_system, currency: Money::Currency.find('USD')) } + let!(:payment_system_to) { create(:gera_payment_system, currency: Money::Currency.find('RUB')) } + let!(:exchange_rate) do + create(:gera_exchange_rate, + payment_system_from: payment_system_from, + payment_system_to: payment_system_to) + end + let!(:currency_rate) do + create(:currency_rate, + snapshot: currency_rate_snapshot, + cur_from: Money::Currency.find('USD'), + cur_to: Money::Currency.find('RUB')) + end + + describe '#perform' do + it 'creates a new DirectionRateSnapshot' do + expect { subject.perform }.to change(DirectionRateSnapshot, :count).by(1) + end + + it 'creates direction rates for each exchange rate' do + expect { subject.perform }.to change(DirectionRate, :count).by_at_least(1) + end + + it 'logs start and finish' do + expect(subject).to receive(:logger).at_least(:twice).and_return(double(info: nil)) + subject.perform + end + end + + describe 'queue configuration' do + it 'uses critical queue' do + expect(described_class.queue_name).to eq('critical') + end + end + + describe 'Error constant' do + it 'defines Error class' do + expect(described_class::Error).to be < StandardError + end + end + end +end diff --git a/spec/jobs/gera/exchange_rate_updater_job_spec.rb b/spec/jobs/gera/exchange_rate_updater_job_spec.rb new file mode 100644 index 00000000..6df12703 --- /dev/null +++ b/spec/jobs/gera/exchange_rate_updater_job_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExchangeRateUpdaterJob do + # Stub Yabeda metrics which may not be configured in test + before do + yabeda_exchange = double('yabeda_exchange') + allow(yabeda_exchange).to receive(:exchange_rate_touch_count).and_return( + double('counter', increment: nil) + ) + stub_const('Yabeda', double('Yabeda', exchange: yabeda_exchange)) + end + + let!(:exchange_rate) { create(:gera_exchange_rate) } + + describe '#perform' do + let(:attributes) { { 'is_enabled' => false } } + + it 'updates exchange rate with given attributes' do + expect { + subject.perform(exchange_rate.id, attributes) + }.to change { exchange_rate.reload.is_enabled }.from(true).to(false) + end + + it 'increments yabeda metric' do + expect(Yabeda.exchange.exchange_rate_touch_count).to receive(:increment) + + subject.perform(exchange_rate.id, attributes) + end + + context 'with non-existent exchange rate' do + it 'does not raise error' do + expect { + subject.perform(-1, attributes) + }.not_to raise_error + end + end + end + + describe 'queue configuration' do + it 'uses exchange_rates queue' do + expect(described_class.queue_name).to eq('exchange_rates') + end + end + end +end diff --git a/spec/jobs/gera/exmo_rates_job_spec.rb b/spec/jobs/gera/exmo_rates_job_spec.rb new file mode 100644 index 00000000..fb0389b7 --- /dev/null +++ b/spec/jobs/gera/exmo_rates_job_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExmoRatesJob do + let!(:rate_source) { create(:rate_source_exmo) } + + describe '#perform' do + it 'uses ExmoFetcher to load rates' do + mock_fetcher = instance_double(ExmoFetcher) + allow(ExmoFetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:perform).and_return({}) + + job = described_class.new + job.perform + + expect(ExmoFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns buy_price and sell_price keys' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'buy_price', sell: 'sell_price' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceExmo' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/external_rate_saver_job_spec.rb b/spec/jobs/gera/external_rate_saver_job_spec.rb new file mode 100644 index 00000000..a83fc041 --- /dev/null +++ b/spec/jobs/gera/external_rate_saver_job_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExternalRateSaverJob do + let!(:rate_source) { create(:rate_source_exmo) } + let!(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + + describe '#perform' do + let(:currency_pair) { 'BTC/USD' } + let(:rate) do + { + 'value' => 50000.0, + 'source_class_name' => 'Gera::RateSourceExmo', + 'source_id' => rate_source.id + } + end + let(:source_rates_count) { 1 } + + it 'creates an external rate' do + expect { + subject.perform(currency_pair, snapshot.id, rate, source_rates_count) + }.to change(ExternalRate, :count).by(1) + end + + it 'creates rate with correct attributes' do + subject.perform(currency_pair, snapshot.id, rate, source_rates_count) + + external_rate = ExternalRate.last + expect(external_rate.cur_from).to eq('BTC') + expect(external_rate.cur_to).to eq('USD') + expect(external_rate.rate_value).to eq(50000.0) + expect(external_rate.source).to eq(rate_source) + expect(external_rate.snapshot).to eq(snapshot) + end + + context 'when snapshot is filled up' do + before do + # Create one external rate so total will be 2 (source_rates_count * 2) + create(:external_rate, source: rate_source, snapshot: snapshot, cur_from: 'ETH', cur_to: 'BTC') + end + + it 'updates actual_snapshot_id' do + # source_rates_count = 1, so expected count is 2 + subject.perform(currency_pair, snapshot.id, rate, 1) + expect(rate_source.reload.actual_snapshot_id).to eq(snapshot.id) + end + end + end + + describe 'queue configuration' do + it 'uses external_rates queue' do + expect(described_class.queue_name).to eq('external_rates') + end + end + end +end diff --git a/spec/jobs/gera/external_rates_batch_job_spec.rb b/spec/jobs/gera/external_rates_batch_job_spec.rb new file mode 100644 index 00000000..6ea0905f --- /dev/null +++ b/spec/jobs/gera/external_rates_batch_job_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExternalRatesBatchJob do + let!(:rate_source) { create(:rate_source_exmo) } + let!(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + + describe '#perform' do + let(:rates) do + { + 'BTC/USD' => { 'buy' => 50000.0, 'sell' => 50100.0 }, + 'ETH/USD' => { 'buy' => 3000.0, 'sell' => 3010.0 } + } + end + + it 'creates external rates for each currency pair' do + expect { + subject.perform(snapshot.id, rate_source.id, rates) + }.to change(ExternalRate, :count).by(4) # 2 pairs * 2 (buy + inverse) + end + + it 'updates rate_source actual_snapshot_id' do + subject.perform(snapshot.id, rate_source.id, rates) + expect(rate_source.reload.actual_snapshot_id).to eq(snapshot.id) + end + + context 'with symbol keys' do + let(:rates) do + { + 'BTC/USD' => { buy: 50000.0, sell: 50100.0 } + } + end + + it 'handles symbol keys correctly' do + expect { + subject.perform(snapshot.id, rate_source.id, rates) + }.to change(ExternalRate, :count).by(2) + end + end + + context 'with invalid rates' do + let(:rates) do + { + 'BTC/USD' => { 'buy' => nil, 'sell' => 50100.0 }, + 'ETH/USD' => { 'buy' => 0, 'sell' => 3010.0 }, + 'LTC/USD' => { 'buy' => -1, 'sell' => 100.0 } + } + end + + it 'skips invalid rates' do + expect { + subject.perform(snapshot.id, rate_source.id, rates) + }.not_to change(ExternalRate, :count) + end + end + + context 'with empty rates' do + let(:rates) { {} } + + it 'still updates actual_snapshot_id' do + subject.perform(snapshot.id, rate_source.id, rates) + expect(rate_source.reload.actual_snapshot_id).to eq(snapshot.id) + end + end + end + end +end diff --git a/spec/jobs/gera/ff_fixed_rates_job_spec.rb b/spec/jobs/gera/ff_fixed_rates_job_spec.rb new file mode 100644 index 00000000..46dc414b --- /dev/null +++ b/spec/jobs/gera/ff_fixed_rates_job_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe FfFixedRatesJob do + let!(:rate_source) { create(:rate_source_ff_fixed) } + + describe '#perform' do + it 'uses FfFixedFetcher to load rates' do + mock_fetcher = instance_double(FfFixedFetcher) + allow(FfFixedFetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:perform).and_return({}) + + job = described_class.new + job.perform + + expect(FfFixedFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns out for both buy and sell' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'out', sell: 'out' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceFfFixed' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/ff_float_rates_job_spec.rb b/spec/jobs/gera/ff_float_rates_job_spec.rb new file mode 100644 index 00000000..0fa85ff8 --- /dev/null +++ b/spec/jobs/gera/ff_float_rates_job_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe FfFloatRatesJob do + let!(:rate_source) { create(:rate_source_ff_float) } + + describe '#perform' do + it 'uses FfFloatFetcher to load rates' do + mock_fetcher = instance_double(FfFloatFetcher) + allow(FfFloatFetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:perform).and_return({}) + + job = described_class.new + job.perform + + expect(FfFloatFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns out for both buy and sell' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'out', sell: 'out' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceFfFloat' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/jobs/gera/garantexio_rates_job_spec.rb b/spec/jobs/gera/garantexio_rates_job_spec.rb new file mode 100644 index 00000000..5fdc41dd --- /dev/null +++ b/spec/jobs/gera/garantexio_rates_job_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe GarantexioRatesJob do + let!(:rate_source) { create(:rate_source_garantexio) } + + describe '#perform' do + it 'uses GarantexioFetcher to load rates' do + mock_fetcher = instance_double(GarantexioFetcher) + allow(GarantexioFetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:perform).and_return({}) + + job = described_class.new + job.perform + + expect(GarantexioFetcher).to have_received(:new) + expect(mock_fetcher).to have_received(:perform) + end + end + + describe '#rate_keys' do + it 'returns last_price for both buy and sell' do + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'last_price', sell: 'last_price' }) + end + end + + describe '#rate_source' do + it 'returns RateSourceGarantexio' do + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) + end + end + end +end diff --git a/spec/lib/bitfinex_fetcher_spec.rb b/spec/lib/bitfinex_fetcher_spec.rb index 2d10030f..5b0d9e5a 100644 --- a/spec/lib/bitfinex_fetcher_spec.rb +++ b/spec/lib/bitfinex_fetcher_spec.rb @@ -8,11 +8,11 @@ module Gera # {"mid":"0.00408895","bid":"0.0040889","ask":"0.004089","last_price":"0.0040889","low":"0.0040562","high":"0.0041476","volume":"7406.62321845","timestamp":"1532882027.7319012"} # {"mid":"8228.25","bid":"8228.2","ask":"8228.3","last_price":"8228.3","low":"8055.0","high":"8313.3","volume":"13611.826947359996","timestamp":"1532874580.9087598"} - subject { described_class.new(ticker: 'neousd').perform } + subject { described_class.new.perform } it do expect(subject).to be_a Hash - expect(subject['low']).to be_present + expect(subject.keys).not_to be_empty end end end diff --git a/spec/lib/builders/currency_rate_auto_builder_spec.rb b/spec/lib/builders/currency_rate_auto_builder_spec.rb new file mode 100644 index 00000000..cac1402b --- /dev/null +++ b/spec/lib/builders/currency_rate_auto_builder_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateAutoBuilder do + let!(:rate_source) { create(:rate_source_exmo, priority: 1) } + let!(:external_rate_snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + + before do + rate_source.update!(actual_snapshot_id: external_rate_snapshot.id) + allow(Gera).to receive(:default_cross_currency).and_return(:USD) + allow(Gera).to receive(:cross_pairs).and_return({}) + end + + describe '#build_currency_rate' do + context 'when currency pair is same (e.g., USD/USD)' do + let(:currency_pair) { CurrencyPair.new('USD/USD') } + subject { described_class.new(currency_pair: currency_pair) } + + it 'returns SuccessResult with rate_value 1' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::SuccessResult) + expect(result.currency_rate.rate_value).to eq(1) + end + + it 'sets mode to same' do + result = subject.build_currency_rate + expect(result.currency_rate.mode).to eq('same') + end + end + + context 'when direct rate exists in source' do + let(:currency_pair) { CurrencyPair.new('BTC/USD') } + subject { described_class.new(currency_pair: currency_pair) } + + let!(:external_rate) do + create(:external_rate, + snapshot: external_rate_snapshot, + cur_from: Money::Currency.find(:BTC), + cur_to: Money::Currency.find(:USD), + rate_value: 50_000) + end + + it 'returns SuccessResult from direct source' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::SuccessResult) + expect(result.currency_rate.rate_value).to eq(50_000) + end + + it 'sets mode to direct' do + result = subject.build_currency_rate + expect(result.currency_rate.mode).to eq('direct') + end + end + + context 'when cross rate needs to be calculated' do + let(:currency_pair) { CurrencyPair.new('ETH/RUB') } + subject { described_class.new(currency_pair: currency_pair) } + + let!(:eth_usd_rate) do + create(:external_rate, + snapshot: external_rate_snapshot, + cur_from: Money::Currency.find(:ETH), + cur_to: Money::Currency.find(:USD), + rate_value: 3000) + end + + let!(:usd_rub_rate) do + create(:external_rate, + snapshot: external_rate_snapshot, + cur_from: Money::Currency.find(:USD), + cur_to: Money::Currency.find(:RUB), + rate_value: 95) + end + + it 'returns SuccessResult with cross rate' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::SuccessResult) + end + + it 'calculates cross rate correctly' do + result = subject.build_currency_rate + # ETH/RUB = ETH/USD * USD/RUB = 3000 * 95 = 285000 + expect(result.currency_rate.rate_value).to eq(285_000) + end + + it 'sets mode to cross' do + result = subject.build_currency_rate + expect(result.currency_rate.mode).to eq('cross') + end + end + + context 'when no rate can be found' do + # Using valid currencies that don't have external rates + let(:currency_pair) { CurrencyPair.new('ZEC/NEO') } + subject { described_class.new(currency_pair: currency_pair) } + + it 'returns ErrorResult' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::ErrorResult) + end + end + end + + describe '#build_same' do + let(:currency_pair) { CurrencyPair.new('EUR/EUR') } + subject { described_class.new(currency_pair: currency_pair) } + + it 'returns CurrencyRate with rate_value 1 for same currencies' do + result = subject.send(:build_same) + expect(result.rate_value).to eq(1) + expect(result.mode).to eq('same') + end + + it 'returns nil for different currencies' do + builder = described_class.new(currency_pair: CurrencyPair.new('BTC/USD')) + expect(builder.send(:build_same)).to be_nil + end + end + end +end diff --git a/spec/lib/builders/currency_rate_builder_spec.rb b/spec/lib/builders/currency_rate_builder_spec.rb new file mode 100644 index 00000000..b742767e --- /dev/null +++ b/spec/lib/builders/currency_rate_builder_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateBuilder do + let(:currency_pair) { CurrencyPair.new('BTC/USD') } + + describe CurrencyRateBuilder::SuccessResult do + let(:currency_rate) { instance_double(CurrencyRate) } + let(:result) { described_class.new(currency_rate: currency_rate) } + + it 'returns true for success?' do + expect(result.success?).to be true + end + + it 'returns false for error?' do + expect(result.error?).to be false + end + + it 'returns currency_rate' do + expect(result.currency_rate).to eq(currency_rate) + end + end + + describe CurrencyRateBuilder::ErrorResult do + let(:error) { StandardError.new('test error') } + let(:result) { described_class.new(error: error) } + + it 'returns false for success?' do + expect(result.success?).to be false + end + + it 'returns true for error?' do + expect(result.error?).to be true + end + + it 'returns nil for currency_rate' do + expect(result.currency_rate).to be_nil + end + + it 'returns error' do + expect(result.error).to eq(error) + end + end + + describe '#build_currency_rate' do + subject { described_class.new(currency_pair: currency_pair) } + + it 'raises error because build is not implemented' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::ErrorResult) + expect(result.error.message).to eq('not implemented') + end + end + end +end diff --git a/spec/lib/builders/currency_rate_direct_builder_spec.rb b/spec/lib/builders/currency_rate_direct_builder_spec.rb new file mode 100644 index 00000000..be323f4a --- /dev/null +++ b/spec/lib/builders/currency_rate_direct_builder_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateDirectBuilder do + let!(:rate_source) { create(:rate_source_exmo) } + let!(:external_rate_snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + let(:currency_pair) { CurrencyPair.new('BTC/USD') } + + before do + rate_source.update!(actual_snapshot_id: external_rate_snapshot.id) + end + + describe '#build_currency_rate' do + subject { described_class.new(currency_pair: currency_pair, source: rate_source) } + + context 'when external rate exists' do + let!(:external_rate) do + create(:external_rate, + snapshot: external_rate_snapshot, + cur_from: Money::Currency.find(:BTC), + cur_to: Money::Currency.find(:USD), + rate_value: 50_000) + end + + it 'returns SuccessResult' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::SuccessResult) + expect(result.success?).to be true + end + + it 'builds CurrencyRate with correct attributes' do + result = subject.build_currency_rate + currency_rate = result.currency_rate + + expect(currency_rate.currency_pair).to eq(currency_pair) + expect(currency_rate.rate_value).to eq(50_000) + expect(currency_rate.rate_source).to eq(rate_source) + expect(currency_rate.mode).to eq('direct') + expect(currency_rate.external_rate_id).to eq(external_rate.id) + end + end + + context 'when currency is not supported by source' do + # Using valid currencies not supported by EXMO (KZT is not in EXMO's supported list) + let(:currency_pair) { CurrencyPair.new('KZT/USD') } + + it 'returns ErrorResult' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::ErrorResult) + expect(result.error?).to be true + end + end + + context 'when external rate does not exist' do + let(:currency_pair) { CurrencyPair.new('BCH/EUR') } + + it 'returns ErrorResult' do + result = subject.build_currency_rate + expect(result).to be_a(CurrencyRateBuilder::ErrorResult) + expect(result.error?).to be true + end + end + end + end +end diff --git a/spec/lib/configuration_spec.rb b/spec/lib/configuration_spec.rb new file mode 100644 index 00000000..ad726a84 --- /dev/null +++ b/spec/lib/configuration_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gera::Configuration do + describe '.configure' do + it 'yields self to block' do + expect { |b| Gera.configure(&b) }.to yield_with_args(Gera) + end + end + + describe '.default_cross_currency' do + it 'returns Money::Currency object' do + expect(Gera.default_cross_currency).to be_a(Money::Currency) + end + + it 'defaults to USD' do + expect(Gera.default_cross_currency.iso_code).to eq('USD') + end + end + + describe '.cross_pairs' do + it 'returns hash with Money::Currency keys and values' do + result = Gera.cross_pairs + expect(result).to be_a(Hash) + result.each do |key, value| + expect(key).to be_a(Money::Currency) + expect(value).to be_a(Money::Currency) + end + end + end + + describe '.payment_system_decorator' do + it 'responds to payment_system_decorator' do + expect(Gera).to respond_to(:payment_system_decorator) + end + end + + describe '.enable_direction_rate_history_intervals' do + it 'defaults to true' do + expect(Gera.enable_direction_rate_history_intervals).to be true + end + + it 'can be configured' do + original_value = Gera.enable_direction_rate_history_intervals + begin + Gera.enable_direction_rate_history_intervals = false + expect(Gera.enable_direction_rate_history_intervals).to be false + ensure + Gera.enable_direction_rate_history_intervals = original_value + end + end + end +end diff --git a/spec/lib/currencies_purger_spec.rb b/spec/lib/currencies_purger_spec.rb new file mode 100644 index 00000000..24a7a187 --- /dev/null +++ b/spec/lib/currencies_purger_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'gera/currencies_purger' + +RSpec.describe Gera::CurrenciesPurger do + describe '.purge_all' do + it 'raises error when env does not match Rails.env' do + expect { described_class.purge_all('wrong_env') }.to raise_error(RuntimeError) + end + + it 'responds to purge_all method' do + expect(described_class).to respond_to(:purge_all) + end + + # Note: Full integration testing of purge_all would require + # complex database setup and is risky to run in test environment. + # The method is designed for production/staging maintenance. + end +end diff --git a/spec/lib/gera/binance_fetcher_spec.rb b/spec/lib/gera/binance_fetcher_spec.rb new file mode 100644 index 00000000..17b62891 --- /dev/null +++ b/spec/lib/gera/binance_fetcher_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe BinanceFetcher do + describe '#perform' do + let(:api_response) do + [ + { 'symbol' => 'BTCUSDT', 'bidPrice' => '50000.00', 'askPrice' => '50001.00' }, + { 'symbol' => 'ETHUSDT', 'bidPrice' => '3000.00', 'askPrice' => '3001.00' }, + { 'symbol' => 'UNKNOWN123', 'bidPrice' => '1.00', 'askPrice' => '1.01' } + ].to_json + end + + let(:response) { double('response', code: 200, body: api_response) } + + before do + allow(RestClient::Request).to receive(:execute).and_return(response) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + context 'with VCR cassette' do + it 'fetches rates from Binance API' do + VCR.use_cassette :binance_with_two_external_rates, allow_playback_repeats: true do + result = subject.perform + expect(result).to be_a(Hash) + end + end + end + + context 'when price is zero' do + let(:api_response) do + [ + { 'symbol' => 'BTCUSDT', 'bidPrice' => '0.00000000', 'askPrice' => '50001.00' } + ].to_json + end + + it 'skips pairs with zero prices' do + result = subject.perform + expect(result).to be_empty + end + end + end + + describe '#price_is_missed?' do + it 'returns true when bidPrice is zero' do + rate = { 'bidPrice' => '0.00000000', 'askPrice' => '1.00' } + expect(subject.send(:price_is_missed?, rate: rate)).to be true + end + + it 'returns true when askPrice is zero' do + rate = { 'bidPrice' => '1.00', 'askPrice' => '0.00000000' } + expect(subject.send(:price_is_missed?, rate: rate)).to be true + end + + it 'returns false when both prices are non-zero' do + rate = { 'bidPrice' => '1.00', 'askPrice' => '1.01' } + expect(subject.send(:price_is_missed?, rate: rate)).to be false + end + end + + describe '#currency_name' do + it 'returns DASH for DSH currency' do + expect(subject.send(:currency_name, :DSH)).to eq('DASH') + end + + it 'returns currency name as is for other currencies' do + expect(subject.send(:currency_name, :BTC)).to eq('BTC') + end + end + + describe '#supported_currencies' do + it 'returns currencies from RateSourceBinance' do + expect(subject.send(:supported_currencies)).to eq(RateSourceBinance.supported_currencies) + end + end + end +end diff --git a/spec/lib/gera/bitfinex_fetcher_spec.rb b/spec/lib/gera/bitfinex_fetcher_spec.rb new file mode 100644 index 00000000..64bd3d73 --- /dev/null +++ b/spec/lib/gera/bitfinex_fetcher_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe BitfinexFetcher do + describe '#perform' do + let(:api_response) do + [ + ['tBTCUSD', 50000.0, 1.5, 50001.0, 2.0, 100.0, 0.01, 50000.5, 1000.0, 51000.0, 49000.0], + ['tETHUSD', 3000.0, 10.0, 3001.0, 15.0, 50.0, 0.02, 3000.5, 5000.0, 3100.0, 2900.0], + ['tUNKNOWN', 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0] + ].to_json + end + + let(:response) { double('response', code: 200, body: api_response) } + + before do + allow(RestClient::Request).to receive(:execute).and_return(response) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + context 'when price is zero' do + let(:api_response) do + [ + ['tBTCUSD', 50000.0, 1.5, 50001.0, 2.0, 100.0, 0.01, 0.0, 1000.0, 51000.0, 49000.0] + ].to_json + end + + it 'skips pairs with zero prices' do + result = subject.perform + expect(result).to be_empty + end + end + + context 'with VCR cassette' do + it 'fetches rates from Bitfinex API' do + VCR.use_cassette 'Gera_BitfinexFetcher/1_1', allow_playback_repeats: true do + result = subject.perform + expect(result).to be_a(Hash) + end + end + end + end + + describe '#find_cur_from' do + it 'finds currency from symbol with t prefix' do + # tBTCUSD should find BTC + result = subject.send(:find_cur_from, 'tBTCUSD') + expect(result).to eq(Money::Currency.find(:BTC)) + end + + it 'returns nil for unsupported currencies' do + result = subject.send(:find_cur_from, 'tUNKNOWN123') + expect(result).to be_nil + end + end + + describe '#price_is_missed?' do + it 'returns true when rate[7] is zero' do + rate = [nil, nil, nil, nil, nil, nil, nil, 0.0] + expect(subject.send(:price_is_missed?, rate: rate)).to be true + end + + it 'returns false when rate[7] is non-zero' do + rate = [nil, nil, nil, nil, nil, nil, nil, 100.0] + expect(subject.send(:price_is_missed?, rate: rate)).to be false + end + end + + describe '#supported_currencies' do + it 'returns currencies from RateSourceBitfinex' do + expect(subject.send(:supported_currencies)).to eq(RateSourceBitfinex.supported_currencies) + end + end + end +end diff --git a/spec/lib/gera/bybit_fetcher_spec.rb b/spec/lib/gera/bybit_fetcher_spec.rb new file mode 100644 index 00000000..d402ddd1 --- /dev/null +++ b/spec/lib/gera/bybit_fetcher_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'gera/bybit_fetcher' + +module Gera + RSpec.describe BybitFetcher do + describe '#perform' do + let(:api_response) do + { + 'result' => { + 'items' => [ + { 'tokenId' => 'USDT', 'currencyId' => 'RUB', 'price' => '95' }, + { 'tokenId' => 'USDT', 'currencyId' => 'RUB', 'price' => '94' }, + { 'tokenId' => 'USDT', 'currencyId' => 'RUB', 'price' => '93' } + ] + } + } + end + + let(:http_response) do + instance_double(RestClient::Response, code: 200, body: api_response.to_json) + end + + before do + allow(RestClient::Request).to receive(:execute).and_return(http_response) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + it 'filters only supported currencies' do + result = subject.perform + result.each_key do |pair| + supported = RateSourceBybit.supported_currencies.map(&:iso_code) + expect(supported).to include(pair.cur_from.iso_code) + expect(supported).to include(pair.cur_to.iso_code) + end + end + + context 'when no rates available' do + let(:api_response) do + { 'result' => { 'items' => [] } } + end + + it 'raises Error' do + expect { subject.perform }.to raise_error(BybitFetcher::Error, 'No rates') + end + end + + context 'when only one rate available' do + let(:api_response) do + { + 'result' => { + 'items' => [ + { 'tokenId' => 'USDT', 'currencyId' => 'RUB', 'price' => '95' } + ] + } + } + end + + it 'raises Error' do + expect { subject.perform }.to raise_error(BybitFetcher::Error, 'No rates') + end + end + + context 'when two rates available' do + let(:api_response) do + { + 'result' => { + 'items' => [ + { 'tokenId' => 'USDT', 'currencyId' => 'RUB', 'price' => '95' }, + { 'tokenId' => 'USDT', 'currencyId' => 'RUB', 'price' => '94' } + ] + } + } + end + + it 'uses second rate' do + result = subject.perform + expect(result.values.first['price']).to eq('94') + end + end + end + + describe '#supported_currencies' do + it 'returns currencies from RateSourceBybit' do + expect(subject.send(:supported_currencies)).to eq(RateSourceBybit.supported_currencies) + end + end + + describe '#params' do + it 'returns params hash with tokenId USDT' do + params = subject.send(:params) + expect(params[:tokenId]).to eq('USDT') + end + + it 'returns params hash with currencyId RUB' do + params = subject.send(:params) + expect(params[:currencyId]).to eq('RUB') + end + end + end +end diff --git a/spec/lib/gera/cryptomus_fetcher_spec.rb b/spec/lib/gera/cryptomus_fetcher_spec.rb new file mode 100644 index 00000000..c73accda --- /dev/null +++ b/spec/lib/gera/cryptomus_fetcher_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'gera/cryptomus_fetcher' + +module Gera + RSpec.describe CryptomusFetcher do + describe '#perform' do + let(:btc_rates) do + [ + { 'from' => 'BTC', 'to' => 'USD', 'course' => '50000' }, + { 'from' => 'BTC', 'to' => 'RUB', 'course' => '5000000' } + ] + end + + let(:eth_rates) do + [ + { 'from' => 'ETH', 'to' => 'USD', 'course' => '3000' }, + { 'from' => 'ETH', 'to' => 'RUB', 'course' => '300000' } + ] + end + + before do + allow(subject).to receive(:rate).with(currency: 'BTC').and_return(btc_rates) + allow(subject).to receive(:rate).with(currency: 'DASH').and_return([]) + allow(subject).to receive(:rate).with(currency: anything).and_return([]) + # Override to return only BTC for simplicity + allow(RateSourceCryptomus).to receive(:supported_currencies).and_return( + [Money::Currency.find(:BTC), Money::Currency.find(:RUB), Money::Currency.find(:USD)] + ) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + it 'filters only supported currencies' do + result = subject.perform + result.each_key do |pair| + supported = RateSourceCryptomus.supported_currencies.map(&:iso_code) + expect(supported).to include(pair.cur_to.iso_code) + end + end + + it 'converts DASH to DSH' do + # Create a subject with stubbed rates method that returns DASH data + dsh_rates = [{ 'from' => 'DASH', 'to' => 'USD', 'course' => '100' }] + allow(subject).to receive(:rates).and_return(dsh_rates) + + result = subject.perform + dsh_pair = result.keys.find { |p| p.cur_from.iso_code == 'DSH' } + expect(dsh_pair).not_to be_nil + end + end + + describe '#supported_currencies' do + before do + allow(RateSourceCryptomus).to receive(:supported_currencies).and_call_original + end + + it 'returns currencies from RateSourceCryptomus' do + expect(subject.send(:supported_currencies)).to eq(RateSourceCryptomus.supported_currencies) + end + end + end +end diff --git a/spec/lib/gera/exmo_fetcher_spec.rb b/spec/lib/gera/exmo_fetcher_spec.rb new file mode 100644 index 00000000..525ac746 --- /dev/null +++ b/spec/lib/gera/exmo_fetcher_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExmoFetcher do + describe '#perform' do + let(:api_response) do + { + 'BTC_USD' => { 'buy_price' => '50000', 'sell_price' => '50001' }, + 'ETH_RUB' => { 'buy_price' => '300000', 'sell_price' => '300100' }, + 'DASH_USD' => { 'buy_price' => '100', 'sell_price' => '101' } + }.to_json + end + + before do + allow(Net::HTTP).to receive(:get).and_return(api_response) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + it 'converts DASH to DSH' do + result = subject.perform + # DASH_USD should become DSH/USD pair + dsh_pair = result.keys.find { |p| p.cur_from.iso_code == 'DSH' } + expect(dsh_pair).not_to be_nil + end + + context 'with VCR cassette' do + it 'fetches rates from EXMO API' do + VCR.use_cassette :exmo, allow_playback_repeats: true do + result = subject.perform + expect(result).to be_a(Hash) + end + end + end + + context 'when API returns error' do + let(:api_response) { { 'error' => 'Invalid request' }.to_json } + + it 'raises Error' do + expect { subject.perform }.to raise_error(ExmoFetcher::Error) + end + end + + context 'when API returns non-hash' do + let(:api_response) { [].to_json } + + it 'raises Error' do + expect { subject.perform }.to raise_error(ExmoFetcher::Error, 'Result is not a hash') + end + end + end + + describe '#split_currency_pair_keys' do + it 'splits currency pair and converts DASH to DSH' do + result = subject.send(:split_currency_pair_keys, 'DASH_USD') + expect(result).to eq(%w[DSH USD]) + end + + it 'splits regular currency pair' do + result = subject.send(:split_currency_pair_keys, 'BTC_USD') + expect(result).to eq(%w[BTC USD]) + end + end + + describe '#find_currency' do + it 'finds currency by key' do + result = subject.send(:find_currency, 'BTC') + expect(result).to eq(Money::Currency.find(:BTC)) + end + + it 'returns nil for unknown currency' do + result = subject.send(:find_currency, 'UNKNOWN123') + expect(result).to be_nil + end + end + end +end diff --git a/spec/lib/gera/ff_fixed_fetcher_spec.rb b/spec/lib/gera/ff_fixed_fetcher_spec.rb new file mode 100644 index 00000000..8e90c24b --- /dev/null +++ b/spec/lib/gera/ff_fixed_fetcher_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe FfFixedFetcher do + describe '#perform' do + let(:parsed_rates) do + [ + { from: 'BTC', to: 'USDT', in: 1.0, out: 50000.0, amount: 1.0, tofee: '0', minamount: '0.001', maxamount: '10' }, + { from: 'BNB', to: 'USDT', in: 1.0, out: 300.0, amount: 1.0, tofee: '0', minamount: '0.1', maxamount: '100' }, + { from: 'UNKNOWN', to: 'XXX', in: 1.0, out: 1.0, amount: 1.0, tofee: '0', minamount: '1', maxamount: '100' } + ] + end + + before do + allow(subject).to receive(:rates).and_return(parsed_rates) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + it 'converts BSC to BNB' do + rates_with_bsc = [ + { from: 'BSC', to: 'USDT', in: 1.0, out: 300.0, amount: 1.0, tofee: '0', minamount: '0.1', maxamount: '100' } + ] + allow(subject).to receive(:rates).and_return(rates_with_bsc) + + result = subject.perform + bnb_pair = result.keys.find { |p| p.cur_from.iso_code == 'BNB' || p.cur_to.iso_code == 'BNB' } + expect(bnb_pair).not_to be_nil + end + + it 'filters unsupported currencies' do + result = subject.perform + result.each_key do |pair| + supported = RateSourceFfFixed.supported_currencies.map(&:iso_code) + expect(supported).to include(pair.cur_from.iso_code) + expect(supported).to include(pair.cur_to.iso_code) + end + end + + it 'does not add reverse pair if direct pair exists' do + rates_with_reverse = [ + { from: 'BTC', to: 'USDT', in: 1.0, out: 50000.0, amount: 1.0, tofee: '0', minamount: '0.001', maxamount: '10' }, + { from: 'USDT', to: 'BTC', in: 50000.0, out: 1.0, amount: 50000.0, tofee: '0', minamount: '100', maxamount: '1000000' } + ] + allow(subject).to receive(:rates).and_return(rates_with_reverse) + + result = subject.perform + # Should only have one pair (not both direct and reverse) + pairs = result.keys.map { |p| [p.cur_from.iso_code, p.cur_to.iso_code].sort } + expect(pairs.uniq.size).to eq(pairs.size) + end + + it 'includes rate data with correct keys' do + result = subject.perform + next if result.empty? + + rate = result.values.first + expect(rate).to include('from', 'to', 'in', 'out', 'amount') + end + end + + describe '#supported_currencies' do + it 'returns currencies from RateSourceFfFixed' do + expect(subject.send(:supported_currencies)).to eq(RateSourceFfFixed.supported_currencies) + end + end + end +end diff --git a/spec/lib/gera/ff_float_fetcher_spec.rb b/spec/lib/gera/ff_float_fetcher_spec.rb new file mode 100644 index 00000000..ee9ac803 --- /dev/null +++ b/spec/lib/gera/ff_float_fetcher_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe FfFloatFetcher do + describe '#perform' do + let(:parsed_rates) do + [ + { from: 'ETH', to: 'USDT', in: 1.0, out: 3000.0, amount: 1.0, tofee: '0', minamount: '0.01', maxamount: '100' }, + { from: 'BNB', to: 'BTC', in: 1.0, out: 0.006, amount: 1.0, tofee: '0', minamount: '0.5', maxamount: '50' }, + { from: 'UNSUPPORTED', to: 'YYY', in: 1.0, out: 1.0, amount: 1.0, tofee: '0', minamount: '1', maxamount: '100' } + ] + end + + before do + allow(subject).to receive(:rates).and_return(parsed_rates) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + it 'converts BSC to BNB' do + rates_with_bsc = [ + { from: 'BSC', to: 'USDT', in: 1.0, out: 300.0, amount: 1.0, tofee: '0', minamount: '0.1', maxamount: '100' } + ] + allow(subject).to receive(:rates).and_return(rates_with_bsc) + + result = subject.perform + bnb_pair = result.keys.find { |p| p.cur_from.iso_code == 'BNB' || p.cur_to.iso_code == 'BNB' } + expect(bnb_pair).not_to be_nil + end + + it 'filters unsupported currencies' do + result = subject.perform + result.each_key do |pair| + supported = RateSourceFfFloat.supported_currencies.map(&:iso_code) + expect(supported).to include(pair.cur_from.iso_code) + expect(supported).to include(pair.cur_to.iso_code) + end + end + + it 'does not add reverse pair if direct pair exists' do + rates_with_reverse = [ + { from: 'ETH', to: 'USDT', in: 1.0, out: 3000.0, amount: 1.0, tofee: '0', minamount: '0.01', maxamount: '100' }, + { from: 'USDT', to: 'ETH', in: 3000.0, out: 1.0, amount: 3000.0, tofee: '0', minamount: '100', maxamount: '1000000' } + ] + allow(subject).to receive(:rates).and_return(rates_with_reverse) + + result = subject.perform + # Should only have one pair (not both direct and reverse) + pairs = result.keys.map { |p| [p.cur_from.iso_code, p.cur_to.iso_code].sort } + expect(pairs.uniq.size).to eq(pairs.size) + end + + it 'includes rate data with correct keys' do + result = subject.perform + next if result.empty? + + rate = result.values.first + expect(rate).to include('from', 'to', 'in', 'out', 'amount') + end + end + + describe '#supported_currencies' do + it 'returns currencies from RateSourceFfFloat' do + expect(subject.send(:supported_currencies)).to eq(RateSourceFfFloat.supported_currencies) + end + end + end +end diff --git a/spec/lib/gera/garantexio_fetcher_spec.rb b/spec/lib/gera/garantexio_fetcher_spec.rb new file mode 100644 index 00000000..d1255158 --- /dev/null +++ b/spec/lib/gera/garantexio_fetcher_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe GarantexioFetcher do + describe '#perform' do + let(:api_response) do + [ + { 'BTC_RUB' => { 'last_price' => '5000000', 'base_volume' => '10' } }, + { 'USDT_RUB' => { 'last_price' => '95', 'base_volume' => '1000' } }, + { 'UNKNOWN_XXX' => { 'last_price' => '1', 'base_volume' => '1' } } + ].to_json + end + + let(:response) { double('response', code: 200, body: api_response) } + + before do + allow(RestClient::Request).to receive(:execute).and_return(response) + end + + it 'returns hash of currency pairs to rates' do + result = subject.perform + expect(result).to be_a(Hash) + end + + it 'creates CurrencyPair keys' do + result = subject.perform + result.each_key do |key| + expect(key).to be_a(CurrencyPair) + end + end + + it 'filters only supported currencies' do + result = subject.perform + result.each_key do |pair| + supported = RateSourceGarantexio.supported_currencies.map(&:iso_code) + expect(supported).to include(pair.cur_from.iso_code) + expect(supported).to include(pair.cur_to.iso_code) + end + end + + context 'when API returns error' do + before do + allow(RestClient::Request).to receive(:execute).and_raise(RestClient::ExceptionWithResponse) + end + + it 'raises error' do + expect { subject.perform }.to raise_error(RestClient::ExceptionWithResponse) + end + end + end + + describe '#supported_currencies' do + it 'returns currencies from RateSourceGarantexio' do + expect(subject.send(:supported_currencies)).to eq(RateSourceGarantexio.supported_currencies) + end + end + end +end diff --git a/spec/lib/gera/repositories/currency_rate_modes_repository_spec.rb b/spec/lib/gera/repositories/currency_rate_modes_repository_spec.rb new file mode 100644 index 00000000..cac9f672 --- /dev/null +++ b/spec/lib/gera/repositories/currency_rate_modes_repository_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateModesRepository do + subject(:repository) { described_class.new } + + describe '#snapshot' do + it 'creates a new active snapshot if none exists' do + expect { repository.snapshot }.to change(CurrencyRateModeSnapshot, :count).by(1) + expect(repository.snapshot.status).to eq('active') + end + + it 'returns existing active snapshot' do + existing = CurrencyRateModeSnapshot.create!(status: :active) + expect(repository.snapshot).to eq(existing) + end + end + + describe '#find_currency_rate_mode_by_pair' do + let!(:snapshot) { CurrencyRateModeSnapshot.create!(status: :active) } + let!(:currency_rate_mode) do + create(:currency_rate_mode, + snapshot: snapshot, + cur_from: 'USD', + cur_to: 'RUB') + end + + it 'returns currency rate mode for pair' do + pair = CurrencyPair.new(Money::Currency.find('USD'), Money::Currency.find('RUB')) + result = repository.find_currency_rate_mode_by_pair(pair) + expect(result).to eq(currency_rate_mode) + end + + it 'returns nil for non-existent pair' do + unknown_pair = CurrencyPair.new(Money::Currency.find('EUR'), Money::Currency.find('BTC')) + expect(repository.find_currency_rate_mode_by_pair(unknown_pair)).to be_nil + end + end + end +end diff --git a/spec/lib/gera/repositories/currency_rates_repository_spec.rb b/spec/lib/gera/repositories/currency_rates_repository_spec.rb new file mode 100644 index 00000000..bb7ff35f --- /dev/null +++ b/spec/lib/gera/repositories/currency_rates_repository_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRatesRepository do + subject(:repository) { described_class.new } + + let!(:currency_rate_snapshot) { create(:currency_rate_snapshot) } + let!(:currency_rate) do + create(:currency_rate, + snapshot: currency_rate_snapshot, + cur_from: Money::Currency.find('USD'), + cur_to: Money::Currency.find('RUB')) + end + + describe '#snapshot' do + it 'returns the last currency rate snapshot' do + expect(repository.snapshot).to eq(currency_rate_snapshot) + end + + it 'raises error when no snapshot exists' do + CurrencyRateSnapshot.delete_all + new_repository = described_class.new + expect { new_repository.snapshot }.to raise_error(RuntimeError, 'No actual snapshot') + end + end + + describe '#find_currency_rate_by_pair' do + it 'returns currency rate for existing pair' do + pair = currency_rate.currency_pair + expect(repository.find_currency_rate_by_pair(pair)).to eq(currency_rate) + end + + it 'raises UnknownPair for non-existent pair' do + unknown_pair = CurrencyPair.new(Money::Currency.find('EUR'), Money::Currency.find('BTC')) + expect { repository.find_currency_rate_by_pair(unknown_pair) } + .to raise_error(CurrencyRatesRepository::UnknownPair) + end + end + + describe '#get_currency_rate_by_pair' do + it 'returns currency rate for existing pair' do + pair = currency_rate.currency_pair + expect(repository.get_currency_rate_by_pair(pair)).to eq(currency_rate) + end + + it 'returns a new frozen CurrencyRate for unknown pair' do + unknown_pair = CurrencyPair.new(Money::Currency.find('EUR'), Money::Currency.find('BTC')) + result = repository.get_currency_rate_by_pair(unknown_pair) + expect(result).to be_a(CurrencyRate) + expect(result).to be_frozen + expect(result.currency_pair).to eq(unknown_pair) + end + end + end +end diff --git a/spec/lib/gera/repositories/direction_rates_repository_spec.rb b/spec/lib/gera/repositories/direction_rates_repository_spec.rb new file mode 100644 index 00000000..e2ecdc50 --- /dev/null +++ b/spec/lib/gera/repositories/direction_rates_repository_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe DirectionRatesRepository do + subject(:repository) { described_class.new } + + let!(:direction_rate_snapshot) { create(:direction_rate_snapshot) } + let!(:exchange_rate) { create(:gera_exchange_rate) } + let!(:currency_rate_snapshot) { create(:currency_rate_snapshot) } + let!(:currency_rate) do + create(:currency_rate, + snapshot: currency_rate_snapshot, + cur_from: exchange_rate.payment_system_from.currency, + cur_to: exchange_rate.payment_system_to.currency) + end + # Create direction_rate with all values pre-set to avoid callback triggering external services + let!(:direction_rate) do + DirectionRate.create!( + snapshot: direction_rate_snapshot, + exchange_rate: exchange_rate, + currency_rate: currency_rate, + ps_from: exchange_rate.payment_system_from, + ps_to: exchange_rate.payment_system_to, + base_rate_value: currency_rate.rate_value, + rate_percent: 5.0, + rate_value: 57.0 # Setting rate_value (finite_rate) avoids calculate_rate callback + ) + end + + describe '#snapshot' do + it 'returns the last direction rate snapshot' do + expect(repository.snapshot).to eq(direction_rate_snapshot) + end + + it 'raises NoActualSnapshot when no snapshot exists' do + DirectionRateSnapshot.delete_all + new_repository = described_class.new + expect { new_repository.snapshot } + .to raise_error(DirectionRatesRepository::NoActualSnapshot) + end + end + + describe '#all' do + it 'returns all direction rates from snapshot' do + expect(repository.all).to include(direction_rate) + end + end + + describe '#find_direction_rate_by_exchange_rate_id' do + it 'returns direction rate for exchange rate id' do + expect(repository.find_direction_rate_by_exchange_rate_id(exchange_rate.id)) + .to eq(direction_rate) + end + + it 'raises FinitRateNotFound for non-existent exchange rate id' do + expect { repository.find_direction_rate_by_exchange_rate_id(-1) } + .to raise_error(DirectionRatesRepository::FinitRateNotFound) + end + end + + describe '#get_by_direction' do + let(:direction) do + double('Direction', + ps_from_id: direction_rate.ps_from_id, + ps_to_id: direction_rate.ps_to_id) + end + + it 'returns direction rate for direction' do + expect(repository.get_by_direction(direction)).to eq(direction_rate) + end + + it 'returns nil for non-existent direction' do + non_existent = double('Direction', ps_from_id: -1, ps_to_id: -1) + expect(repository.get_by_direction(non_existent)).to be_nil + end + end + + describe '#get_matrix' do + it 'returns a hash matrix of direction rates' do + matrix = repository.get_matrix + expect(matrix).to be_a(Hash) + expect(matrix[direction_rate.ps_from_id][direction_rate.ps_to_id]).to eq(direction_rate) + end + end + end +end diff --git a/spec/lib/gera/repositories/exchange_rates_repository_spec.rb b/spec/lib/gera/repositories/exchange_rates_repository_spec.rb new file mode 100644 index 00000000..489f9af8 --- /dev/null +++ b/spec/lib/gera/repositories/exchange_rates_repository_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExchangeRatesRepository do + subject(:repository) { described_class.new } + + let!(:exchange_rate) { create(:gera_exchange_rate) } + + describe '#find_by_direction' do + let(:direction) do + double('Direction', + ps_from_id: exchange_rate.ps_from_id, + ps_to_id: exchange_rate.ps_to_id) + end + + it 'returns exchange rate for direction' do + expect(repository.find_by_direction(direction)).to eq(exchange_rate) + end + + it 'returns nil for non-existent direction' do + non_existent = double('Direction', ps_from_id: -1, ps_to_id: -1) + expect(repository.find_by_direction(non_existent)).to be_nil + end + end + + describe '#get_matrix' do + it 'returns a hash matrix of exchange rates' do + matrix = repository.get_matrix + expect(matrix).to be_a(Hash) + expect(matrix[exchange_rate.ps_from_id][exchange_rate.ps_to_id]).to eq(exchange_rate) + end + + it 'memoizes the matrix' do + expect(repository.get_matrix).to eq(repository.get_matrix) + end + end + end +end diff --git a/spec/lib/gera/repositories/payment_systems_repository_spec.rb b/spec/lib/gera/repositories/payment_systems_repository_spec.rb new file mode 100644 index 00000000..8ca27a52 --- /dev/null +++ b/spec/lib/gera/repositories/payment_systems_repository_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe PaymentSystemsRepository do + subject(:repository) { described_class.new } + + let!(:payment_system1) { create(:gera_payment_system, income_enabled: true, outcome_enabled: true, is_available: true) } + let!(:payment_system2) { create(:gera_payment_system, income_enabled: true, outcome_enabled: true, is_available: true) } + let!(:unavailable_ps) { create(:gera_payment_system, income_enabled: false, outcome_enabled: false, is_available: false) } + + describe '#find_by_id' do + it 'returns payment system by id' do + expect(repository.find_by_id(payment_system1.id)).to eq(payment_system1) + end + + it 'returns nil for non-existent id' do + expect(repository.find_by_id(-1)).to be_nil + end + end + + describe '#available' do + it 'returns only available payment systems' do + available = repository.available + expect(available).to include(payment_system1) + expect(available).to include(payment_system2) + expect(available).not_to include(unavailable_ps) + end + + it 'memoizes the result' do + expect(repository.available).to eq(repository.available) + end + end + + describe '#all' do + it 'returns all payment systems' do + all = repository.all + expect(all).to include(payment_system1) + expect(all).to include(payment_system2) + expect(all).to include(unavailable_ps) + end + end + end +end diff --git a/spec/lib/gera/repositories/universe_spec.rb b/spec/lib/gera/repositories/universe_spec.rb new file mode 100644 index 00000000..7a61bf2d --- /dev/null +++ b/spec/lib/gera/repositories/universe_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe Universe do + describe '.instance' do + it 'returns a Universe instance' do + expect(described_class.instance).to be_a(Universe) + end + + it 'returns the same instance within the same request' do + expect(described_class.instance).to eq(described_class.instance) + end + end + + describe '.clear!' do + it 'clears the cached repositories' do + universe = described_class.instance + universe.payment_systems # initialize cache + + described_class.clear! + + # After clear, a new repository should be created + expect(universe.instance_variable_get(:@payment_systems)).to be_nil + end + end + + describe '#payment_systems' do + it 'returns a PaymentSystemsRepository' do + expect(described_class.instance.payment_systems).to be_a(PaymentSystemsRepository) + end + + it 'memoizes the repository' do + universe = described_class.instance + expect(universe.payment_systems).to eq(universe.payment_systems) + end + end + + describe '#currency_rate_modes_repository' do + it 'returns a CurrencyRateModesRepository' do + expect(described_class.instance.currency_rate_modes_repository).to be_a(CurrencyRateModesRepository) + end + end + + describe '#currency_rates_repository' do + it 'returns a CurrencyRatesRepository' do + expect(described_class.instance.currency_rates_repository).to be_a(CurrencyRatesRepository) + end + end + + describe '#direction_rates_repository' do + it 'returns a DirectionRatesRepository' do + expect(described_class.instance.direction_rates_repository).to be_a(DirectionRatesRepository) + end + end + + describe '#exchange_rates_repository' do + it 'returns an ExchangeRatesRepository' do + expect(described_class.instance.exchange_rates_repository).to be_a(ExchangeRatesRepository) + end + end + end +end diff --git a/spec/lib/money_support_spec.rb b/spec/lib/money_support_spec.rb index 6370fd4c..0a37a1f9 100644 --- a/spec/lib/money_support_spec.rb +++ b/spec/lib/money_support_spec.rb @@ -3,6 +3,6 @@ require 'spec_helper' RSpec.describe 'Gera define money' do - it { expect(Money::Currency.all.count).to eq 14 } + it { expect(Money::Currency.all.count).to eq 37 } it { expect(USD).to be_a Money::Currency } end diff --git a/spec/lib/numeric_spec.rb b/spec/lib/numeric_spec.rb new file mode 100644 index 00000000..4df0b41b --- /dev/null +++ b/spec/lib/numeric_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gera::Numeric do + describe '#to_rate' do + it 'converts number to RateFromMultiplicator' do + result = 100.to_rate + expect(result).to be_a(Gera::RateFromMultiplicator) + end + + it 'preserves the value' do + result = 100.to_rate + expect(result.to_f).to eq(100.0) + end + end + + describe '#percent_of' do + it 'calculates percentage of a value' do + result = 10.percent_of(100) + expect(result).to eq(10) + end + + it 'handles decimal percentages' do + result = 2.5.percent_of(100) + expect(result).to eq(2.5) + end + end + + describe '#as_percentage_of' do + it 'calculates what percentage one number is of another' do + result = 5.as_percentage_of(10) + # Returns percentage as decimal (0.5 = 50%) + expect(result.to_f).to eq(0.5) + end + + it 'handles decimal values' do + result = 25.as_percentage_of(100) + # Returns percentage as decimal (0.25 = 25%) + expect(result.to_f).to eq(0.25) + end + end + + describe 'Numeric extension' do + it 'extends ::Numeric class' do + expect(::Numeric.include?(Gera::Numeric)).to be true + end + + it 'works with Integer' do + expect(100).to respond_to(:to_rate) + expect(100).to respond_to(:percent_of) + expect(100).to respond_to(:as_percentage_of) + end + + it 'works with Float' do + expect(10.5).to respond_to(:to_rate) + expect(10.5).to respond_to(:percent_of) + expect(10.5).to respond_to(:as_percentage_of) + end + end +end diff --git a/spec/lib/rate_spec.rb b/spec/lib/rate_spec.rb new file mode 100644 index 00000000..051c43c5 --- /dev/null +++ b/spec/lib/rate_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gera::Rate do + describe 'attributes' do + let(:rate) { described_class.new(in_amount: 1, out_amount: 100) } + + it 'has in_amount attribute' do + expect(rate.in_amount).to eq(1) + end + + it 'has out_amount attribute' do + expect(rate.out_amount).to eq(100) + end + end + + describe '#to_d' do + it 'returns ratio of out_amount to in_amount as BigDecimal' do + rate = described_class.new(in_amount: 1, out_amount: 100) + expect(rate.to_d).to eq(100.to_d) + end + + it 'handles decimal values' do + rate = described_class.new(in_amount: 2, out_amount: 5) + expect(rate.to_d).to eq(2.5.to_d) + end + end + + describe '#to_f' do + it 'returns ratio as Float' do + rate = described_class.new(in_amount: 1, out_amount: 100) + expect(rate.to_f).to eq(100.0) + end + end + + describe '#reverse' do + it 'swaps in_amount and out_amount' do + rate = described_class.new(in_amount: 1, out_amount: 100) + reversed = rate.reverse + + expect(reversed.in_amount).to eq(100) + expect(reversed.out_amount).to eq(1) + end + + it 'returns frozen object' do + rate = described_class.new(in_amount: 1, out_amount: 100) + expect(rate.reverse).to be_frozen + end + end + + describe 'inheritance' do + it 'inherits from RateFromMultiplicator' do + expect(described_class.superclass).to eq(Gera::RateFromMultiplicator) + end + end +end diff --git a/spec/models/gera/cbr_external_rate_spec.rb b/spec/models/gera/cbr_external_rate_spec.rb new file mode 100644 index 00000000..7cfe802d --- /dev/null +++ b/spec/models/gera/cbr_external_rate_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CbrExternalRate do + # Note: This spec tests the model's interface. + # The table gera_cbr_external_rates may not exist in test database. + + describe 'model interface' do + it 'inherits from ApplicationRecord' do + expect(CbrExternalRate.superclass).to eq(ApplicationRecord) + end + + it 'responds to rate attribute' do + expect(CbrExternalRate.new).to respond_to(:rate) + end + + it 'defines <=> operator for comparison' do + expect(CbrExternalRate.instance_methods).to include(:<=>) + end + end + end +end diff --git a/spec/models/gera/cross_rate_mode_spec.rb b/spec/models/gera/cross_rate_mode_spec.rb new file mode 100644 index 00000000..296cde43 --- /dev/null +++ b/spec/models/gera/cross_rate_mode_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CrossRateMode do + describe 'associations' do + let(:mode_snapshot) { create(:currency_rate_mode_snapshot) } + let(:currency_rate_mode) { create(:currency_rate_mode, snapshot: mode_snapshot) } + let(:cross_mode) { create(:cross_rate_mode, currency_rate_mode: currency_rate_mode) } + + it 'belongs to currency_rate_mode' do + expect(cross_mode).to respond_to(:currency_rate_mode) + expect(cross_mode.currency_rate_mode).to eq(currency_rate_mode) + end + + it 'belongs to rate_source optionally' do + expect(cross_mode).to respond_to(:rate_source) + end + end + + describe 'CurrencyPairSupport' do + it 'includes CurrencyPairSupport module' do + expect(CrossRateMode.include?(CurrencyPairSupport)).to be true + end + end + + describe '#title' do + let(:mode_snapshot) { create(:currency_rate_mode_snapshot) } + let(:currency_rate_mode) { create(:currency_rate_mode, snapshot: mode_snapshot) } + + context 'with rate source' do + let(:rate_source) { create(:rate_source_exmo) } + let(:cross_mode) do + create(:cross_rate_mode, + currency_rate_mode: currency_rate_mode, + rate_source: rate_source, + cur_from: 'BTC', + cur_to: 'USD') + end + + it 'includes currency pair and rate source' do + expect(cross_mode.title).to include('BTC/USD') + expect(cross_mode.title).not_to include('auto') + end + end + + context 'without rate source' do + let(:cross_mode) do + create(:cross_rate_mode, + currency_rate_mode: currency_rate_mode, + rate_source: nil, + cur_from: 'BTC', + cur_to: 'USD') + end + + it 'shows auto as source' do + expect(cross_mode.title).to include('BTC/USD') + expect(cross_mode.title).to include('auto') + end + end + end + end +end diff --git a/spec/models/gera/currency_rate_history_interval_filter_spec.rb b/spec/models/gera/currency_rate_history_interval_filter_spec.rb new file mode 100644 index 00000000..8869f894 --- /dev/null +++ b/spec/models/gera/currency_rate_history_interval_filter_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateHistoryIntervalFilter do + describe 'included modules' do + it 'includes Virtus.model' do + expect(described_class.include?(Virtus::Model::Core)).to be true + end + + it 'includes ActiveModel::Conversion' do + expect(described_class.include?(ActiveModel::Conversion)).to be true + end + + it 'extends ActiveModel::Naming' do + expect(described_class).to respond_to(:model_name) + end + + it 'includes ActiveModel::Validations' do + expect(described_class.include?(ActiveModel::Validations)).to be true + end + end + + describe 'attributes' do + subject { described_class.new } + + it 'has cur_from attribute with default' do + expect(subject.cur_from).to be_present + end + + it 'has cur_to attribute with default' do + expect(subject.cur_to).to be_present + end + + it 'has value_type attribute with default rate' do + expect(subject.value_type).to eq('rate') + end + end + + describe '#currency_from' do + subject { described_class.new(cur_from: 'BTC') } + + it 'returns Money::Currency object' do + expect(subject.currency_from).to be_a(Money::Currency) + expect(subject.currency_from.iso_code).to eq('BTC') + end + end + + describe '#currency_to' do + subject { described_class.new(cur_to: 'USD') } + + it 'returns Money::Currency object' do + expect(subject.currency_to).to be_a(Money::Currency) + expect(subject.currency_to.iso_code).to eq('USD') + end + end + + describe '#to_param' do + subject { described_class.new(cur_from: 'BTC', cur_to: 'USD') } + + it 'returns hash representation' do + expect(subject.to_param).to be_a(Hash) + expect(subject.to_param[:cur_from]).to eq('BTC') + expect(subject.to_param[:cur_to]).to eq('USD') + end + end + + describe '#persisted?' do + subject { described_class.new } + + it 'returns false' do + expect(subject.persisted?).to be false + end + end + end +end diff --git a/spec/models/gera/currency_rate_history_interval_spec.rb b/spec/models/gera/currency_rate_history_interval_spec.rb new file mode 100644 index 00000000..ff825a66 --- /dev/null +++ b/spec/models/gera/currency_rate_history_interval_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateHistoryInterval do + describe 'HistoryIntervalConcern' do + it 'includes HistoryIntervalConcern module' do + expect(CurrencyRateHistoryInterval.include?(HistoryIntervalConcern)).to be true + end + end + + describe 'model interface' do + it 'inherits from ApplicationRecord' do + expect(CurrencyRateHistoryInterval.superclass).to eq(ApplicationRecord) + end + + it 'responds to interval_from and interval_to' do + interval = CurrencyRateHistoryInterval.new + expect(interval).to respond_to(:interval_from) + expect(interval).to respond_to(:interval_to) + end + + it 'responds to currency id attributes' do + interval = CurrencyRateHistoryInterval.new + expect(interval).to respond_to(:cur_from_id) + expect(interval).to respond_to(:cur_to_id) + end + + it 'responds to rate aggregation attributes' do + interval = CurrencyRateHistoryInterval.new + expect(interval).to respond_to(:min_rate) + expect(interval).to respond_to(:max_rate) + end + end + + describe '.create_by_interval!' do + it 'responds to create_by_interval! class method' do + expect(CurrencyRateHistoryInterval).to respond_to(:create_by_interval!) + end + end + end +end diff --git a/spec/models/gera/currency_rate_mode_snapshot_spec.rb b/spec/models/gera/currency_rate_mode_snapshot_spec.rb new file mode 100644 index 00000000..82c6ee2c --- /dev/null +++ b/spec/models/gera/currency_rate_mode_snapshot_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateModeSnapshot do + describe 'associations' do + let(:snapshot) { create(:currency_rate_mode_snapshot) } + + it 'has many currency_rate_modes' do + expect(snapshot).to respond_to(:currency_rate_modes) + end + end + + describe 'enums' do + it 'defines status enum' do + expect(described_class.statuses).to include('draft', 'active', 'deactive') + end + end + + describe 'scopes' do + let!(:active) { create(:currency_rate_mode_snapshot, status: :active) } + let!(:draft) { create(:currency_rate_mode_snapshot, status: :draft) } + + describe '.ordered' do + it 'orders by status desc and created_at desc' do + result = CurrencyRateModeSnapshot.ordered + expect(result.first.status).to eq('active') + end + end + end + + describe 'callbacks' do + describe 'before_validation' do + it 'sets title from current time if blank' do + snapshot = CurrencyRateModeSnapshot.new + snapshot.valid? + expect(snapshot.title).to be_present + end + end + end + + describe '#create_modes!' do + let(:snapshot) { create(:currency_rate_mode_snapshot) } + + it 'creates modes for all currency pairs' do + expect { + snapshot.create_modes! + }.to change { snapshot.currency_rate_modes.count }.from(0) + end + + it 'returns self' do + expect(snapshot.create_modes!).to eq(snapshot) + end + end + + describe 'nested attributes' do + it 'accepts nested attributes for currency_rate_modes' do + expect(described_class.nested_attributes_options).to have_key(:currency_rate_modes) + end + end + end +end diff --git a/spec/models/gera/currency_rate_mode_spec.rb b/spec/models/gera/currency_rate_mode_spec.rb new file mode 100644 index 00000000..38e77c9c --- /dev/null +++ b/spec/models/gera/currency_rate_mode_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateMode do + describe 'associations' do + let(:snapshot) { create(:currency_rate_mode_snapshot) } + let(:mode) { create(:currency_rate_mode, snapshot: snapshot) } + + it 'belongs to snapshot' do + expect(mode).to respond_to(:snapshot) + expect(mode.snapshot).to eq(snapshot) + end + + it 'has many cross_rate_modes' do + expect(mode).to respond_to(:cross_rate_modes) + end + end + + describe 'enums' do + it 'defines mode enum' do + expect(described_class.modes).to include('auto', 'cbr', 'cbr_avg', 'exmo', 'cross', 'bitfinex') + end + end + + describe '.default_for_pair' do + let(:pair) { CurrencyPair.new('BTC/USD') } + + it 'creates new CurrencyRateMode with auto mode' do + mode = described_class.default_for_pair(pair) + expect(mode.mode).to eq('auto') + expect(mode.new_record?).to be true + end + end + + describe '#to_s' do + let(:snapshot) { create(:currency_rate_mode_snapshot) } + let(:mode) { create(:currency_rate_mode, snapshot: snapshot, mode: :exmo) } + + it 'returns mode name for persisted record' do + expect(mode.to_s).to eq('exmo') + end + + it 'returns default for new auto mode record' do + new_mode = described_class.new(mode: :auto) + expect(new_mode.to_s).to eq('default') + end + end + + describe '#mode' do + let(:snapshot) { create(:currency_rate_mode_snapshot) } + let(:mode) { create(:currency_rate_mode, snapshot: snapshot, mode: :auto) } + + it 'returns inquiry object' do + expect(mode.mode.auto?).to be true + expect(mode.mode.exmo?).to be false + end + end + + describe 'nested attributes' do + it 'accepts nested attributes for cross_rate_modes' do + expect(described_class.nested_attributes_options).to have_key(:cross_rate_modes) + end + end + end +end diff --git a/spec/models/gera/currency_rate_snapshot_spec.rb b/spec/models/gera/currency_rate_snapshot_spec.rb new file mode 100644 index 00000000..92a7edc6 --- /dev/null +++ b/spec/models/gera/currency_rate_snapshot_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRateSnapshot do + describe 'associations' do + let(:mode_snapshot) { create(:currency_rate_mode_snapshot) } + let(:snapshot) { create(:currency_rate_snapshot, currency_rate_mode_snapshot: mode_snapshot) } + + it 'has many rates' do + expect(snapshot).to respond_to(:rates) + end + + it 'belongs to currency_rate_mode_snapshot' do + expect(snapshot).to respond_to(:currency_rate_mode_snapshot) + expect(snapshot.currency_rate_mode_snapshot).to eq(mode_snapshot) + end + end + + describe '#currency_rates' do + let(:mode_snapshot) { create(:currency_rate_mode_snapshot) } + let(:snapshot) { create(:currency_rate_snapshot, currency_rate_mode_snapshot: mode_snapshot) } + let!(:rate) { create(:currency_rate, snapshot: snapshot) } + + it 'returns rates' do + expect(snapshot.currency_rates).to include(rate) + end + + it 'is an alias for rates' do + expect(snapshot.currency_rates).to eq(snapshot.rates) + end + end + end +end diff --git a/spec/models/gera/currency_rate_test.rb b/spec/models/gera/currency_rate_test.rb new file mode 100644 index 00000000..1952e6c1 --- /dev/null +++ b/spec/models/gera/currency_rate_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Gera::CurrencyRate', type: :model do + fixtures :gera_currency_rates, :gera_rate_sources, :gera_external_rate_snapshots + + describe "CurrencyRate model" do + it "loads fixtures correctly" do + rate = gera_currency_rates(:usd_rub) + expect(rate).to be_persisted + expect(rate.rate).to eq 60.5 + expect(rate.currency_from).to eq "USD" + expect(rate.currency_to).to eq "RUB" + expect(rate.mode).to eq "direct" + end + + it "creates new currency rate" do + rate = Gera::CurrencyRate.create!( + currency_from: "EUR", + currency_to: "USD", + rate: 1.2, + rate_source: gera_rate_sources(:one), + external_rate_snapshot: gera_external_rate_snapshots(:one), + mode: "direct" + ) + expect(rate).to be_persisted + expect(rate.rate).to eq 1.2 + end + + it "handles different modes" do + direct_rate = gera_currency_rates(:usd_rub) + inverse_rate = gera_currency_rates(:rub_usd) + + expect(direct_rate.mode).to eq "direct" + expect(inverse_rate.mode).to eq "inverse" + end + end +end \ No newline at end of file diff --git a/spec/models/gera/direction_rate_history_interval_filter_spec.rb b/spec/models/gera/direction_rate_history_interval_filter_spec.rb new file mode 100644 index 00000000..d7ca0665 --- /dev/null +++ b/spec/models/gera/direction_rate_history_interval_filter_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe DirectionRateHistoryIntervalFilter do + describe 'included modules' do + it 'includes Virtus.model' do + expect(described_class.include?(Virtus::Model::Core)).to be true + end + + it 'includes ActiveModel::Conversion' do + expect(described_class.include?(ActiveModel::Conversion)).to be true + end + + it 'extends ActiveModel::Naming' do + expect(described_class).to respond_to(:model_name) + end + + it 'includes ActiveModel::Validations' do + expect(described_class.include?(ActiveModel::Validations)).to be true + end + end + + describe 'attributes' do + let!(:payment_system) { create(:gera_payment_system) } + subject { described_class.new } + + it 'has payment_system_from_id attribute' do + expect(subject).to respond_to(:payment_system_from_id) + end + + it 'has payment_system_to_id attribute' do + expect(subject).to respond_to(:payment_system_to_id) + end + + it 'has value_type attribute with default rate' do + expect(subject.value_type).to eq('rate') + end + end + + describe '#payment_system_from' do + let(:payment_system) { create(:gera_payment_system) } + subject { described_class.new(payment_system_from_id: payment_system.id) } + + it 'returns PaymentSystem object' do + expect(subject.payment_system_from).to eq(payment_system) + end + end + + describe '#payment_system_to' do + let(:payment_system) { create(:gera_payment_system) } + subject { described_class.new(payment_system_to_id: payment_system.id) } + + it 'returns PaymentSystem object' do + expect(subject.payment_system_to).to eq(payment_system) + end + end + + describe '#to_param' do + let(:ps_from) { create(:gera_payment_system) } + let(:ps_to) { create(:gera_payment_system) } + subject { described_class.new(payment_system_from_id: ps_from.id, payment_system_to_id: ps_to.id) } + + it 'returns hash representation' do + expect(subject.to_param).to be_a(Hash) + end + end + + describe '#persisted?' do + let!(:payment_system) { create(:gera_payment_system) } + subject { described_class.new } + + it 'returns false' do + expect(subject.persisted?).to be false + end + end + end +end diff --git a/spec/models/gera/direction_rate_history_interval_spec.rb b/spec/models/gera/direction_rate_history_interval_spec.rb new file mode 100644 index 00000000..14030a94 --- /dev/null +++ b/spec/models/gera/direction_rate_history_interval_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe DirectionRateHistoryInterval do + describe 'HistoryIntervalConcern' do + it 'includes HistoryIntervalConcern module' do + expect(DirectionRateHistoryInterval.include?(HistoryIntervalConcern)).to be true + end + end + + describe 'model interface' do + it 'inherits from ApplicationRecord' do + expect(DirectionRateHistoryInterval.superclass).to eq(ApplicationRecord) + end + + it 'responds to interval_from and interval_to' do + interval = DirectionRateHistoryInterval.new + expect(interval).to respond_to(:interval_from) + expect(interval).to respond_to(:interval_to) + end + + it 'responds to payment system id attributes' do + interval = DirectionRateHistoryInterval.new + # Note: associations are commented out in the model, + # so we test the id columns directly + expect(interval).to respond_to(:payment_system_from_id) + expect(interval).to respond_to(:payment_system_to_id) + end + + it 'responds to rate aggregation attributes' do + interval = DirectionRateHistoryInterval.new + expect(interval).to respond_to(:min_rate) + expect(interval).to respond_to(:max_rate) + expect(interval).to respond_to(:min_comission) + expect(interval).to respond_to(:max_comission) + end + end + + describe '.create_by_interval!' do + it 'responds to create_by_interval! class method' do + expect(DirectionRateHistoryInterval).to respond_to(:create_by_interval!) + end + end + end +end diff --git a/spec/models/gera/direction_rate_snapshot_spec.rb b/spec/models/gera/direction_rate_snapshot_spec.rb new file mode 100644 index 00000000..95d963b1 --- /dev/null +++ b/spec/models/gera/direction_rate_snapshot_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe DirectionRateSnapshot do + describe 'associations' do + let(:snapshot) { create(:direction_rate_snapshot) } + + it 'has many direction_rates' do + expect(snapshot).to respond_to(:direction_rates) + end + end + + describe 'persistence' do + it 'can be created' do + snapshot = DirectionRateSnapshot.create! + expect(snapshot).to be_persisted + end + end + end +end diff --git a/spec/models/gera/direction_rate_spec.rb b/spec/models/gera/direction_rate_spec.rb index 492b6861..55e1fbf3 100644 --- a/spec/models/gera/direction_rate_spec.rb +++ b/spec/models/gera/direction_rate_spec.rb @@ -4,7 +4,19 @@ describe Gera::DirectionRate do before do - allow(Gera::DirectionsRatesWorker).to receive :perform_async + allow(Gera::DirectionsRatesJob).to receive :perform_later + + # Mock BestChange::Service to avoid dependency issues + best_change_service_class = Class.new do + def initialize(exchange_rate:) + # Mock implementation + end + + def rows_without_kassa + [] + end + end + stub_const('BestChange::Service', best_change_service_class) end subject { create :gera_direction_rate } diff --git a/spec/models/gera/direction_spec.rb b/spec/models/gera/direction_spec.rb new file mode 100644 index 00000000..a9f7d652 --- /dev/null +++ b/spec/models/gera/direction_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe Direction do + let(:ps_from) { create(:gera_payment_system) } + let(:ps_to) { create(:gera_payment_system) } + subject { described_class.new(ps_from: ps_from, ps_to: ps_to) } + + describe 'Virtus model' do + it 'includes Virtus.model' do + expect(described_class.include?(Virtus::Model::Core)).to be true + end + end + + describe 'attributes' do + it 'has ps_from attribute' do + expect(subject.ps_from).to eq(ps_from) + end + + it 'has ps_to attribute' do + expect(subject.ps_to).to eq(ps_to) + end + end + + describe 'attribute aliases' do + it 'aliases payment_system_from to ps_from' do + expect(subject.payment_system_from).to eq(subject.ps_from) + end + + it 'aliases payment_system_to to ps_to' do + expect(subject.payment_system_to).to eq(subject.ps_to) + end + + it 'aliases income_payment_system to ps_from' do + expect(subject.income_payment_system).to eq(subject.ps_from) + end + + it 'aliases outcome_payment_system to ps_to' do + expect(subject.outcome_payment_system).to eq(subject.ps_to) + end + end + + describe '#currency_from' do + it 'returns currency from payment_system_from' do + expect(subject.currency_from).to eq(ps_from.currency) + end + end + + describe '#currency_to' do + it 'returns currency from payment_system_to' do + expect(subject.currency_to).to eq(ps_to.currency) + end + end + + describe '#ps_to_id' do + it 'delegates to ps_to' do + expect(subject.ps_to_id).to eq(ps_to.id) + end + end + + describe '#ps_from_id' do + it 'delegates to ps_from' do + expect(subject.ps_from_id).to eq(ps_from.id) + end + end + + describe '#to_s' do + it 'returns formatted string with payment system ids' do + expect(subject.to_s).to eq("direction:#{ps_from.id}-#{ps_to.id}") + end + + context 'when ps_from is nil' do + subject { described_class.new(ps_from: nil, ps_to: ps_to) } + + it 'uses ??? placeholder' do + expect(subject.to_s).to include('???') + end + end + end + + describe '#inspect' do + it 'returns same as to_s' do + expect(subject.inspect).to eq(subject.to_s) + end + end + end +end diff --git a/spec/models/gera/exchange_rate_dependent_spec.rb b/spec/models/gera/exchange_rate_dependent_spec.rb new file mode 100644 index 00000000..a6e60145 --- /dev/null +++ b/spec/models/gera/exchange_rate_dependent_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gera::ExchangeRate, 'dependent delete_all' do + before do + allow(Gera::DirectionsRatesJob).to receive(:perform_later) + + # Mock BestChange::Service to avoid dependency issues + best_change_service_class = Class.new do + def initialize(exchange_rate:); end + + def rows_without_kassa + [] + end + end + stub_const('BestChange::Service', best_change_service_class) + end + + describe '#destroy' do + context 'with associated direction_rates' do + let!(:exchange_rate) { create(:gera_exchange_rate) } + let!(:direction_rate) { create(:gera_direction_rate, exchange_rate: exchange_rate) } + + it 'deletes associated direction_rates' do + expect { exchange_rate.destroy }.to change(Gera::DirectionRate, :count).by(-1) + end + + it 'does not raise foreign key constraint error' do + expect { exchange_rate.destroy }.not_to raise_error + end + end + + context 'with multiple direction_rates' do + let!(:exchange_rate) { create(:gera_exchange_rate) } + + before do + 3.times { create(:gera_direction_rate, exchange_rate: exchange_rate) } + end + + it 'deletes all associated direction_rates' do + expect { exchange_rate.destroy }.to change(Gera::DirectionRate, :count).by(-3) + end + end + end + + describe '.destroy_all' do + before do + Gera::DirectionRate.delete_all + Gera::ExchangeRate.delete_all + + er = create(:gera_exchange_rate) + create(:gera_direction_rate, exchange_rate: er) + end + + it 'deletes all exchange_rates and associated direction_rates' do + expect { Gera::ExchangeRate.destroy_all }.not_to raise_error + expect(Gera::ExchangeRate.count).to eq(0) + expect(Gera::DirectionRate.count).to eq(0) + end + end +end diff --git a/spec/models/gera/exchange_rate_limit_spec.rb b/spec/models/gera/exchange_rate_limit_spec.rb new file mode 100644 index 00000000..dd7861d7 --- /dev/null +++ b/spec/models/gera/exchange_rate_limit_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExchangeRateLimit do + # Note: This spec tests the model's interface. + # The table gera_exchange_rate_limits may not exist in test database. + + describe 'model interface' do + it 'inherits from ApplicationRecord' do + expect(ExchangeRateLimit.superclass).to eq(ApplicationRecord) + end + + it 'is defined as a class' do + expect(ExchangeRateLimit).to be_a(Class) + end + + it 'has exchange_rate association defined' do + expect(ExchangeRateLimit.reflect_on_association(:exchange_rate)).to be_present + end + end + end +end diff --git a/spec/models/gera/exchange_rate_spec.rb b/spec/models/gera/exchange_rate_spec.rb index 3a48e4a5..90b0809c 100644 --- a/spec/models/gera/exchange_rate_spec.rb +++ b/spec/models/gera/exchange_rate_spec.rb @@ -5,7 +5,7 @@ module Gera RSpec.describe ExchangeRate do before do - allow(DirectionsRatesWorker).to receive(:perform_async) + allow(DirectionsRatesJob).to receive(:perform_later) end subject { create :gera_exchange_rate } it { expect(subject).to be_persisted } diff --git a/spec/models/gera/exchange_rate_test.rb b/spec/models/gera/exchange_rate_test.rb new file mode 100644 index 00000000..58023454 --- /dev/null +++ b/spec/models/gera/exchange_rate_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Gera::ExchangeRate', type: :model do + fixtures :gera_payment_systems, :gera_exchange_rates + + describe "ExchangeRate model" do + it "loads fixtures correctly" do + rate = gera_exchange_rates(:one) + expect(rate).to be_persisted + expect(rate.value).to eq 1.5 + expect(rate.payment_system_from).to eq gera_payment_systems(:one) + expect(rate.payment_system_to).to eq gera_payment_systems(:two) + end + + it "creates new exchange rate" do + rate = Gera::ExchangeRate.create!( + payment_system_from: gera_payment_systems(:btc), + payment_system_to: gera_payment_systems(:usd), + value: 2.5, + is_enabled: true + ) + expect(rate).to be_persisted + expect(rate.value).to eq 2.5 + end + + it "has currency assignments" do + rate = gera_exchange_rates(:btc_to_usd) + expect(rate.in_cur).to eq "BTC" + expect(rate.out_cur).to eq "USD" + end + end +end \ No newline at end of file diff --git a/spec/models/gera/external_rate_snapshot_spec.rb b/spec/models/gera/external_rate_snapshot_spec.rb new file mode 100644 index 00000000..1ad5f277 --- /dev/null +++ b/spec/models/gera/external_rate_snapshot_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExternalRateSnapshot do + describe 'associations' do + let(:rate_source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + + it 'belongs to rate_source' do + expect(snapshot).to respond_to(:rate_source) + expect(snapshot.rate_source).to eq(rate_source) + end + + it 'has many external_rates' do + expect(snapshot).to respond_to(:external_rates) + end + end + + describe 'scopes' do + let(:rate_source) { create(:rate_source_exmo) } + let!(:older_snapshot) { create(:external_rate_snapshot, rate_source: rate_source, actual_for: 1.day.ago) } + let!(:newer_snapshot) { create(:external_rate_snapshot, rate_source: rate_source, actual_for: Time.zone.now) } + + describe '.ordered' do + it 'orders by actual_for desc' do + expect(ExternalRateSnapshot.ordered.first).to eq(newer_snapshot) + end + end + + describe '.last_actuals_by_rate_sources' do + let(:another_source) { create(:rate_source_cbr) } + let!(:another_snapshot) { create(:external_rate_snapshot, rate_source: another_source) } + + it 'returns one snapshot per rate source' do + result = ExternalRateSnapshot.last_actuals_by_rate_sources + expect(result.pluck(:rate_source_id).uniq.count).to eq(result.count) + end + end + end + + describe 'callbacks' do + let(:rate_source) { create(:rate_source_exmo) } + + describe 'before_save' do + it 'sets actual_for if blank' do + snapshot = ExternalRateSnapshot.new(rate_source: rate_source) + snapshot.save! + expect(snapshot.actual_for).to be_present + end + + it 'does not override actual_for if set' do + specific_time = 1.hour.ago + snapshot = ExternalRateSnapshot.new(rate_source: rate_source, actual_for: specific_time) + snapshot.save! + expect(snapshot.actual_for).to be_within(1.second).of(specific_time) + end + end + end + + describe '#to_s' do + let(:rate_source) { create(:rate_source_exmo, title: 'EXMO') } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + + it 'returns formatted string' do + expect(snapshot.to_s).to include('snapshot') + expect(snapshot.to_s).to include(snapshot.id.to_s) + end + end + end +end diff --git a/spec/models/gera/external_rate_spec.rb b/spec/models/gera/external_rate_spec.rb new file mode 100644 index 00000000..b4c73d20 --- /dev/null +++ b/spec/models/gera/external_rate_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe ExternalRate do + describe 'associations' do + let(:rate_source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + let(:rate) { create(:external_rate, snapshot: snapshot) } + + it 'belongs to source' do + expect(rate).to respond_to(:source) + expect(rate.source).to eq(rate_source) + end + + it 'belongs to snapshot' do + expect(rate).to respond_to(:snapshot) + expect(rate.snapshot).to eq(snapshot) + end + end + + describe 'scopes' do + describe '.ordered' do + let(:rate_source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + let!(:rate1) { create(:external_rate, snapshot: snapshot, cur_from: Money::Currency.find(:BTC), cur_to: Money::Currency.find(:USD)) } + let!(:rate2) { create(:external_rate, snapshot: snapshot, cur_from: Money::Currency.find(:ETH), cur_to: Money::Currency.find(:USD)) } + + it 'orders by cur_from and cur_to' do + expect(ExternalRate.ordered).to eq([rate1, rate2]) + end + end + end + + describe 'callbacks' do + let(:rate_source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + + describe 'before_validation' do + it 'sets source from snapshot if blank' do + rate = ExternalRate.new(snapshot: snapshot, cur_from: 'btc', cur_to: 'usd', rate_value: 50_000) + rate.valid? + expect(rate.source).to eq(rate_source) + end + + it 'upcases currencies' do + rate = ExternalRate.new(snapshot: snapshot, cur_from: 'btc', cur_to: 'usd', rate_value: 50_000) + rate.valid? + expect(rate.cur_from).to eq('BTC') + expect(rate.cur_to).to eq('USD') + end + end + end + + describe '#dump' do + let(:rate_source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + let(:rate) { create(:external_rate, snapshot: snapshot) } + + it 'returns hash with specific attributes' do + dump = rate.dump + expect(dump.keys.map(&:to_s)).to match_array(%w[id cur_from cur_to rate_value source_id created_at]) + end + end + + describe 'CurrencyPairSupport' do + let(:rate_source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } + let(:rate) do + create(:external_rate, + snapshot: snapshot, + cur_from: Money::Currency.find(:BTC), + cur_to: Money::Currency.find(:USD)) + end + + it 'includes CurrencyPairSupport module' do + expect(ExternalRate.include?(CurrencyPairSupport)).to be true + end + + it 'responds to currency_pair' do + expect(rate).to respond_to(:currency_pair) + end + end + end +end diff --git a/spec/models/gera/payment_system_dependent_spec.rb b/spec/models/gera/payment_system_dependent_spec.rb new file mode 100644 index 00000000..e9d875dc --- /dev/null +++ b/spec/models/gera/payment_system_dependent_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gera::PaymentSystem, 'dependent delete_all' do + before do + allow(Gera::DirectionsRatesJob).to receive(:perform_later) + + # Mock BestChange::Service to avoid dependency issues + best_change_service_class = Class.new do + def initialize(exchange_rate:); end + + def rows_without_kassa + [] + end + end + stub_const('BestChange::Service', best_change_service_class) + end + + describe '#destroy' do + context 'with associated exchange_rates' do + let!(:payment_system) { create(:gera_payment_system) } + let!(:other_ps) { create(:gera_payment_system) } + + before do + # Clear auto-created exchange_rates from after_create callback + Gera::ExchangeRate.delete_all + end + + let!(:exchange_rate_as_income) do + create(:gera_exchange_rate, + payment_system_from: payment_system, + payment_system_to: other_ps) + end + + let!(:exchange_rate_as_outcome) do + create(:gera_exchange_rate, + payment_system_from: other_ps, + payment_system_to: payment_system) + end + + it 'deletes exchange_rates where payment_system is income or outcome' do + expect(Gera::ExchangeRate.count).to eq(2) + expect { payment_system.destroy }.to change(Gera::ExchangeRate, :count).by(-2) + end + + it 'does not raise foreign key constraint error' do + expect { payment_system.destroy }.not_to raise_error + end + end + + context 'with associated direction_rates' do + let!(:payment_system) { create(:gera_payment_system) } + let!(:other_ps) { create(:gera_payment_system) } + + before do + Gera::ExchangeRate.delete_all + end + + let!(:exchange_rate) do + create(:gera_exchange_rate, + payment_system_from: payment_system, + payment_system_to: other_ps) + end + + let!(:direction_rate) do + create(:gera_direction_rate, exchange_rate: exchange_rate) + end + + it 'deletes associated direction_rates through exchange_rate cascade' do + expect { payment_system.destroy }.to change(Gera::DirectionRate, :count).by(-1) + end + end + end + + describe '.destroy_all' do + before do + Gera::PaymentSystem.delete_all + Gera::ExchangeRate.delete_all + Gera::DirectionRate.delete_all + + ps1 = create(:gera_payment_system) + ps2 = create(:gera_payment_system) + Gera::ExchangeRate.delete_all # Clear auto-created + create(:gera_exchange_rate, payment_system_from: ps1, payment_system_to: ps2) + end + + it 'deletes all payment_systems and associated records' do + expect { Gera::PaymentSystem.destroy_all }.not_to raise_error + expect(Gera::PaymentSystem.count).to eq(0) + expect(Gera::ExchangeRate.count).to eq(0) + end + end +end diff --git a/spec/models/gera/payment_system_simple_test.rb b/spec/models/gera/payment_system_simple_test.rb new file mode 100644 index 00000000..7316a274 --- /dev/null +++ b/spec/models/gera/payment_system_simple_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Gera::PaymentSystem', type: :model do + describe "PaymentSystem model" do + it "creates new payment system with factory bot" do + ps = create :gera_payment_system + expect(ps).to be_persisted + expect(ps.name).to be_present + expect(ps.currency).to be_present + end + + it "validates presence of name" do + ps = Gera::PaymentSystem.new + expect(ps).not_to be_valid + expect(ps.errors[:name]).to include "can't be blank" + end + + it "creates new payment system manually" do + ps = Gera::PaymentSystem.create!( + name: "Test System", + currency: "USD", + income_enabled: true, + outcome_enabled: true, + is_available: true + ) + expect(ps).to be_persisted + expect(ps.name).to eq "Test System" + end + end +end \ No newline at end of file diff --git a/spec/models/gera/payment_system_spec.rb b/spec/models/gera/payment_system_spec.rb index 5d92710e..69c0d1f4 100644 --- a/spec/models/gera/payment_system_spec.rb +++ b/spec/models/gera/payment_system_spec.rb @@ -5,7 +5,7 @@ module Gera RSpec.describe PaymentSystem do before do - allow(DirectionsRatesWorker).to receive(:perform_async) + allow(DirectionsRatesJob).to receive(:perform_later) end subject { create :gera_payment_system } it { expect(subject).to be_persisted } diff --git a/spec/models/gera/payment_system_test.rb b/spec/models/gera/payment_system_test.rb new file mode 100644 index 00000000..763bb153 --- /dev/null +++ b/spec/models/gera/payment_system_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Gera::PaymentSystem', type: :model do + fixtures :gera_payment_systems + + describe "PaymentSystem model" do + it "loads fixtures correctly" do + expect(gera_payment_systems(:one)).to be_persisted + expect(gera_payment_systems(:one).name).to eq "Yandex Money" + expect(gera_payment_systems(:one).currency).to eq "RUB" + end + + it "validates presence of name" do + ps = Gera::PaymentSystem.new + expect(ps).not_to be_valid + expect(ps.errors[:name]).to include "can't be blank" + end + + it "creates new payment system" do + ps = Gera::PaymentSystem.create!( + name: "Test System", + currency: "USD", + income_enabled: true, + outcome_enabled: true, + is_available: true + ) + expect(ps).to be_persisted + expect(ps.name).to eq "Test System" + end + end +end \ No newline at end of file diff --git a/spec/models/gera/rate_source_auto_spec.rb b/spec/models/gera/rate_source_auto_spec.rb new file mode 100644 index 00000000..36fed2d9 --- /dev/null +++ b/spec/models/gera/rate_source_auto_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceAuto do + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + + describe '#build_currency_rate' do + subject { described_class.new } + + context 'when pair has same currencies' do + let(:pair) { CurrencyPair.new('USD/USD') } + + it 'returns CurrencyRate with rate_value 1' do + result = subject.build_currency_rate(pair) + expect(result.rate_value).to eq(1) + end + + it 'sets mode to same' do + result = subject.build_currency_rate(pair) + expect(result.mode).to eq('same') + end + end + end + end +end diff --git a/spec/models/gera/rate_source_binance_spec.rb b/spec/models/gera/rate_source_binance_spec.rb new file mode 100644 index 00000000..8f6f12e3 --- /dev/null +++ b/spec/models/gera/rate_source_binance_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceBinance do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes major crypto currencies' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('BTC', 'ETH', 'BNB', 'SOL') + end + + it 'includes stablecoins' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('USDT', 'USDC') + end + end + + describe '.available_pairs' do + it 'generates pairs from supported currencies' do + pairs = described_class.available_pairs + expect(pairs).not_to be_empty + expect(pairs).to all(be_a(CurrencyPair)) + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_bitfinex_spec.rb b/spec/models/gera/rate_source_bitfinex_spec.rb new file mode 100644 index 00000000..e47c09b7 --- /dev/null +++ b/spec/models/gera/rate_source_bitfinex_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceBitfinex do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes BTC and ETH' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('BTC', 'ETH') + end + + it 'includes fiat currencies' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('USD', 'EUR') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_bybit_spec.rb b/spec/models/gera/rate_source_bybit_spec.rb new file mode 100644 index 00000000..c5ceb881 --- /dev/null +++ b/spec/models/gera/rate_source_bybit_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceBybit do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes USDT and RUB' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('USDT', 'RUB') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_cbr_avg_spec.rb b/spec/models/gera/rate_source_cbr_avg_spec.rb new file mode 100644 index 00000000..994ba0cd --- /dev/null +++ b/spec/models/gera/rate_source_cbr_avg_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceCbrAvg do + describe 'inheritance' do + it 'inherits from RateSourceCbr' do + expect(described_class.superclass).to eq(RateSourceCbr) + end + end + + describe '.supported_currencies' do + it 'inherits supported currencies from RateSourceCbr' do + expect(described_class.supported_currencies).to eq(RateSourceCbr.supported_currencies) + end + end + + describe '.available_pairs' do + it 'inherits available pairs from RateSourceCbr' do + expect(described_class.available_pairs).to eq(RateSourceCbr.available_pairs) + end + end + end +end diff --git a/spec/models/gera/rate_source_cbr_spec.rb b/spec/models/gera/rate_source_cbr_spec.rb new file mode 100644 index 00000000..a5f6d88f --- /dev/null +++ b/spec/models/gera/rate_source_cbr_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceCbr do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes RUB' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('RUB') + end + + it 'includes KZT' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('KZT') + end + + it 'includes USD' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('USD') + end + + it 'includes EUR' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('EUR') + end + end + + describe '.available_pairs' do + it 'returns predefined currency pairs' do + pairs = described_class.available_pairs + expect(pairs).to all(be_a(CurrencyPair)) + end + + it 'includes USD/RUB pair' do + pair_strings = described_class.available_pairs.map(&:to_s) + expect(pair_strings).to include('USD/RUB') + end + + it 'includes EUR/RUB pair' do + pair_strings = described_class.available_pairs.map(&:to_s) + expect(pair_strings).to include('EUR/RUB') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_cryptomus_spec.rb b/spec/models/gera/rate_source_cryptomus_spec.rb new file mode 100644 index 00000000..6ef6d77d --- /dev/null +++ b/spec/models/gera/rate_source_cryptomus_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceCryptomus do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes fiat currencies' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('RUB', 'USD', 'EUR', 'KZT') + end + + it 'includes crypto currencies' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('BTC', 'ETH', 'USDT', 'TON') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_exmo_spec.rb b/spec/models/gera/rate_source_exmo_spec.rb new file mode 100644 index 00000000..5906d83c --- /dev/null +++ b/spec/models/gera/rate_source_exmo_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceExmo do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes BTC' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('BTC') + end + + it 'includes USD' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('USD') + end + + it 'includes RUB' do + expect(described_class.supported_currencies.map(&:iso_code)).to include('RUB') + end + end + + describe '.available_pairs' do + it 'returns array of CurrencyPair objects' do + pairs = described_class.available_pairs + expect(pairs).to all(be_a(CurrencyPair)) + end + + it 'generates pairs from supported currencies' do + pairs = described_class.available_pairs + expect(pairs).not_to be_empty + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_ff_fixed_spec.rb b/spec/models/gera/rate_source_ff_fixed_spec.rb new file mode 100644 index 00000000..27bc8512 --- /dev/null +++ b/spec/models/gera/rate_source_ff_fixed_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceFfFixed do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes major crypto currencies' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('BTC', 'ETH', 'LTC', 'USDT') + end + + it 'includes TON' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('TON') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_ff_float_spec.rb b/spec/models/gera/rate_source_ff_float_spec.rb new file mode 100644 index 00000000..745005fd --- /dev/null +++ b/spec/models/gera/rate_source_ff_float_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceFfFloat do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes major crypto currencies' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('BTC', 'ETH', 'LTC', 'USDT') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_garantexio_spec.rb b/spec/models/gera/rate_source_garantexio_spec.rb new file mode 100644 index 00000000..8e766a41 --- /dev/null +++ b/spec/models/gera/rate_source_garantexio_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceGarantexio do + describe '.supported_currencies' do + it 'returns array of Money::Currency objects' do + currencies = described_class.supported_currencies + expect(currencies).to all(be_a(Money::Currency)) + end + + it 'includes USDT, BTC, and RUB' do + iso_codes = described_class.supported_currencies.map(&:iso_code) + expect(iso_codes).to include('USDT', 'BTC', 'RUB') + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_manual_spec.rb b/spec/models/gera/rate_source_manual_spec.rb new file mode 100644 index 00000000..f233b72c --- /dev/null +++ b/spec/models/gera/rate_source_manual_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSourceManual do + describe '.supported_currencies' do + it 'returns all currencies' do + expect(described_class.supported_currencies).to eq(Money::Currency.all) + end + end + + describe '.available_pairs' do + it 'returns all currency pairs' do + expect(described_class.available_pairs).to eq(CurrencyPair.all) + end + end + + describe 'inheritance' do + it 'inherits from RateSource' do + expect(described_class.superclass).to eq(RateSource) + end + end + end +end diff --git a/spec/models/gera/rate_source_spec.rb b/spec/models/gera/rate_source_spec.rb new file mode 100644 index 00000000..5f151fa5 --- /dev/null +++ b/spec/models/gera/rate_source_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe RateSource do + describe 'associations' do + let(:source) { create(:rate_source_exmo) } + + it 'has many snapshots' do + expect(source).to respond_to(:snapshots) + end + + it 'has many external_rates' do + expect(source).to respond_to(:external_rates) + end + + it 'belongs to actual_snapshot' do + expect(source).to respond_to(:actual_snapshot) + end + end + + describe 'scopes' do + let!(:enabled_source) { create(:rate_source_exmo, is_enabled: true, priority: 1) } + let!(:disabled_source) { create(:rate_source_cbr, is_enabled: false, priority: 2) } + + describe '.ordered' do + it 'orders by priority' do + expect(RateSource.ordered.first).to eq(enabled_source) + end + end + + describe '.enabled' do + it 'returns only enabled sources' do + expect(RateSource.enabled).to include(enabled_source) + expect(RateSource.enabled).not_to include(disabled_source) + end + end + end + + describe 'callbacks' do + describe 'before_create' do + it 'sets priority if not provided' do + source = RateSource.create!(type: 'Gera::RateSourceManual', key: 'test_manual') + expect(source.priority).to be_present + end + end + + describe 'before_validation' do + it 'sets title from class name if blank' do + source = RateSource.new(type: 'Gera::RateSourceManual') + source.valid? + expect(source.title).to be_present + end + + it 'sets key from class name if blank' do + source = RateSource.new(type: 'Gera::RateSourceManual') + source.valid? + expect(source.key).to be_present + end + end + end + + describe '.get!' do + let!(:source) { create(:rate_source_exmo) } + + it 'returns source by type' do + expect(RateSourceExmo.get!).to eq(source) + end + end + + describe '#find_rate_by_currency_pair' do + let(:source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: source) } + let(:currency_pair) { CurrencyPair.new('BTC/USD') } + let!(:external_rate) do + create(:external_rate, + snapshot: snapshot, + cur_from: Money::Currency.find(:BTC), + cur_to: Money::Currency.find(:USD), + rate_value: 50_000) + end + + before { source.update!(actual_snapshot_id: snapshot.id) } + + it 'finds rate by currency pair' do + expect(source.find_rate_by_currency_pair(currency_pair)).to eq(external_rate) + end + + it 'returns nil when rate not found' do + unknown_pair = CurrencyPair.new('ETH/EUR') + expect(source.find_rate_by_currency_pair(unknown_pair)).to be_nil + end + end + + describe '#find_rate_by_currency_pair!' do + let(:source) { create(:rate_source_exmo) } + + it 'raises RateNotFound when rate not found' do + currency_pair = CurrencyPair.new('BTC/USD') + expect { source.find_rate_by_currency_pair!(currency_pair) }.to raise_error(RateSource::RateNotFound) + end + end + + describe '#is_currency_supported?' do + let(:source) { create(:rate_source_exmo) } + + it 'returns true for supported currency' do + expect(source.is_currency_supported?(:BTC)).to be true + end + + it 'returns false for unsupported currency' do + expect(source.is_currency_supported?(:KZT)).to be false + end + + it 'accepts Money::Currency objects' do + currency = Money::Currency.find(:BTC) + expect(source.is_currency_supported?(currency)).to be true + end + end + + describe '#actual_rates' do + let(:source) { create(:rate_source_exmo) } + let(:snapshot) { create(:external_rate_snapshot, rate_source: source) } + let!(:external_rate) { create(:external_rate, snapshot: snapshot) } + + before { source.update!(actual_snapshot_id: snapshot.id) } + + it 'returns external rates from actual snapshot' do + expect(source.actual_rates).to include(external_rate) + end + end + + describe '#to_s' do + let(:source) { create(:rate_source_exmo, title: 'EXMO Exchange') } + + it 'returns title' do + expect(source.to_s).to eq('EXMO Exchange') + end + end + end +end diff --git a/spec/models/gera/rate_source_test.rb b/spec/models/gera/rate_source_test.rb new file mode 100644 index 00000000..66d0a8fd --- /dev/null +++ b/spec/models/gera/rate_source_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Gera::RateSource', type: :model do + fixtures :gera_rate_sources + + describe "RateSource model" do + it "loads fixtures correctly" do + source = gera_rate_sources(:cbr) + expect(source).to be_persisted + expect(source.name).to eq "Central Bank of Russia" + expect(source.type).to eq "Gera::RateSourceCbr" + expect(source.is_enabled).to be true + end + + it "creates new rate source" do + source = Gera::RateSource.create!( + name: "Test Source", + type: "Gera::RateSourceManual", + is_enabled: true + ) + expect(source).to be_persisted + expect(source.name).to eq "Test Source" + end + + it "validates presence of name" do + source = Gera::RateSource.new + expect(source).not_to be_valid + expect(source.errors[:name]).to include "can't be blank" + end + end +end \ No newline at end of file diff --git a/spec/models/gera/target_autorate_setting_spec.rb b/spec/models/gera/target_autorate_setting_spec.rb new file mode 100644 index 00000000..604c784d --- /dev/null +++ b/spec/models/gera/target_autorate_setting_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe TargetAutorateSetting do + # Note: This spec tests the model's interface. + # The table gera_target_autorate_settings may not exist in test database. + + describe 'model interface' do + it 'inherits from ApplicationRecord' do + expect(TargetAutorateSetting.superclass).to eq(ApplicationRecord) + end + + it 'is defined as a class' do + expect(TargetAutorateSetting).to be_a(Class) + end + + it 'has exchange_rate association defined' do + expect(TargetAutorateSetting.reflect_on_association(:exchange_rate)).to be_present + end + + it 'defines could_be_calculated? method' do + expect(TargetAutorateSetting.instance_methods).to include(:could_be_calculated?) + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 00000000..d1abbf9e --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# This file is copied to spec/ when you run 'rails generate rspec:install' +ENV['RAILS_ENV'] ||= 'test' +require File.expand_path('dummy/config/environment.rb', __dir__) + +require 'rspec/rails' +require 'factory_bot' +require 'database_rewinder' + +# Requires supporting ruby files with custom matchers and macros, etc, +# in spec/support/ and its subdirectories. +Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } + +# Load Gera library +require_relative '../lib/gera' + +# Skip migration check for SQLite memory database +# ActiveRecord::Migration.maintain_test_schema! + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods + + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = ["#{::Rails.root}/spec/fixtures"] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # RSpec Rails can automatically mix in different behaviours to your tests + # based on their file location, for example enabling you to call `get` and + # `post` in specs under `spec/controllers`. + # + # You can disable this behaviour by removing the line below, or instead + # explicitly enabling it with the `--type request` option: + # + # rails generate rspec:integration my_request_spec.rb --type request + # + # The different available types are documented in the features, such as in + # https://relishapp.com/rspec/rspec-rails/docs + config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") + + # Add Gera-specific configuration + config.before(:each) do + Gera::Universe.clear! + end + + config.before(:suite) do + FactoryBot.definition_file_paths = [File.expand_path('../../factories', __dir__)] + FactoryBot.find_definitions + DatabaseRewinder.init + DatabaseRewinder.clean_all + end + + config.after(:each) do + DatabaseRewinder.clean + end +end \ No newline at end of file diff --git a/spec/services/gera/rate_comission_calculator_spec.rb b/spec/services/gera/rate_comission_calculator_spec.rb new file mode 100644 index 00000000..ab1bb6a8 --- /dev/null +++ b/spec/services/gera/rate_comission_calculator_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + # Extend PaymentSystem with auto_rate_settings for testing + # This association is expected to be defined in the host application + class PaymentSystem + def auto_rate_settings + # Return empty relation-like object + @auto_rate_settings_stub ||= Class.new do + def find_by(*) + nil + end + end.new + end + end + + RSpec.describe RateComissionCalculator do + let(:payment_system_from) { create(:gera_payment_system, currency: Money::Currency.find('USD')) } + let(:payment_system_to) { create(:gera_payment_system, currency: Money::Currency.find('RUB')) } + let(:exchange_rate) do + create(:gera_exchange_rate, + payment_system_from: payment_system_from, + payment_system_to: payment_system_to) + end + + subject(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + let(:external_rates) { nil } + + describe '#auto_comission' do + it 'returns calculated commission' do + # Without real auto_rate_settings, auto_comission returns 0 + expect(calculator.auto_comission).to be_a(Numeric) + end + end + + describe '#auto_comission_by_reserve' do + context 'when auto rates by reserve not ready' do + it 'returns 0.0' do + expect(calculator.auto_comission_by_reserve).to eq(0.0) + end + end + end + + describe '#comission_by_base_rate' do + context 'when auto rates by base rate not ready' do + it 'returns 0.0' do + expect(calculator.comission_by_base_rate).to eq(0.0) + end + end + end + + describe '#auto_rate_by_base_from' do + context 'when base rate checkpoints not ready' do + it 'returns 0.0' do + expect(calculator.auto_rate_by_base_from).to eq(0.0) + end + end + end + + describe '#auto_rate_by_base_to' do + context 'when base rate checkpoints not ready' do + it 'returns 0.0' do + expect(calculator.auto_rate_by_base_to).to eq(0.0) + end + end + end + + describe '#auto_rate_by_reserve_from' do + context 'when reserve checkpoints not ready' do + it 'returns 0.0' do + expect(calculator.auto_rate_by_reserve_from).to eq(0.0) + end + end + end + + describe '#auto_rate_by_reserve_to' do + context 'when reserve checkpoints not ready' do + it 'returns 0.0' do + expect(calculator.auto_rate_by_reserve_to).to eq(0.0) + end + end + end + + describe '#current_base_rate' do + context 'when same currencies' do + let(:payment_system_to) { create(:gera_payment_system, currency: Money::Currency.find('USD')) } + + it 'returns 1.0' do + expect(calculator.current_base_rate).to eq(1.0) + end + end + + context 'when different currencies with history' do + let!(:history_interval) do + # Use even 5-minute intervals as required by HistoryIntervalConcern + base_time = Time.current.beginning_of_hour + CurrencyRateHistoryInterval.create!( + cur_from_id: exchange_rate.in_currency.local_id, + cur_to_id: exchange_rate.out_currency.local_id, + avg_rate: 75.5, + min_rate: 74.0, + max_rate: 77.0, + interval_from: base_time, + interval_to: base_time + 5.minutes + ) + end + + it 'returns avg_rate from last history interval' do + expect(calculator.current_base_rate).to eq(75.5) + end + end + end + + describe '#average_base_rate' do + context 'when same currencies' do + let(:payment_system_to) { create(:gera_payment_system, currency: Money::Currency.find('USD')) } + + it 'returns 1.0' do + expect(calculator.average_base_rate).to eq(1.0) + end + end + end + + describe '#auto_comission_from' do + it 'returns sum of reserve and base rate auto rates' do + expect(calculator.auto_comission_from).to eq(0.0) + end + end + + describe '#auto_comission_to' do + it 'returns sum of reserve and base rate auto rates' do + expect(calculator.auto_comission_to).to eq(0.0) + end + end + + describe '#bestchange_delta' do + it 'returns auto_comission_by_external_comissions' do + expect(calculator.bestchange_delta).to eq(0) + end + end + + describe 'constants' do + it 'defines AUTO_COMISSION_GAP' do + expect(described_class::AUTO_COMISSION_GAP).to eq(0.01) + end + + it 'defines NOT_ALLOWED_COMISSION_RANGE' do + expect(described_class::NOT_ALLOWED_COMISSION_RANGE).to eq(0.7..1.4) + end + + it 'defines EXCLUDED_PS_IDS' do + expect(described_class::EXCLUDED_PS_IDS).to eq([54, 56]) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 542b4eec..20c69198 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,8 +14,11 @@ require 'timecop' -require 'sidekiq/testing/inline' -Sidekiq::Testing.inline! +# ActiveJob test mode - jobs execute immediately +ActiveJob::Base.queue_adapter = :test + +# Suppress Money gem deprecation warnings +Money.locale_backend = :i18n require_relative '../lib/gera' @@ -23,16 +26,32 @@ Rails.backtrace_cleaner.remove_silencers! +# Monkey patch to prevent fixture_path error in Rails 8 +if defined?(RSpec::Core::ExampleGroup) && !RSpec::Core::ExampleGroup.respond_to?(:fixture_path=) + RSpec::Core::ExampleGroup.define_singleton_method(:fixture_path=) do |path| + # Do nothing - fixture_path is deprecated in Rails 8 + end +end + +if defined?(ActiveSupport::TestCase) && !ActiveSupport::TestCase.respond_to?(:fixture_path=) + ActiveSupport::TestCase.define_singleton_method(:fixture_path=) do |path| + # Do nothing - fixture_path is deprecated in Rails 8 + end +end + VCR.configure do |c| c.cassette_library_dir = 'spec/vcr_cassettes' # c.allow_http_connections_when_no_cassette = true c.ignore_localhost = true c.hook_into :webmock c.configure_rspec_metadata! + c.allow_http_connections_when_no_cassette = true end RSpec.configure do |config| config.include FactoryBot::Syntax::Methods + config.use_transactional_fixtures = true + config.fixture_paths = [Rails.root.join('spec/fixtures')] # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. @@ -51,10 +70,7 @@ Gera::Universe.clear! end - config.before(:suite) do - FactoryBot.find_definitions - end - + # rspec-mocks config goes here. You can use an alternate test double # library (such as bogus or mocha) by changing the `mock_with` option here. config.mock_with :rspec do |mocks| @@ -72,6 +88,8 @@ config.shared_context_metadata_behavior = :apply_to_host_groups config.before(:suite) do + FactoryBot.definition_file_paths = [File.expand_path('../factories', __dir__)] + FactoryBot.find_definitions DatabaseRewinder.init require 'database_rewinder/active_record_monkey' # Почему-то падает с ошибкой undefined method `empty?' for nil:NilClass diff --git a/spec/vcr_cassettes/Gera_BitfinexFetcher/1_1.yml b/spec/vcr_cassettes/Gera_BitfinexFetcher/1_1.yml index 5eaa43c2..a17ac82b 100644 --- a/spec/vcr_cassettes/Gera_BitfinexFetcher/1_1.yml +++ b/spec/vcr_cassettes/Gera_BitfinexFetcher/1_1.yml @@ -2,38 +2,60 @@ http_interactions: - request: method: get - uri: https://api.bitfinex.com/v1/pubticker/neousd + uri: https://api-pub.bitfinex.com/v2/tickers?symbols=ALL body: encoding: US-ASCII string: '' headers: Accept: - "*/*" - Accept-Encoding: - - gzip, deflate User-Agent: - - rest-client/2.0.2 (linux-gnu x86_64) ruby/2.4.4p296 + - rest-client/2.1.0 (linux x86_64) ruby/3.2.8p263 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Host: - - api.bitfinex.com + - api-pub.bitfinex.com response: status: code: 200 message: OK headers: - Server: - - nginx/1.6.2 Date: - - Sun, 29 Jul 2018 16:32:09 GMT + - Sun, 19 Oct 2025 14:43:55 GMT Content-Type: - - text/html - Content-Length: - - '175' + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked Connection: - keep-alive - Location: - - http://orionet.ru/ + Cf-Ray: + - 99110ced98dfd2a9-FRA + Vary: + - Accept-Encoding + X-Frame-Options: + - sameorigin + X-Xss-Protection: + - 1; mode=block + Referrer-Policy: + - same-origin + X-Download-Options: + - noopen + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; + X-Permitted-Cross-Domain-Policies: + - none + Last-Modified: + - Sun, 19 Oct 2025 14:43:55 GMT + Cf-Cache-Status: + - HIT + Age: + - '0' + Server: + - cloudflare body: - encoding: UTF-8 - string: '{"mid":"0.00408895","bid":"0.0040889","ask":"0.004089","last_price":"0.0040889","low":"0.0040562","high":"0.0041476","volume":"7406.62321845","timestamp":"1532882027.7319012"}' - http_version: - recorded_at: Wed, 14 Mar 2018 21:05:58 GMT + encoding: ASCII-8BIT + string: '[["tBTCUSD",108400,11.36344859,108410,21.98270172,1090,0.01015654,108410,758.30311744,108680,106370],["tLTCUSD",94.374,1289.00178637,94.393,431.08056801,3.509,0.0386139,94.383,4745.79429369,94.703,90.673],["tLTCBTC",0.00087061,1057.32083493,0.00087113,319.70869286,0.0000229,0.02699676,0.00087115,741.28052688,0.00087259,0.000846],["tETHUSD",3987.8,40.77074365,3987.9,78.8465045,101.4,0.02608763,3988.3,2798.26549212,4005,3833.3],["tETHBTC",0.036784,66.43378175,0.036801,172.89956541,0.000549,0.01515444,0.036776,543.97948063,0.036898,0.036021],["tETCBTC",0.00014595,16442.2766412,0.00014626,14520.04045738,0.00000168,0.01164968,0.00014589,501.04706225,0.00014589,0.00014323],["tETCUSD",15.814,14122.31239001,15.827,10001.93237049,0.32,0.02067718,15.796,4041.81723966,15.853,15.232],["tRRTUSD",0.75001,327802.47277754,0.79,58653.33585171,0,0,0.75001,100,0.75001,0.75001],["tZECUSD",221.29,178.18325859,222.27,65.44179004,4.9,0.02265267,221.21,280.52296571,231,210.39],["tZECBTC",0.0019804,137.726813,0.0020993,148.37511322,0.0000254,0.01279855,0.00201,7.87281352,0.0021141,0.0019304],["tXMRUSD",318.76,81.06454248,318.98,52.36066118,9.66,0.03122979,318.98,1776.31512619,320,305.62],["tXMRBTC",0.0029366,494.49147287,0.0029479,6.88847306,0.0000534,0.01856875,0.0029292,206.28768067,0.0029751,0.0028532],["tDSHUSD",43.839,509.93866641,43.926,454.07538926,-1.194,-0.02653569,43.802,2168.39946986,44.996,41.801],["tDSHBTC",0.00040262,382.85711225,0.0004071,124.57305227,-0.00001436,-0.03453916,0.0004014,41.05586393,0.00041576,0.00039326],["tBTCEUR",93085,6.87382802,93175,5.11447324,898,0.00973547,93138,1.20865653,93350,91397],["tBTCJPY",16088000,28.18197661,16459000,10.67803486,-109000,-0.00721329,15002000,0.0001446,15111000,15002000],["tXRPUSD",2.4122,48202.72703827,2.4126,133847.2120875,0.0378,0.01592384,2.4116,421797.85517357,2.4173,2.3226],["tXRPBTC",0.00002225,625393.18783771,0.00002227,424306.05353838,1e-7,0.00451875,0.00002223,21803.80828356,0.00002223,0.00002188],["tIOTUSD",0.14594,1026981.69179705,0.14641,302086.37619138,0.00726,0.05200201,0.14687,342969.41856963,0.14687,0.13715],["tIOTBTC",0.00000134,240407.57470544,0.00000136,124733.38573671,2e-8,0.01526718,0.00000133,1526.89483712,0.00000134,0.00000128],["tEOSUSD",0.29062,17088.05309164,0.29139,3612.2503499,0.00825,0.02916637,0.29111,6157.50716478,0.292,0.28254],["tEOSBTC",0.00000242,104831.12797239,0.00000292,20989.23322278,-6e-8,-0.0234375,0.0000025,23.74168606,0.00000256,0.0000025],["tNEOUSD",5.1904,28340.72569984,5.2088,3639.0372388,0.1227,0.02452332,5.1261,728.43828245,5.2057,5.0034],["tZRXUSD",0.20228,82945.71164347,0.20662,38812.61085018,0.00574,0.02909718,0.20301,36746.18811709,0.20348,0.19643],["tTRXUSD",0.32027,769771.89258806,0.3204,454830.52672167,0.00619,0.01971714,0.32013,112889.49876221,0.32052,0.31339],["tTRXBTC",0.00000295,1542465.31910817,0.00000297,743625.6819502,0,0,0.00000295,28006.65130678,0.00000295,0.00000293],["tBTCGBP",80799,11.58461106,80994,11.4605534,1100,0.01378446,80900,0.01597595,81000,79393],["tETHEUR",3422.8,187.84107008,3428,187.30200278,95,0.02850798,3427.4,5.55727847,3437.4,3299],["tETHJPY",591100,30098.13568372,606370,130.66368467,7060,0.01223337,584170,0.00551325,584170,577110],["tETHGBP",2973.1,1673.46899045,2981.6,146.01641801,49,0.01699972,2931.4,0.30089711,2931.4,2873.7],["tDAIUSD",0.99401,27467.03477937,0.99979,73424.92540717,0,0,0.99796,5.63865269,0.99796,0.99796],["tXLMUSD",0.32205,399312.27996114,0.32223,832296.97095671,0.00524,0.01655504,0.32176,446715.84992841,0.32313,0.30818],["tXLMBTC",0.00000296,142072.90629738,0.00000298,224321.45396107,3e-8,0.01020408,0.00000297,18942.70747451,0.00000297,0.0000029],["tXTZUSD",0.60175,422699.2351126,0.60331,16837.21859733,0.00071,0.00121617,0.58451,10957.43820449,0.58943,0.58056],["tTRXEUR",0.27387,7157319.29506274,0.27594,370892.31977205,-0.00206,-0.00761553,0.26844,115.45219099,0.2705,0.26844],["tOMNUSD",1.3,19388.85809981,1.6412,3780.04213471,0,0,1.4,2,1.4,1.4],["tPNKUSD",0.02977,1173803.72218456,0.02989,1042283.74999999,-0.00008,-0.00266934,0.02989,808017.31376431,0.02997,0.02977],["tUSTUSD",1.0014,12151287.78651091,1.0015,16847168.44624413,-0.0005,-0.000499,1.0015,97177460.99288982,1.0021,1.0013],["tEUTUSD",1.0486,789788.81990735,1.3999,3781.82066473,0,0,1.0958,34.23694166,1.0958,1.0958],["tUDCUSD",1.0011,1114764.88380141,1.0013,1312734.20192846,-0.0001,-0.00009986,1.0013,1069794.67406938,1.0015,1.0012],["tTSDUSD",0.99301,51084.12958152,1.0007,1211.05116288,0,0,1.0007,11.42267969,1.0007,1.0007],["tBTTUSD",5e-7,16221303383.506264,5.1e-7,17470520562.84524,0,0,4.9e-7,9880200,4.9e-7,4.9e-7],["tBTCUST",108210,13.04777408,108220,8.08354268,1150,0.01074264,108200,153.91627804,108490,106150],["tETHUST",3981.7,44.5738743,3982.1,63.21155817,103.6,0.02671894,3981,5670.17045874,4000,3825.7],["tLTCUST",94.257,1343.8372477,94.26,590.31813461,3.577,0.03943901,94.274,5468.13222863,94.586,90.45],["tEOSUST",0.29061,4380.37739396,0.29162,16328.02933506,0.0012,0.00421038,0.28621,2262.70404852,0.28916,0.28447],["tATOUSD",3.2587,73057.49668006,3.2761,55710.44025189,0.0571,0.01787056,3.2523,404.6509392,3.2523,3.1579],["tWBTUSD",107980,21.3295666,109810,0.04988255,0,0,115180,0.00255454,115180,115180],["tLEOUSD",9.4014,4322.97772411,9.4243,1816.20947734,0.023,0.00244647,9.4243,462.54351349,9.4243,9.4013],["tLEOBTC",0.00006855,208309.27382304,0.00009208,18200.52544563,0,0,0.00009158,1.54693328,0.00009158,0.00009158],["tLEOUST",9.408,1864.80238586,9.4377,3254.73371674,-0.0082,-0.00086927,9.425,416.04003928,9.4383,9.415],["tLEOETH",0.0019961,511950.18599853,0.0027371,178.28394302,0,0,0.0027356,1.2717252,0.0027356,0.0026642],["tGTXUSD",16.029,4607.32579107,16.037,5730.1303316,0,0,15.904,1.2801,15.904,15.904],["tKANUSD",0.00064901,25619476.66681747,0.000655,12604636.25940555,0,0,0.000654,2807050.61084114,0.00065601,0.000643],["tGTXUST",16.005,27139.1605,16.015,4955.78999629,0,0,15.914,1.2801,15.914,15.914],["tKANUST",0.000654,6059908.65363345,0.000659,12531415.63804195,0.000008,0.01228879,0.000659,1479969.30698755,0.000659,0.000651],["tAMPUSD",1.2167,11650.13224496,1.2196,10787.77099722,0.0329,0.02797381,1.209,1603.3372722,1.209,1.1663],["tALGUSD",0.18674,1050407.85330101,0.18694,366383.15986688,0.00771,0.04301495,0.18695,109043.16543908,0.18744,0.17714],["tAMPUST",1.2173,22372.62408631,1.2201,12474.71705575,0.0553,0.04747596,1.2201,1919.88350664,1.2201,1.1647],["tUOSUSD",0.022,3730519.40686609,0.02319,63629.61716552,-0.00099,-0.04145729,0.02289,134051.32406578,0.024348,0.022],["tUOSBTC",2e-7,103011.40590157,2.2e-7,386683.33972803,-1e-8,-0.04545455,2.1e-7,363315.15304352,2.2e-7,1.8e-7],["tUDCUST",0.9996,675945.58266148,0.9997,652113.48548039,0.00009,0.00009004,0.9996,25333.6996629,0.99974,0.99951],["tTSDUST",0.99301,26503.7230868,1.0001,25174.73580334,0,0,0.99301,2.46278575,0.99301,0.99301],["tUST:CNHT",6,50620.13947236,8,2401.32747247,0,0,6.5,34.7254238,6.5,6],["tCHZUSD",0.03329,3596321.81639873,0.033328,999591.17447615,0.000348,0.0106919,0.032896,19717.61060637,0.033049,0.032312],["tCHZUST",0.033225,4628184.6724031,0.033277,208313.54242126,0.000874,0.02688734,0.03338,83067.15358908,0.033382,0.03241],["tXAUT:USD",4241,199.91838692,4247,57.85279026,9,0.00212314,4248,43.92800842,4256.6,4232],["tXAUT:BTC",0.039111,103.24191133,0.039251,99.04671015,-0.000374,-0.00944683,0.039216,18.58477231,0.039839,0.039216],["tXAUT:UST",4236.6,251.81402782,4238.7,94.09466231,5.2,0.00122804,4239.6,43.21472938,4249.1,4226.7],["tTESTBTC:TESTUSD",108270,132.05986507,108460,158.76476842,1080,0.01005774,108460,2.60732402,108710,106380],["tTESTBTC:TESTUSDT",108110,148.03919644,108320,159.1703165,1220,0.01139122,108320,41.75958625,108610,106270],["tDOTUSD",3.034,109202.86977907,3.0383,82691.44159414,0.1092,0.03728362,3.0381,8435.89902082,3.0464,2.8965],["tADAUSD",0.65643,291443.56050105,0.65683,158474.25815892,0.0216,0.03404309,0.65609,66707.31675621,0.65866,0.6234],["tADABTC",0.00000605,799978.61584896,0.00000607,691601.65532698,1.5e-7,0.02542373,0.00000605,1460.64343395,0.00000605,0.0000059],["tADAUST",0.65531,184970.508921,0.65583,126327.97319779,0.02475,0.03918495,0.65637,24340.23902244,0.65663,0.62407],["tFETUSD",0.27196,291901.24610244,0.27413,67858.01894494,0.01182,0.04583172,0.26972,65315.10114135,0.26972,0.25432],["tFETUST",0.27267,659400.86307596,0.27318,124225.22828448,0.01586,0.06179141,0.27253,93165.8621009,0.27368,0.25278],["tDOTUST",3.0323,82215.97529827,3.033,68454.34266288,0.0967,0.0329854,3.0283,7947.52571831,3.0477,2.8847],["tLINK:USD",17.475,14881.13597285,17.483,15340.54776431,0.619,0.03675772,17.459,844.49642557,17.459,16.599],["tLINK:UST",17.444,8362.49958836,17.457,14886.19270086,0.65,0.03875738,17.421,2328.7089674,17.5,16.562],["tCOMP:USD",36.318,6391.60110749,36.528,5665.33385728,2.34,0.0681183,36.692,134.71962582,37.255,34],["tCOMP:UST",36.317,7411.74008485,36.52,5274.18141729,1.948,0.05702075,36.111,158.87011173,36.687,34.148],["tEGLD:USD",9.9177,24369.87080248,9.9957,15049.34849173,0.0509,0.00520115,9.8372,71.49628559,9.9048,9.4835],["tEGLD:UST",9.9125,34333.04526875,9.9807,15842.09538329,0.2535,0.02620047,9.9289,142.12827529,9.9289,9.4746],["tUNIUSD",6.3029,4652.60018971,6.3036,2747.99337887,0.3054,0.05091952,6.3031,6891.49504148,6.3362,5.9202],["tUNIUST",6.2932,12558.98114524,6.2933,9226.95932218,0.3078,0.05142428,6.2933,1762.05030368,6.3276,5.9124],["tAVAX:USD",20.726,4369.64859866,20.736,4106.69129597,0.664,0.03304469,20.758,2813.36113433,20.816,19.822],["tAVAX:UST",20.693,6354.20058925,20.704,10388.84624924,0.748,0.03740374,20.746,155.02268599,20.746,19.8],["tYFIUSD",4878.9,2.2881029,4890.8,0.57267261,154.2,0.03252067,4895.8,0.18614024,4920,4731.1],["tYFIUST",4856.6,504.43771676,4895.2,1.13183593,153.6,0.03249143,4881,0.02425644,4881,4727.4],["tFILUSD",1.5291,131946.5793903,1.5328,70421.49959725,-0.0086,-0.00569801,1.5007,1773.12037259,1.5093,1.4842],["tFILUST",1.5261,86193.51772198,1.5302,46560.76023927,0.0256,0.01703147,1.5287,4449.9521661,1.5287,1.4856],["tJSTUSD",0.031805,2326940.86000622,0.031997,802808.84132147,-0.000069,-0.00216477,0.031805,252966.09146066,0.031874,0.031715],["tJSTUST",0.031805,24266670.29058018,0.031993,763524.68526325,0,0,0.031676,254129.60730416,0.031676,0.031675],["tBCHN:USD",476.15,182.72713842,476.91,182.8447169,7.64,0.01630632,476.17,13.62760397,476.17,460],["tXDCUSD",0.061221,818565.39700145,0.061259,850537.01620014,0.000768,0.0126938,0.06127,630324.40705091,0.06127,0.054293],["tXDCUST",0.061131,662451.12528193,0.061169,538076.79963134,0.00077,0.01273569,0.06123,385631.77864661,0.06127,0.05853],["tSUNUSD",0.023042,1155991.82429323,0.023206,1129373.21412278,0.000134,0.00585536,0.023019,4179.32833,0.023019,0.022843],["tSUNUST",0.023014,64813814.42721307,0.023152,1415135.86496424,-0.000138,-0.00599635,0.022876,2600.62559431,0.023014,0.022876],["tEUTUST",1.0501,134583.20891334,1.16,145493.38030776,0,0,1.16,33.91448359,1.16,1.16],["tXMRUST",318.19,651.31733559,318.81,530.07798858,10.12,0.03275399,319.09,5821.55603635,319.09,305.02],["tXRPUST",2.4082,54918.35302981,2.4087,108251.6077886,0.0393,0.01659068,2.4081,344506.97862907,2.4142,2.3181],["tSUSHI:USD",0.53912,252167.40481195,0.53963,176170.79520953,0.03041,0.05962745,0.54041,5703.24804726,0.54507,0.51],["tSUSHI:UST",0.53836,224645.2296001,0.53905,364571.49746998,0.02471,0.04794986,0.54004,6924.31931667,0.54312,0.51149],["tETH2X:USD",3787.4,480818.22815113,3920,16.17014225,-0.1,-0.00002551,3919.9,0.12928967,3920,3919.9],["tETH2X:UST",3005.6,254008.4590789,944420,5.0672186,0,0,3695.4,0.025915,3695.4,3695.4],["tETH2X:ETH",0.99401,60560.61260182,1.0003,272.46603397,-0.00629,-0.00628811,0.99401,0.36523193,1.0003,0.99401],["tAAVE:USD",225.37,833.94191858,225.7,776.8001219,15.68,0.07490923,225,184.27534026,225.73,209.2],["tAAVE:UST",225.11,792.72837039,225.44,1064.39563127,14.64,0.06946949,225.38,123.28777106,225.38,209.01],["tXLMUST",0.32137,296496.78418529,0.32171,445352.21374215,0.00366,0.01153919,0.32084,99621.287695,0.32202,0.30903],["tSOLUSD",191.19,657.99971366,191.2,900.39359289,4.79,0.02570846,191.11,13530.38374732,191.85,183.64],["tSOLUST",190.87,621.67346007,190.89,490.23373401,4.99,0.02684961,190.84,5435.52725579,191.48,183.35],["tNEAR:USD",2.2602,108004.46421075,2.28,82941.62606996,0.1196,0.05563049,2.2695,201.91014002,2.2917,2.1499],["tNEAR:UST",2.2549,417193.53277516,2.2731,25063.90637657,-0.011,-0.00510773,2.1426,142.30353741,2.1731,2.1426],["tDOGE:USD",0.19759,523087.99269645,0.19766,814029.59135883,0.00918,0.0488168,0.19723,1333605.94896422,0.19821,0.18642],["tDOGE:UST",0.19724,414714.22379119,0.19736,321039.68316148,0.01016,0.05416356,0.19774,373169.12368468,0.19774,0.18608],["tNEXO:USD",1.1677,100105.62196793,1.3069,987.66862189,-0.0033,-0.00280779,1.172,1038.77251271,1.1753,1.172],["tNEXO:BTC",0.00001086,27244.30998822,0.00001087,23467.95173777,-8e-8,-0.00731261,0.00001086,185.8693816,0.00001094,0.0000108],["tNEXO:UST",1.1661,666362.26973079,1.1708,24582.94156666,-0.0032,-0.00272549,1.1709,1222.46757025,1.1741,1.1662],["tICPUSD",3.1101,78227.56844145,3.4125,261.96036003,-0.2663,-0.08265822,2.9554,37.07806153,3.2217,2.9554],["tICPUST",3.1017,63589.64739554,3.15,790.18586745,-0.2475,-0.07503638,3.0509,209.0744066,3.2984,3.0035],["tXRDUSD",0.0025277,2797666.33932409,0.0026068,4074402.07030876,-0.0000334,-0.01299105,0.0025376,738971.88258284,0.0026108,0.0024785],["tXRDBTC",2e-8,5927624.34175055,3e-8,9098196.37027655,1e-8,0.5,3e-8,41829.40858642,3e-8,2e-8],["tDOGE:BTC",0.00000182,3677288.73873327,0.00000183,2480016.97648626,5e-8,0.02840909,0.00000181,465.00744656,0.00000181,0.00000176],["tETCUST",15.785,12405.74420412,15.804,17057.99548295,0.373,0.02417369,15.803,381.48979743,15.803,15.288],["tNEOUST",5.1784,141201.57686543,5.2062,1591.16089939,0.0082,0.00162889,5.0423,3.96401303,5.0615,5.034],["tATOUST",3.2562,74499.88808247,3.2688,55744.9837301,0.0818,0.02571357,3.263,2497.98066933,3.263,3.1767],["tTRXUST",0.31965,455207.52199227,0.32007,651788.05188424,0.00614,0.01958595,0.31963,16084.96966889,0.32,0.31274],["tEURUST",1.1616,130043.40898086,1.1617,44535.83632258,-0.001,-0.00086014,1.1616,42152.77684565,1.163,1.1603],["tBTC:XAUT",25.446,3.41476229,25.549,3.04562277,0.315,0.01249752,25.52,0.01034952,25.52,25.137],["tETH:XAUT",0.93577,172581.14701212,0.9443,69.1749979,0,0,0.90899,0.00458102,0.90899,0.90899],["tSOLBTC",0.0017632,748.73193542,0.0017633,1091.81142716,0.0000275,0.01584375,0.0017632,2076.42604493,0.0017654,0.0017213],["tAVAX:BTC",0.00019077,20924.57024585,0.00019153,18173.75411273,0.000003,0.01595915,0.00019098,115.37117714,0.00019098,0.00018649],["tJASMY:USD",0.010109,15339828.56724158,0.010147,16473476.72320355,0.000156,0.01576395,0.010052,75313.25435518,0.010052,0.0098654],["tJASMY:UST",0.010065,6362510.27591507,0.010181,429481.27940655,-0.0000586,-0.00589656,0.0098794,5250.00155191,0.010016,0.0098794],["tSHIB:USD",0.00001014,20445956316.530518,0.00001017,20590929037.837948,2.7e-7,0.02735562,0.00001014,269181870.4955269,0.00001016,0.00000978],["tSHIB:UST",0.00001013,24768905399.385952,0.00001015,23640406243.78449,3.4e-7,0.034588,0.00001017,147830538.50539228,0.00001017,0.00000981],["tMIMUSD",0.79046,126089.58794403,1.0892,142.14522352,0,0,0.915,5.58148657,0.915,0.915],["tMIMUST",0.98931,22031.4429628,1.0089,90075.32844466,-0.02029,-0.0200911,0.98961,11.05686003,1.0099,0.98961],["tSPELL:USD",0.00035591,18258655.81904838,0.00035856,5497685.09701171,0.00000627,0.01803798,0.00035387,327749.59642619,0.00035387,0.00034479],["tSPELL:UST",0.00035594,27132941.94201346,0.00035836,1626849.39947778,0.0000051,0.01459854,0.00035445,32149.95277782,0.00035445,0.00034635],["tCRVUSD",0.56557,290306.52656223,0.56699,39753.54841748,0.03694,0.07000853,0.56459,16488.94681453,0.56459,0.52603],["tCRVUST",0.56493,295380.95535743,0.56613,97509.98782296,0.035,0.06656397,0.56081,5090.40743794,0.56081,0.51757],["tWOOUSD",0.030636,164138.99128252,0.049829,62035.24145035,0,0,0.049829,529.2562114,0.049829,0.049829],["tWOOUST",0.043006,18879381.79047949,0.043508,22530.72595296,0.00057,0.01373329,0.042075,79.56438042,0.042075,0.041505],["tGBPUST",1.3364,12361.90450599,1.3394,22520.53534409,0,0,1.3408,372.20450594,1.3408,1.34],["tJPYUST",0.0063795,211007551.16514573,0.0067299,3466537.48018012,0.0001659,0.02751244,0.0061959,4830.56331802,0.0061959,0.00603],["tHIXUSD",0.00006016,440694310.3458158,0.00007703,8045050.13338815,-0.0000026,-0.03998155,0.00006243,91979.88315649,0.00006503,0.00006243],["tHIXUST",0.00006,411668171.7877522,0.00007531,6221172.34399013,-0.00000791,-0.11282271,0.0000622,5406575.15664601,0.00007011,0.00006],["tGALA:USD",0.011181,7882347.80439244,0.01123,8325218.65359067,0.000149,0.01349882,0.011187,41220.64080307,0.011187,0.010873],["tGALA:UST",0.011156,4977822.37099614,0.0112,1128899.90853538,0.000252,0.02333333,0.011052,58352.65820179,0.011052,0.0108],["tAPEUSD",0.40071,566921.89919478,0.40158,20032.51059373,-0.00007,-0.00018008,0.38865,10167.26146613,0.38872,0.3879],["tAPEUST",0.40038,495129.44675744,0.40087,54548.1775902,0.01369,0.03542961,0.40009,3712.13823011,0.40009,0.3835],["tB2MUSD",0.013839,716394.12371407,0.013899,221722.81288793,0.000492,0.03686222,0.013839,918.69145871,0.013922,0.013347],["tB2MUST",0.013625,2227568.87180056,0.013738,179046.77807311,0,0,0.013761,286.48702793,0.013761,0.013761],["tSTGUSD",0.16189,887582.55237108,0.16215,23704.44531691,-0.00095,-0.00596996,0.15818,243.95057068,0.16052,0.15818],["tSTGUST",0.16166,400190.77908358,0.1619,26118.62279409,-0.00198,-0.01245361,0.15701,3758.66846143,0.15997,0.15701],["tMXNT:USD",0.020001,913032.18066804,0.19,4770.031594,0,0,0.04621,51.726617,0.04621,0.04621],["tUST:MXNT",5.5,201000035,23,57.353192,-0.17,-0.02548726,6.5,7.503748,6.67,6.5],["tAPENFT:USD",4e-7,57478013054.666695,4.1e-7,55410886330.71331,0,0,4.1e-7,200000000.000001,4.1e-7,4.1e-7],["tSWEAT:USD",0.0016759,1808162.22897765,0.0016788,1581201.38848509,0.000054,0.03332511,0.0016744,288478.17304493,0.0016924,0.0016166],["tSWEAT:UST",0.0016755,3045494.63891578,0.0016783,1695933.28445192,0.000036,0.02199951,0.0016724,333156.71218534,0.0016816,0.0016179],["tAPTUSD",3.2454,47002.9702143,3.2475,44990.42564887,0.0702,0.02211511,3.2445,5865.20023567,3.2575,3.1523],["tAPTUST",3.2353,10699.15790404,3.6432,10325.02410454,-0.0231,-0.007192,3.1888,464.28664097,3.2119,3.1681],["tBTCTRY",312,11000003.155227,4040000,0.0014,2749980,3.05546543,3650000,0.00059668,3650000,900020],["tTRYUST",0.033,16701366.95091,18.999,4674.22210694,-0.0125,-0.2173913,0.045,2152.34587956,0.0575,0.0436],["tWBTBTC",0.9992,5.93071405,0.9997,1.10747112,0,0,0.9996,0.00009866,0.9996,0.9996],["tTESTDOT:TESTUSD",3.0326,227997.68047408,3.0428,231022.51999867,0.0999,0.03394611,3.0428,4549.68251838,3.0559,2.8969],["tTESTSOL:TESTUSD",190.66,221773.04130732,191.48,228402.43341376,5.14,0.02758399,191.48,7768.15500655,192.19,184.18],["tARBUSD",0.32017,689726.08484094,0.32066,489411.16297792,0.01001,0.03237596,0.31919,13257.02857618,0.31919,0.3061],["tARBUST",0.31953,421967.11792924,0.32009,370094.70258723,0.01224,0.03979323,0.31983,17365.19084479,0.31996,0.30461],["tTONUSD",2.2314,50881.74001173,2.2367,66461.13974089,0.067,0.03123252,2.2122,15277.77920939,2.2122,2.1327],["tTONUST",2.2262,116251.44332394,2.2525,74550.62906781,0.1005,0.0471676,2.2312,90500.75791501,2.2373,2.1231],["tTESTXAUT:TESTUSD",4238.8,9109.2467551,4249.2,9245.31101499,-1.4,-0.00032937,4249.2,4.70622524,4261.2,4238.2],["tTESTETH:TESTUSD",3984.4,1225.71744995,3991.1,1224.77312305,100.6,0.02585786,3991.1,1.58632205,4011.7,3860.9],["tTESTAVAX:TESTUSD",20.684,223112.75177688,20.766,226992.41348396,0.612,0.03036618,20.766,4567.20407937,20.884,19.865],["tTESTDOGE:TESTUSD",0.19735,22333125.28717013,0.1978,22391782.09638603,0.00944,0.0501168,0.1978,1500547.51327328,0.19838,0.18675],["tTESTXTZ:TESTUSD",0.60119,225170.88943682,0.60407,224344.36902296,0.01603,0.02726005,0.60407,4743.80925902,0.61881,0.57963],["tTESTALGO:TESTUSD",0.18655,223654.69509159,0.18717,225620.24897685,0.00749,0.04169682,0.18712,15371.19231964,0.18797,0.17739],["tTESTNEAR:TESTUSD",2.257,226531.73100229,2.3026,227548.02084033,0.1194,0.05509159,2.2867,4534.694302,2.314,2.1317],["tTESTFIL:TESTUSD",1.5279,227741.83243499,1.5346,230638.63019366,0.023,0.01521567,1.5346,4575.16720507,1.5387,1.4759],["tTESTADA:TESTUSD",0.65544,2272129.97741632,0.6576,1697722.10908621,0.02145,0.03371846,0.6576,6272.92909268,0.65995,0.62591],["tTESTLTC:TESTUSD",94.309,11022.13813909,94.514,11199.94552641,3.562,0.03916351,94.514,4490.64257046,94.821,90.798],["tTESTAPT:TESTUSD",4.7834,0,4.8049,0,-0.0086,-0.00178664,4.8049,28.76535954,4.8135,4.7957],["tTESTEOS:TESTUSD",0.3,443.8,0.7,1993,0,0,0.1,2,0.1,0.1],["tLDOUSD",0.91871,36743.29893891,0.92277,25454.32061915,0.04491,0.05122853,0.92157,87452.42100762,0.92209,0.85992],["tLDOUST",0.9178,242611.30564971,0.92373,21493.01001188,0.04558,0.05213314,0.91988,116450.17038686,0.92102,0.85802],["tBGBUSD",4.6887,7580,4.7469,8025.22990233,-0.0008,-0.0001762,4.5394,6.60102746,4.5402,4.5394],["tBGBUST",4.6819,255315.27797138,4.7391,6052.40556909,0,0,4.6054,2.43999861,4.6054,4.6054],["tSUIUSD",2.6303,26267.51375535,2.6321,43093.44693221,0.1351,0.05425703,2.6251,76525.35144666,2.65,2.4486],["tSUIUST",2.626,42477.73863112,2.6281,46206.13280414,0.1623,0.06546467,2.6415,53163.40043527,2.6418,2.4527],["tFLOKI:USD",0.00006774,155457085.31478244,0.00006796,119522905.11575855,0.00000125,0.01922781,0.00006626,370838601.95905787,0.00006693,0.00006449],["tFLOKI:UST",0.0000676,348313270.8787495,0.00006785,250282482.34499007,0.00000302,0.04727614,0.0000669,41036039.28227606,0.0000669,0.00006388],["tPEPE:USD",0.00000703,25979053698.399868,0.00000705,21389508155.148247,3.8e-7,0.05654762,0.0000071,895236766.7447885,0.0000071,0.00000672],["tPEPE:UST",0.00000702,24995079177.370796,0.00000703,19193127629.2502,4.2e-7,0.06296852,0.00000709,122326686.70935044,0.00000709,0.00000667],["tWHBT:USD",41.104,48.85829024,41.26,131.00379024,0.235,0.00573941,41.18,3.06782374,41.2,40.88],["tWHBT:UST",41.125,295.16631978,41.279,146.10331063,0.273,0.00666992,41.203,440.5449297,41.203,40.805],["tKAVA:USD",0.13501,376741.53039003,0.15027,22240.44303781,-0.00767,-0.05376043,0.135,1558.08690436,0.14365,0.135],["tKAVA:UST",0.14714,796207.33845357,0.14815,51057.69097812,0.0069,0.04873914,0.14847,1934.27644683,0.15151,0.11816],["tOPXUSD",0.44129,22563.5800024,0.44147,29782.22544541,0.01458,0.0341676,0.4413,24137.79969079,0.44531,0.41976],["tOPXUST",0.43836,346573.27763547,0.45925,19727.8382652,0.01072,0.02524016,0.43544,1471.41925625,0.44913,0.42413],["tSEIUSD",0.19803,245375.88271802,0.19928,49829.98587972,0.00062,0.00321327,0.19357,434798.91308056,0.19466,0.18944],["tSEIUST",0.19774,121946.94640718,0.19898,106499.90041746,-0.00054,-0.00280432,0.19202,5376.82085685,0.19256,0.18952],["tTIAUSD",1.047,3036.47290053,1.0526,7274.77188982,0.0453,0.04497617,1.0525,1759.85242625,1.0525,0.98237],["tTIAUST",1.0454,107573.89061747,1.0472,6074.30161285,0.0395,0.03923321,1.0463,15558.13907455,1.0486,0.9784],["tGOMINING:USD",0.41847,132627.50429068,0.42021,59967.48655184,0,0,0.41869,127.13941842,0.41869,0.41869],["tGOMINING:UST",0.41841,56730.59323799,0.42027,74590.35394464,0.0032,0.00767405,0.42019,134.56967834,0.42019,0.41699],["tBONK:USD",0.00001478,2510587274.513359,0.0000148,2563419314.7511473,5.9e-7,0.04149086,0.00001481,221094425.18814704,0.00001483,0.000014],["tBONK:UST",0.00001476,6667948497.473496,0.00001478,2007698402.3084683,7.5e-7,0.0533049,0.00001482,123322500.71165168,0.00001482,0.000014],["tALT2612:USD",98,460,100,3167.30280889,0,0,100,6.99999999,100,100],["tALT2612:UST",100,31.17786,105,1287.696,0,0,105,1.5,105,105],["tLIFIII:USD",0.012598,1022826.61010105,0.012599,301255.7,-0.000032,-0.00253365,0.012598,272829.85713011,0.01272,0.01253],["tLIFIII:UST",0.012531,260192.69907314,0.01261,512402.012444,-0.00012,-0.00948467,0.012532,289597.54811207,0.012699,0.012491],["tJUPUSD",0.35013,42487.30090939,0.35409,49807.01306309,0.00899,0.02626044,0.35133,32350.20361476,0.3533,0.33744],["tJUPUST",0.35008,51603.82228991,0.35282,70530.07582854,0.00938,0.02745982,0.35097,240.30542706,0.35097,0.33683],["tDYMUSD",0.11786,73317.4198765,0.11888,1213.498151,0.00046,0.00398579,0.11587,234.47459502,0.11587,0.11507],["tDYMUST",0.11855,16978.49489072,0.11904,7700.548948,-0.00059,-0.00507876,0.11558,254.9677247,0.11617,0.11445],["tWIFUSD",0.52854,68429.9832557,0.53434,17887.69517508,0.01519,0.02949343,0.53022,894.82650201,0.53427,0.50899],["tWIFUST",0.52765,102128.64437539,0.53362,15626.53060966,0.01015,0.01980024,0.52277,859.75932982,0.52277,0.50964],["tCELO:USD",0.25405,191422.62870914,0.25615,47502.49584856,0.01004,0.03922488,0.266,10485.349953,0.28591,0.24506],["tCELO:UST",0.24262,73248.69395003,0.27899,9678.07987564,-0.14517,-0.3708046,0.24633,1152.10683229,0.3915,0.24633],["tSTRK:USD",0.11294,164481.86656218,0.11369,173695.61676217,0.00375,0.03415612,0.11354,92052.05666542,0.11354,0.10808],["tSTRK:UST",0.11281,99610.70493668,0.11411,72803.68984453,0.00318,0.02895384,0.11301,49312.04379341,0.11301,0.10874],["tENAUSD",0.49527,4555.09829554,0.50212,1602.92178105,0.02858,0.0614069,0.494,3066.16098788,0.50705,0.44834],["tENAUST",0.49407,1661563.02645016,0.50333,28155.18847044,0.02586,0.05543408,0.49236,1793.74056849,0.50916,0.45305],["tTOKEN:USD",0.0086414,2775156.80221955,0.0086777,156109.81211829,0,0,0.0085315,122.49997224,0.0085315,0.0085315],["tTOKEN:UST",0.0086414,5028757.34389131,0.0086777,28626.69038884,-0.0000239,-0.00285571,0.0083453,1331.88056944,0.0083692,0.0083453],["tSPEC:USD",0.20161,54768.7569134,0.20349,11548.28845127,0.01107,0.05782793,0.2025,1926.80857343,0.2025,0.18951],["tSPEC:UST",0.20069,58607.2525818,0.20233,57802.18722958,0.00448,0.02333212,0.19649,1800.33442438,0.19725,0.19031],["tHILSV:USD",0,0,0,0,0,0,0,0.000001,0,0],["tATHUSD",0.028933,1214099.00398735,0.02897,1030898.22399879,0.000496,0.01741634,0.028975,220154.53199942,0.029216,0.027069],["tATHUST",0.028913,2015829.09984741,0.028931,1476387.30321533,0.000458,0.01609672,0.028911,753962.13872972,0.029241,0.027023],["tAIOZ:USD",0.20952,32030.92736196,0.20994,20310.508615,0.00527,0.02657322,0.20359,1732.54889055,0.20854,0.1965],["tZKXUSD",0.037027,488671.12505188,0.037445,6063.25882335,0.001567,0.04364903,0.037467,6052.27437496,0.037467,0.035761],["tZKXUST",0.037138,515068.45695907,0.037383,243829.69106874,0.000997,0.02762691,0.037085,499.63659471,0.037085,0.035852],["tAUSDT:USD",1,2318800.753623,1.0015,2372649.68770573,0,0,1.0019,0.05499846,1.0019,1.0019],["tAUSDT:UST",0.9996,2381209.81682437,1,2359870.45065717,0,0,1.0001,10.45542369,1.0001,1.0001],["tZROUSD",1.7248,4596.67454297,1.7259,13.79224497,0.0162,0.00951207,1.7193,1107.56116449,1.7348,1.6856],["tZROUST",1.7217,37776.5013399,1.7252,5958.79939736,0.0166,0.0097475,1.7196,182.73005888,1.7291,1.682],["tMEWUSD",0.0019421,4081809.82389864,0.0019513,6380753.5098439,0.000076,0.04062216,0.0019469,39082.17866038,0.0019469,0.0018597],["tMEWUST",0.0019396,6167467.19349054,0.0019487,9534587.45616742,0.0000737,0.03940755,0.0019439,5936.40356402,0.0019475,0.0018467],["tUXLINK:USD",0.012001,151955.72365318,0.056,82593.248548,0,0,0.048,1267.52115348,0.048,0.048],["tUXLINK:UST",0.0232,1633087.3471367,0.1999,174918.255451,0,0,0.023156,2972.42996373,0.023156,0.023156],["tPOLUSD",0.19405,825999.49794611,0.19451,503425.32059378,0.00534,0.02854852,0.19239,5166.4499165,0.19239,0.18705],["tPOLUST",0.19383,506712.46614012,0.19427,422689.44913453,0.0065,0.03471666,0.19373,945.14089665,0.19373,0.18081],["tJUSTICE:USD",0.00004703,88763701.65372448,0.00007078,75586926.58721511,0,0,0.00005231,175559.81348182,0.00005231,0.00004992],["tJUSTICE:UST",0.00002224,38637630.14298092,0.00011998,1352172.68752433,0,0,0.00005936,1435012.04332134,0.00005936,0.00004989],["tEIGEN:USD",1.1441,21630.32160391,1.1461,1099.85091677,0.0067,0.00588339,1.1455,810.00422267,1.1597,1.0921],["tEIGEN:UST",1.1425,62825.55290955,1.1445,4348.10288055,-0.0019,-0.00165837,1.1438,664.77985912,1.1601,1.0883],["tNYMUSD",0.04678,355175.22432231,0.04679,189587.03722878,0.001188,0.02604863,0.046795,3182.43538662,0.048521,0.045367],["tNYMUST",0.046555,429248.82015494,0.046695,403568.31425815,0.000636,0.01386709,0.0465,12786.83806633,0.049001,0.045456],["tALT11M250830:USD",100,0,100,0,0,0,100,0.000001,0,0],["tALT11M250830:UST",100,0,100,0,0,0,100,0.000001,0,0],["tCNHT:USD",0.06,332083.333,0.19,1039.71036373,-0.01,-0.06666667,0.14,69.93,0.15,0.14],["tEURQ:USD",1.1511,77573.78518561,1.167,50321.03463488,0,0,1.167,17.76037053,1.167,1.167],["tEURQ:UST",1.1607,195630.26829431,1.1695,154535.45214402,0,0,1.1604,21.24528612,1.1604,1.1604],["tUSDQ:USD",0.998,30244.50538108,1.0005,34928.04866857,-0.0001,-0.00009995,1.0004,22.75793643,1.0005,1.0004],["tUSDQ:UST",0.9967,391484.74695086,0.99895,140552.24376726,0.00064,0.00064253,0.9967,4415.13493466,0.99845,0.99],["tUSTBL:USD",1,100,1.081,507.069801,0,0,1.1,502.78066,1.1,1.1],["tUSTBL:UST",1.021,808.02217,1.0245,23284.408604,0,0,1.0245,5,1.0245,1.0245],["tALT11M251029:USD",1,0,1,0,0,0,1,0.000001,0,0],["tALT11M251029:UST",1.000450203,0,1.000450203,0,0,0,1.000450203,0.000001,0,0],["tTITAN1:USD",1.3,229.948,1.6,1700,0,0,1.35,100,1.35,1.35],["tTITAN1:GBP",0.75,60,1.08,114420,0,0,1.1,40,1.1,1.1],["tTITAN2:USD",2,310,2.1,112950,0,0,2.1,50,2.1,2.1],["tTITAN2:GBP",1.2969,8850,1.3969,223000,0,0,1.38,10,1.38,1.38],["tEURR:USD",1.1639,14615.15626686,1.1657,8732.937199,-0.0018,-0.00153846,1.1682,2009.94639038,1.171,1.1682],["tEURR:UST",1.1628,240304.8214557,1.1645,416225.543,0,0,1.1621,1149.6903211,1.1621,1.1621],["tUSDR:USD",0.9993,8031.57585597,1,44240.4480182,0.0007,0.00070049,1,149.22325686,1,0.9993],["tUSDR:UST",0.99765,43210.06660598,0.99864,104496.72077925,0.00015,0.00015038,0.99765,375.66580831,0.99864,0.9975],["tHTXDAO:USD",0.00000201,12664821227.822035,0.00000203,11734154163.350687,0,0,0.00000201,91417.91044776,0.00000201,0.00000201],["tHTXDAO:UST",0.00000201,12313803568.758196,0.00000203,13307168337.489143,0,0,0.00000199,1604429.62325442,0.00000199,0.00000199],["tJXXUSD",0.065408,24415.47833245,0.065978,16323.74573,0.0046,0.07142857,0.069,508.30423738,0.069,0.0644],["tJXXUST",0.065317,434287.89128248,0.065915,35526.36037221,-0.000329,-0.00505369,0.064772,10386.66158944,0.065149,0.06433],["tBTC:USDR",108370,107.31224423,108690,0.70447383,-730,-0.00679259,106740,0.00293166,107470,106740],["tSONIC:USD",0.18017,297585.16565993,0.18034,226397.761373,0.005,0.02920902,0.17618,25103.88604279,0.17829,0.17118],["tSONIC:UST",0.17987,228215.83619085,0.1803,15896.9087349,0.01065,0.06282445,0.18017,757.23851892,0.18017,0.16952],["tBTC:USDQ",108350,105.13425194,115910,0.02827354,560,0.00523413,107550,0.00079128,108100,106500],["tKAIA:USD",0.10624,114874.07031857,0.10673,11257.17907629,0.00147,0.01406429,0.10599,0.735,0.10599,0.10452],["tKAIA:UST",0.1061,1884785.22877982,0.10661,22591.318565,0.00168,0.01604738,0.10637,107.98206068,0.10637,0.10469],["tGUNUSD",0.020059,54034.60137873,0.02021,109743.33378267,0.000508,0.02586558,0.020148,2718.37126176,0.020209,0.019511],["tGUNUST",0.020111,2156208.5988223,0.020241,161230.49775663,0.000447,0.0227319,0.020111,1383.74688175,0.020111,0.019615],["tSTXUSD",0.45602,129451.16117264,0.45721,52626.35060707,0.00605,0.01450074,0.42327,95.09185963,0.42787,0.41533],["tSTXUST",0.45532,129749.84876831,0.45658,52480.22814497,-0.00759,-0.01792123,0.41593,24.81110719,0.42352,0.41593],["tBTC:EURQ",92213,4.12159934,93634,4.12514124,0,0,91067,0.00006986,91067,91067],["tBTC:EURR",92259,104.13693297,104000,0.00412,0,0,91493,0.00028061,91493,91493],["tEURQ:EUR",0.996,226387.82656907,1,193951.62012165,0,0,0.996,88.22783306,1,0.996],["tEURR:EUR",0.99601,238007.21582683,1,196242.52326511,0,0,0.99601,11.90479801,0.99601,0.99601],["tUSDF:USD",0.97611,1141.53914059,1.0001,53266.51820309,-0.01911,-0.01910809,0.98099,270.56416069,1.0001,0.98099],["tSHMUSD",0.051508,4841.79267428,0.051839,3809.5253933,0,0,0.051788,258.53591479,0.051788,0.051508],["tSHMUST",0.051332,13896.95915478,0.05159,26990.14902435,0,0,0.05159,219.41258744,0.05159,0.05159],["tUSDF:UST",0.9995,41443.96461263,1.0002,41733.74229864,0,0,1.0002,266.82479531,1.0002,0.999],["tBMN2:USD",53000,10.102,80000,7.32121,-1000,-0.01886792,52000,0.4,53000,52000],["tBMN2:BTC",0.8711640466775967,0,0.8711640466775967,0,0,0,0.8711640466775967,0.000001,0,0],["tMNTUSD",1.8713,178664.99913053,1.8756,76149.60196512,0.2526,0.15507398,1.8815,1699.55421744,1.8815,1.6289],["tMNTUST",1.8686,116988.82429504,1.8727,74774.2040225,0.2628,0.16240267,1.881,1882.33627642,1.881,1.5938],["tHYPE:USD",38.115,192.11661105,38.225,130.40546301,-1.046,-0.02732426,37.235,79.64461298,38.281,36.422],["tHYPE:UST",38.121,89.09016075,38.225,80.04732697,1.436,0.03907058,38.19,8.12761129,38.283,36.151],["tSKYUSD",0.059527,289192.26324796,0.059667,555666.14103579,0.000345,0.00585699,0.059249,691.33131826,0.065,0.058688],["tSKYUST",0.059439,293717.07016666,0.059573,402068.55695815,0.001452,0.024717,0.060197,361.47180882,0.07,0.057121],["tXPLUSD",0.42813,58446.03291116,0.42883,45896.77111451,0.01121,0.02759317,0.41747,3802.26512572,0.41747,0.40626],["tXPLUST",0.42724,188855.81208116,0.42816,162564.96732145,0.01609,0.03916653,0.4269,31776.8146547,0.4269,0.40739],["tBTCF0:USTF0",108230,12.41696798,108240,16.20493407,1120,0.01045947,108200,215.82070398,108520,106130],["tETHF0:USTF0",3982.9,203.8022794,3983,205.89738949,103.3,0.02662646,3982.9,3549.08056539,4001.6,3827.2],["tXAUTF0:USTF0",4251.7,1074.39224249,4251.8,864.01547475,-0.1,-0.00002352,4251.8,0.25233127,4251.9,4251.8],["tTESTBTCF0:TESTUSDTF0",108120,459.58522418,108290,471.89055818,1090,0.01016791,108290,1.36775248,108570,106200],["tEURF0:USTF0",1.1589,1050278.00780234,1.1655,3321242.4531834,0,0,1.165,10,1.165,1.165],["tGBPF0:USTF0",1.3302,1040279.0379358,1.3406,2066.14642093,0,0,1.3386,6,1.3386,1.3346],["tJPYF0:USTF0",0.006438,20004959.83224603,0.0069545,124313.75368467,0,0,0.00668,300,0.00668,0.00668],["tEUROPE50IXF0:USTF0",5472.8,0.03297357,5490,0.0071547,0,0,5700,0.000001,5700,5700],["tLTCF0:USTF0",94.245,1843.59351823,94.246,1442.07411322,3.455,0.03805485,94.245,4188.64551169,94.607,90.579],["tDOTF0:USTF0",3.0279,77780.97605289,3.0297,60113.29114023,0.1021,0.03485474,3.0314,107087.99014313,3.0432,2.8894],["tXAGF0:USTF0",52.303,220.71889343,55.1,422.33533191,-0.48,-0.00907321,52.423,0.5,52.903,52.423],["tIOTF0:USTF0",0.14537,2855628.82187028,0.1456,1862383.24964358,0.00174,0.01225697,0.1437,59918.92700958,0.1437,0.141],["tLINKF0:USTF0",17.443,17308.43495517,17.453,11865.39582317,0.612,0.03638093,17.434,19664.14249933,17.503,16.549],["tUNIF0:USTF0",6.2915,36210.60500509,6.2972,29853.42959446,0.3363,0.05626851,6.313,17519.08805018,6.313,5.9624],["tETHF0:BTCF0",0.036762,185.68199362,0.036763,119.46653702,0.000594,0.0164229,0.036763,296.5237951,0.03688,0.03603],["tADAF0:USTF0",0.65496,437674.09514965,0.65505,396485.82537368,0.02146,0.03387316,0.655,916001.92742648,0.6569,0.62231],["tXLMF0:USTF0",0.32121,523915.11281197,0.32154,496748.87229147,0.00466,0.01472587,0.32111,373444.0588713,0.32244,0.30743],["tLTCF0:BTCF0",0.00087031,2603.00002,0.000871,3020.08892,0.00001067,0.01240135,0.00087106,82.2,0.00087106,0.00085851],["tXAUTF0:BTCF0",0.0405,13.63291047,0.042,5.73442704,0,0,0.0445,0.04,0.0445,0.0445],["tDOGEF0:USTF0",0.19718,1296314.55343724,0.19724,1520653.36610765,0.00954,0.05085017,0.19715,2014179.87223711,0.19792,0.18596],["tSOLF0:USTF0",190.85,2800.59629978,190.92,2881.07003284,4.98,0.02683479,190.56,4847.65541727,191.59,183.42],["tSUSHIF0:USTF0",0.53853,156824.15696868,0.53911,130439.27701798,0.02546,0.04945898,0.54023,40657.71620193,0.54023,0.50864],["tFILF0:USTF0",1.5271,124400.57663113,1.5291,102021.55077279,0.0045,0.00299142,1.5088,36063.44774152,1.5243,1.4739],["tAVAXF0:USTF0",20.699,8641.42523572,20.714,10745.05547969,0.704,0.03513149,20.743,72938.21934237,20.774,19.763],["tXRPF0:USTF0",2.4086,273311.28412997,2.4095,266094.78002224,0.0382,0.01613448,2.4058,204234.33697378,2.4132,2.319],["tXRPF0:BTCF0",0.00002225,177458.92065978,0.00002228,175057.07371727,0,0,0.00002207,2000,0.00002207,0.00002207],["tALGF0:USTF0",0.18678,1429631.57139427,0.18679,1449984.19380066,0.00505,0.02821071,0.18406,535269.577454,0.18449,0.17823],["tGERMANY40IXF0:USTF0",24200,0.03856894,25965,0.00612777,0,0,24274,0.00157159,24274,24274],["tAAVEF0:USTF0",225.3,860.56262289,225.39,946.9307433,14.62,0.06959254,224.7,1181.32428679,225.88,208.78],["tEGLDF0:USTF0",9.921,25699.784478,9.9348,23540.624237,0.199,0.02050595,9.9035,6078.0749761,9.9035,9.476],["tAXSF0:USTF0",1.6103,97436.59719182,1.6146,99696.6712582,0.0675,0.04361874,1.615,158347.68766486,1.6154,1.5397],["tCOMPF0:USTF0",36.361,5085.78538566,36.408,3676.41214083,2.11,0.06163283,36.345,28695.47692194,37.312,33.885],["tXTZF0:USTF0",0.60134,358368.7257215,0.60182,444319.79365164,0.00586,0.01003459,0.58984,19675.5037759,0.58984,0.58226],["tTRXF0:USTF0",0.3198,757623.66946567,0.32003,827296.9750186,0.00699,0.02233584,0.31994,431397.9830145,0.32031,0.31231],["tATOF0:USTF0",3.2623,82035.07989386,3.2651,83166.7463231,0.0809,0.02537641,3.2689,5315.5373794,3.2689,3.1476],["tSHIBF0:USTF0",0.00001014,36092429745.720146,0.00001016,36391443126.60827,3.5e-7,0.03567788,0.00001016,3150436333.052598,0.00001016,0.00000979],["tNEOF0:USTF0",5.1838,41732.20655976,5.1943,32471.33939904,0.2041,0.0410474,5.1764,2445.01878413,5.1764,4.9723],["tZECF0:USTF0",222.52,21068.81625969,224.02,822.29274668,3.01,0.01369801,222.75,368.52417486,225.52,212.48],["tCRVF0:USTF0",0.56576,197253.39082584,0.56747,224164.3908436,0.03764,0.0715739,0.56353,150150.28407615,0.56353,0.51679],["tNEARF0:USTF0",2.2632,90934.62667314,2.2674,86474.35946635,0.1444,0.06766001,2.2786,49035.0044887,2.2786,2.1342],["tICPF0:USTF0",3.1157,54260.37544762,3.1198,54786.36854141,0.0759,0.02487954,3.1266,12647.00375274,3.1288,3.0027],["tGALAF0:USTF0",0.011204,6835505.83292239,0.011205,7961622.04553698,0.000361,0.03334257,0.011188,10011576.75254484,0.011244,0.01078],["tAPEF0:USTF0",0.40045,382000.99680951,0.40088,374198.44794721,0.01244,0.03205194,0.40056,240680.73307316,0.40056,0.38483],["tETCF0:USTF0",15.798,9842.25928549,15.805,7633.86918513,0.299,0.01933398,15.764,5252.92822634,15.845,15.238],["tJASMYF0:USTF0",0.010121,16232454.42258534,0.010139,16367560.4556672,0,0,0.0098486,14000,0.0098486,0.0098486],["tSTGF0:USTF0",0.16537,966084.07905226,0.16538,419390.92790176,0,0,0.16538,3160,0.16538,0.16538],["tSANDF0:USTF0",0.21101,848306.92382864,0.21137,751234.54201761,0.00411,0.02019259,0.20765,18067.1518458,0.20774,0.20242],["tAPTF0:USTF0",3.2362,107390.51952784,3.2427,84193.54230583,0.0142,0.00450622,3.1654,2886.1562725,3.2056,3.1512],["tTESTDOTF0:TESTUSDTF0",3.0263,224986.0515562,3.0315,222827.32448241,0.0988,0.0336604,3.034,4746.12461162,3.0439,2.8882],["tTESTSOLF0:TESTUSDTF0",190.37,190075.41901311,191.35,226808.98382086,4.79,0.02572365,191,4760.14997229,192.05,183.63],["tUKOILF0:USTF0",63,10102.3374976,67,118.47294599,-3.5,-0.05263158,63,1.19127596,66.5,63],["tXPTF0:USTF0",1131.6,1.84651113,1745.3,4.00859452,0,0,1538.8,0.45333957,1538.8,1491],["tXPDF0:USTF0",1035.8,1.57923121,1466,2.12010851,0,0,1466,0.02110299,1466,1466],["tARBF0:USTF0",0.31991,559954.247104,0.32006,482552.783852,0.01307,0.04267476,0.31934,299080.93429607,0.32069,0.30434],["tFRANCE40IXF0:USTF0",5754.3,0.00521349,8490,0.10147535,0,0,7865.5,0.0006,7865.5,7865.5],["tSPAIN35IXF0:USTF0",10953,0.00273897,19517,0.00075857,0,0,19478,0.00009,19478,19478],["tUK100IXF0:USTF0",6566.2,0.00564529,11900,0.00195625,0,0,9110.6,0.00119426,9110.6,9110.6],["tJAPAN225IXF0:USTF0",46000,0.0210001,51000,0.01037049,0,0,51000,0.0014998,51000,51000],["tHONGKONG50IXF0:USTF0",26836,0.01439319,33413,0.03044893,0,0,30998,0.00214007,30998,25172],["tAUSTRALIA200IXF0:USTF0",6500,1.00482842,9383,1.00129725,0,0,8692.1,0.000029,8692.1,8692.1],["tTESTXAUTF0:TESTUSDTF0",4248.4,1230.82203494,4255.1,1206.2778287,28.4,0.00671919,4255.1,3305.77815489,4256,4222.5],["tTESTETHF0:TESTUSDTF0",3980.9,1252.66876982,3985.2,727.21555046,101.7,0.02618772,3985.2,3329.79896343,4001.7,3836.3],["tTESTAVAXF0:TESTUSDTF0",20.673,231498.58274383,20.749,229051.36563498,0.632,0.03141622,20.749,4744.53696205,20.864,19.805],["tTESTDOGEF0:TESTUSDTF0",0.19677,22684358.5037088,0.19726,22273209.31813531,0.00939,0.04998137,0.19726,23067.48611472,0.19824,0.18651],["tTESTXTZF0:TESTUSDTF0",0.60106,225699.94951679,0.60224,223351.00291629,0.01541,0.02625974,0.60224,4737.2125114,0.60549,0.57778],["tTESTALGOF0:TESTUSDTF0",0.1,300000,0.185,1000000,0,0,1.9,10000,1.9,1.9],["tTESTNEARF0:TESTUSDTF0",2.2611,223421.95286613,2.269,226335.0164105,0.1133,0.05255833,2.269,15075.84315413,2.286,2.1164],["tTESTFILF0:TESTUSDTF0",1.5227,112191.65083775,1.5332,114649.43699039,0.0244,0.01617179,1.5332,3345.37762961,1.5361,1.4751],["tTESTADAF0:TESTUSDTF0",0.65399,2229192.92118827,0.6563,2264046.52991215,0.02215,0.03492865,0.6563,4700.7360019,0.65857,0.62322],["tTESTLTCF0:TESTUSDTF0",94.13,226630.63363976,94.356,221809.46535569,3.512,0.03865968,94.356,4969.78375041,94.716,90.534],["tTESTAPTF0:TESTUSDTF0",3.2268,113437.58986724,3.246,111113.22817634,0.0744,0.02345819,3.246,4701.05025358,3.2637,3.137],["tTRYF0:USTF0",0.02,1864.39451861,0.031874,470.60299931,0,0,0.0241,340,0.0241,0.0241],["tBNBF0:USTF0",1118.3,190.68549099,1119.9,239.14626854,28.5,0.02617801,1117.2,76.25559004,1119.4,1069.9],["tEVIVF0:USTF0",75.6,852.08950851,75.619,843.47800618,-0.6,-0.00782269,76.1,0.4,77.05,76.1],["tBVIVF0:USTF0",49.574,1309.52316328,50.073,1279.57388658,0,0,53.451,222,53.451,53.451],["tTONF0:USTF0",2.2277,73097.66052427,2.2294,75953.33903285,0.0858,0.03994413,2.2338,14905.87956468,2.2338,2.1193],["tPOLF0:USTF0",0.19419,799777.98964627,0.1944,799186.32327578,0.00616,0.03273636,0.19433,124110.85736062,0.19433,0.188],["tPEPEF0:USTF0",0.00000702,18170789583.028816,0.00000704,17832336759.89124,3.6e-7,0.05341246,0.0000071,2360114651.910454,0.0000071,0.00000667],["tCHZF0:USTF0",0.033231,3018238.34399327,0.033291,3021544.38095599,0,0,0.03319,903.88671287,0.03319,0.03319],["tSUIF0:USTF0",2.6256,103506.98745741,2.6291,104241.93827421,0.1586,0.06391038,2.6402,125464.04929608,2.6538,2.4483],["fUSD",0.0003418,0.0003418,30,55078800.27021477,0.00019285205479452054,2,115723.3888612,0.0000176,0.0543,0.0003416,152337140.174065,0.0005,0.00006951,null,null,3058854.88997737],["fGBP",0,0.000055,120,15757.13192502,0,30,8514633.84672367,0,0,0,5036.57999999,0,0,null,null,8505426.59108675],["fEUR",0.00015506575342465754,0.00012,120,302999.91279996,0.0001254735,2,65255836.19786011,-0.000123,-0.9248,0.00001,313257.86544714,0.00013879,0.00001,null,null,65175491.48800051],["fBTC",0.000010608219178082192,2e-7,120,25.76521534,1e-7,2,86.56053135,-1e-7,-0.001,1e-7,689.17961283,0.0000023,1e-8,null,null,1374.84855202],["fLTC",0.0009741479452054795,0,0,0,0.000016,2,1193.80297086,0.0000215,0.215,0.0000246,1856.53208251,0.00002564,5e-7,null,null,1956.18099504],["fETH",0.00009723287671232877,0.0000255,2,4136.77857769,0.00005713,2,1150.3460877,-0.000012,-0.12,0.000058,3083.10419151,0.000071,2e-7,null,null,2885.42927904],["fETC",0.0004308,0,0,0,0.00005,2,4294.39623472,0,0,0.000399,306.62714473,0.000399,0.00005,null,null,1626.56136392],["fZEC",0.0002807342465753425,0.000145,2,4.52841357,0.00025,2,2823.40596294,-0.00024226,-0.8727,0.00003533,496.07315541,0.00028036,0.0000135,null,null,638.3076753],["fXMR",0.00010960273972602739,0.000029887671,120,44.1166493,0.0000081999,3,12151.95339579,-0.0001875,-0.9581,0.0000082,2028.09710139,0.00019577,0.0000011,null,null,0],["fDSH",0.0002509315068493151,0,0,0,0.0002509315068493151,120,3031.71379,0.00002099,0.0913,0.00025099,3.42547648,0.00023,0.00023,null,null,224.70067172],["fJPY",0,0,0,0,0,0,0,0,0,0,206726.429,0,0,null,null,1819514.89368798],["fXRP",0.00030855616438356165,1e-10,30,10000,0.0000189436,2,77633.15475685,0.00000902,0.0902,0.000023,157115.50045049,0.00003425,2e-7,null,null,406619.21305267],["fIOT",0.0002541780821917808,0,0,0,0.0004,120,723275.17044653,0.00019357,1.0057,0.00038605,803136.26757205,0.00038605,0.00000792,null,null,0],["fNEO",0.000012021917808219178,0,0,0,0.0000020219,2,4788.83539795,-0.00000187,-0.0187,0.00001261,1626.61997105,0.00001516,8.4e-7,null,null,1886.77300782],["fZRX",0.0018365616438356163,0,0,0,0.0018365616438356163,2,135232.88076227,0.00002416,0.0131,0.00186344,40516.84768295,0.00186385,0.0014,null,null,30554.26504112],["fTRX",0.0000593972602739726,0,0,0,5e-7,2,3917166.69757975,-0.00005442,-0.5442,5e-7,413486.26882260997,0.00005978,5e-7,null,null,246460.54615864],["fDAI",7.945205479452054e-8,0.00003,120,11198.9759767,5e-8,28,43708.99143297,0,0,8e-8,295,8e-8,8e-8,null,null,39404.24513468],["fXLM",0.00003545205479452055,0,0,0,0.0000254521,2,503534.68922372,0.00002075,0.2075,0.00002141,338412.19283392,0.00003435,6.6e-7,null,null,267589.84589918],["fUST",0.0003415917808219178,0.0003415917808219178,60,29295438.00911259,0.00024514,2,28774.662872,-0.0000618,-0.2053,0.00023918,74186995.3812184,0.00040724,9.3e-7,null,null,0],["fATO",0.002070504109589041,0,0,0,0.0013319,120,19870.26988471,0.0000333,0.0256,0.0013333,101.15840856,0.0013,0.0013,null,null,1211.40382864],["fLEO",0,0,0,0,0,30,319355.00136405,0,0,0,632.48962694,0,0,null,null,254103.19949293],["fALG",0.00034708767123287673,0,0,0,0.00018999999999999998,5,372017.73532809,-0.00126,-0.869,0.00019,9156.1174646,0.00145,0.00032712,null,null,11673.43418118],["fXAUT",0.000029172602739726027,0.000011,2,12.27916543,0.0000191726,2,966.63796644,-0.00000978,-0.0978,0.00001917,743.27617492,0.00002917,0.00001898,null,null,824.45615894],["fTESTUSD",0.00043355616438356165,0.0000011911,120,1000,0.0001772,2,116206203.66219059,0.0000061,0.014,0.00044157,4872129.58360111,0.00044157,0.0004274,null,null,0],["fTESTUSDT",0.0001,0,0,0,0.0001,30,19910880.5463603,0,0,0.00009988,4972.27946144,0.00009988,0.00009988,null,null,15732.13432521],["fTESTBTC",0.007499698630136986,1e-7,120,0.005,0.007519819,2,104.36633098,0,0,0.0074997,65767.27951846,0.0074997,0.001,null,null,0],["fDOT",0.00044093150684931505,0.00015,2,9782.08244175,0.000425556164,7,11984.5792289,-0.000255,-0.85,0.000045,1266.03451355,0.00042556,0.000045,null,null,4761.56375374],["fADA",0.000024542465753424658,0,0,0,9.999e-7,2,511698.63740576,0,0,0.000001,33104.04008969,0.000001,1e-8,null,null,319239.25178376],["fLINK",0.000006350684931506849,0,0,0,0.000006350684931506849,3,5390.90920592,3.2e-7,0.0032,0.00000632,450.78468848,0.00000632,0.000006,null,null,3059.948825],["fCOMP",0.0025114109589041097,0,0,0,0.000007,2,1277.50266044,0.001993,19.93,0.002,10.00150868,0.000007,0.000007,null,null,195.62109304],["fEGLD",0,0,0,0,0,2,2687.68338136,0,0,0.00189871,76.30265428,0.00189871,0.00189871,null,null,15.30805628],["fUNI",0.0004169095890410959,0,0,0,0.0003818057489917197,120,17763.23151697,0,0,0.00041676,273.4290565,0.00041676,0.00041655,null,null,10924.38177173],["fAVAX",0.0004863917808219178,0,0,0,0.0000404,2,46756.86003827,0,0,0.0000404,760.57755934,0.0000404,0.000031,null,null,1069.5866648],["fFIL",0.00037781643835616436,0,0,0,0.00007464528,2,41282.95724549,-0.00074693,-0.9088,0.00007498,2379.50196077,0.00082191,0.00007498,null,null,827.64176152],["fBCHN",0.0022126986301369863,0.00005,14,49.02,0.0003159169,2,274.94175493,0.00002209,0.2209,0.00002299,149.3943482,0.00045423,9e-7,null,null,95.26406003],["fSUSHI",0.002323008219178082,0,0,0,0.001998,14,208530.38159775,-0.0003,-0.1304,0.002,3253.84285727,0.0023,0.0002,null,null,7973.42944611],["fSOL",0.00019315616438356163,0.000088,120,190.99395639,0.0000988,2,2627.74780598,-2e-7,-0.002,0.0000987,3028.58297427,0.0000996,0.00002,null,null,1466.59186972],["fDOGE",0.000008747945205479452,0,0,0,0.000008747945205479452,2,2343321.95800811,0.00000209,0.0209,0.0000125,1243295.14908193,0.0000125,9e-7,null,null,33242.36500696],["fSHIB",0.000018435616438356163,0,0,0,0.000015,10,977050118.0019802,0,0,0.00001844,132156178.2353661,0.00001844,0.000015,null,null,330803544.5450074],["fAPE",0.00095,0,0,0,0.001,120,46489.82504025,0,0,0.001,1549.31923489,0.001,0.001,null,null,0],["fAPT",0.001667786301369863,0.00033,30,15000,0.0016,2,9568.35566317,-0.00007162,-0.0428,0.0016,128.02818526,0.00167162,0.00167162,null,null,1251.472399],["fTESTDOT",0,0,0,0,0.0001,30,9999967984.922132,0,0,0.0001,1000,0.0001,0.0001,null,null,0],["fTESTXAUT",0,0,0,0,0.0001,30,100013075.96384364,0,0,0.0001,8.07210504,0.0001,0.0001,null,null,0],["fTESTETH",0.0001,0,0,0,0.0001,30,9962696995.985891,0,0,0.0001,150,0.0001,0.0001,null,null,0],["fTESTAVAX",0,0,0,0,0.0001,30,9999999579.999998,0,0,0.0001,20,0.0001,0.0001,null,null,0],["fTESTDOGE",0,0,0,0,0,2,10000028917.535568,0,0,0.0001,15000,0.0001,0.0001,null,null,177],["fTESTXTZ",0,0,0,0,0.0001,30,9999362617.4811,0,0,0.0001,296.10851335,0.0001,0.0001,null,null,0],["fTESTALGO",0,0,0,0,0.0001,30,9999969999.999998,0,0,0.0001,20000,0.0001,0.0001,null,null,0],["fTESTNEAR",0.0001,0,0,0,0.0001,30,9999960322.503254,0,0,0.0001,1042.97099527,0.0001,0.0001,null,null,0],["fTESTFIL",0,0,0,0,0.0001,30,9999994267.696302,0,0,0.0001,176.24162206,0.0001,0.0001,null,null,0],["fTESTADA",0,0,0,0,0.0001,30,9999781278.897017,0,0,0.0001,1805.64082192,0.0001,0.0001,null,null,0],["fTESTLTC",0,0,0,0,0.0001,30,9999999058.883957,0,0,0.0001,9.44762585,0.0001,0.0001,null,null,0],["fTESTAPT",0,0,0,0,0.0001,30,9999999799.999998,0,0,0.0001,200,0.0001,0.0001,null,null,0],["fSUI",0.0002364191780821918,0,0,0,0.000229,2,31597.42385836,0.000021,0.101,0.000229,4293.31591904,0.000208,0.00020799,null,null,9562.85086969]]' + recorded_at: Sun, 19 Oct 2025 14:43:55 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr_cassettes/cbrf.yml b/spec/vcr_cassettes/cbrf.yml deleted file mode 100644 index 8b24f520..00000000 --- a/spec/vcr_cassettes/cbrf.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: http://www.cbr.ru/scripts/XML_daily.asp?date_req=11/03/2018 - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - response: - status: - code: 200 - message: OK - headers: - Server: - - nginx/1.12.2 - Date: - - Thu, 26 Jul 2018 09:02:06 GMT - Content-Type: - - application/xml; charset=windows-1251 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Vary: - - Accept-Encoding - Cache-Control: - - no-cache - Pragma: - - no-cache - Expires: - - "-1" - X-Aspnet-Version: - - 4.0.30319 - X-Powered-By: - - ASP.NET - body: - encoding: ASCII-8BIT - string: !binary |- - PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+PFZhbEN1cnMgRGF0ZT0iMDguMDMuMjAxOCIgbmFtZT0iRm9yZWlnbiBDdXJyZW5jeSBNYXJrZXQiPjxWYWx1dGUgSUQ9IlIwMTAxMCI+PE51bUNvZGU+MDM2PC9OdW1Db2RlPjxDaGFyQ29kZT5BVUQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wOLx8vDg6+jp8ero6SDk7uvr4PA8L05hbWU+PFZhbHVlPjQ0LDM2MTc8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTAyMEEiPjxOdW1Db2RlPjk0NDwvTnVtQ29kZT48Q2hhckNvZGU+QVpOPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPsDn5fDh4Onk5uDt8ero6SDs4O3g8jwvTmFtZT48VmFsdWU+MzMsMzYzMzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDM1Ij48TnVtQ29kZT44MjY8L051bUNvZGU+PENoYXJDb2RlPkdCUDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7U8+3yIPHy5fDr6O3j7uIg0e7l5Ojt5e3t7uPuIOru8O7r5eLx8uLgPC9OYW1lPjxWYWx1ZT43OCw4MDAyPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEwNjAiPjxOdW1Db2RlPjA1MTwvTnVtQ29kZT48Q2hhckNvZGU+QU1EPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+wPDs/+3x6uj1IOTw4Ozu4jwvTmFtZT48VmFsdWU+MTEsNzk2NzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDkwQiI+PE51bUNvZGU+OTMzPC9OdW1Db2RlPjxDaGFyQ29kZT5CWU48L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+weXr7vDz8fHq6Okg8PPh6/w8L05hbWU+PFZhbHVlPjI5LDAyNDY8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTEwMCI+PE51bUNvZGU+OTc1PC9OdW1Db2RlPjxDaGFyQ29kZT5CR048L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+we7r4+Dw8ero6SDr5eI8L05hbWU+PFZhbHVlPjM2LDAzNjc8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTExNSI+PE51bUNvZGU+OTg2PC9OdW1Db2RlPjxDaGFyQ29kZT5CUkw8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wfDg5+jr/PHq6Okg8OXg6zwvTmFtZT48VmFsdWU+MTcsNjk3ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMTM1Ij48TnVtQ29kZT4zNDg8L051bUNvZGU+PENoYXJDb2RlPkhVRjwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPsLl7ePl8PHq6PUg9O7w6O3y7uI8L05hbWU+PFZhbHVlPjIyLDUzMzk8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIwMCI+PE51bUNvZGU+MzQ0PC9OdW1Db2RlPjxDaGFyQ29kZT5IS0Q8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPsPu7eru7ePx6uj1IOTu6+vg8O7iPC9OYW1lPjxWYWx1ZT43Miw1MDIyPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyMTUiPjxOdW1Db2RlPjIwODwvTnVtQ29kZT48Q2hhckNvZGU+REtLPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7E4PLx6uj1IOrw7u08L05hbWU+PFZhbHVlPjk0LDYxMDI8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIzNSI+PE51bUNvZGU+ODQwPC9OdW1Db2RlPjxDaGFyQ29kZT5VU0Q8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+xO7r6+DwINHYwDwvTmFtZT48VmFsdWU+NTYsODAxMTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMjM5Ij48TnVtQ29kZT45Nzg8L051bUNvZGU+PENoYXJDb2RlPkVVUjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7F4vDuPC9OYW1lPjxWYWx1ZT43MCw1Mjk5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyNzAiPjxOdW1Db2RlPjM1NjwvTnVtQ29kZT48Q2hhckNvZGU+SU5SPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yO3k6Onx6uj1IPDz7+jpPC9OYW1lPjxWYWx1ZT44Nyw0MjMzPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzMzUiPjxOdW1Db2RlPjM5ODwvTnVtQ29kZT48Q2hhckNvZGU+S1pUPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yuDn4PXx8uDt8ero9SDy5e3j5TwvTmFtZT48VmFsdWU+MTcsNzUxNTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzUwIj48TnVtQ29kZT4xMjQ8L051bUNvZGU+PENoYXJDb2RlPkNBRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7K4O3g5PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40Myw5Mzk5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzNzAiPjxOdW1Db2RlPjQxNzwvTnVtQ29kZT48Q2hhckNvZGU+S0dTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yujw4+jn8ero9SDx7uzu4jwvTmFtZT48VmFsdWU+ODMsNTk4NjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzc1Ij48TnVtQ29kZT4xNTY8L051bUNvZGU+PENoYXJDb2RlPkNOWTwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+yujy4Onx6uj1IP7g7eXpPC9OYW1lPjxWYWx1ZT44OSw4MzM5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE1MDAiPjxOdW1Db2RlPjQ5ODwvTnVtQ29kZT48Q2hhckNvZGU+TURMPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7M7uvk4OLx6uj1IOvl5eI8L05hbWU+PFZhbHVlPjM0LDExNDg8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTUzNSI+PE51bUNvZGU+NTc4PC9OdW1Db2RlPjxDaGFyQ29kZT5OT0s8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPs3u8OLl5vHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NzIsOTA4OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNTY1Ij48TnVtQ29kZT45ODU8L051bUNvZGU+PENoYXJDb2RlPlBMTjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7P7uv88ero6SDn6+7y++k8L05hbWU+PFZhbHVlPjE2LDgyODA8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4NUYiPjxOdW1Db2RlPjk0NjwvTnVtQ29kZT48Q2hhckNvZGU+Uk9OPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPtDz7Pvt8ero6SDr5ek8L05hbWU+PFZhbHVlPjE1LDEyMjA8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4OSI+PE51bUNvZGU+OTYwPC9OdW1Db2RlPjxDaGFyQ29kZT5YRFI8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0cTQICjx7+X26ODr/O375SDv8ODi4CDn4Ojs8fLi7uLg7ej/KTwvTmFtZT48VmFsdWU+ODIsNTk1MDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNjI1Ij48TnVtQ29kZT43MDI8L051bUNvZGU+PENoYXJDb2RlPlNHRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7R6O3j4O/z8PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40MywxODE2PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE2NzAiPjxOdW1Db2RlPjk3MjwvTnVtQ29kZT48Q2hhckNvZGU+VEpTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7S4OTm6Orx6uj1IPHu7O7t6DwvTmFtZT48VmFsdWU+NjQsMzQ3MTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzAwSiI+PE51bUNvZGU+OTQ5PC9OdW1Db2RlPjxDaGFyQ29kZT5UUlk8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0vPw5fbq4P8g6+jw4DwvTmFtZT48VmFsdWU+MTQsOTU1OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzEwQSI+PE51bUNvZGU+OTM0PC9OdW1Db2RlPjxDaGFyQ29kZT5UTVQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+ze7i++kg8vPw6uzl7fHq6Okg7ODt4PI8L05hbWU+PFZhbHVlPjE2LDI1MjE8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTcxNyI+PE51bUNvZGU+ODYwPC9OdW1Db2RlPjxDaGFyQ29kZT5VWlM8L0NoYXJDb2RlPjxOb21pbmFsPjEwMDAwPC9Ob21pbmFsPjxOYW1lPtPn4eXq8ero9SDx8+zu4jwvTmFtZT48VmFsdWU+NjksNTc5ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzIwIj48TnVtQ29kZT45ODA8L051bUNvZGU+PENoYXJDb2RlPlVBSDwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+0+rw4Ojt8ero9SDj8Oji5e08L05hbWU+PFZhbHVlPjIxLDU5NzQ8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTc2MCI+PE51bUNvZGU+MjAzPC9OdW1Db2RlPjxDaGFyQ29kZT5DWks8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPtfl+PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+MjcsNzUzOTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzcwIj48TnVtQ29kZT43NTI8L051bUNvZGU+PENoYXJDb2RlPlNFSzwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+2OLl5PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NjksMDUzMjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzc1Ij48TnVtQ29kZT43NTY8L051bUNvZGU+PENoYXJDb2RlPkNIRjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7Y4uXp9uDw8ero6SD08ODt6jwvTmFtZT48VmFsdWU+NjAsNDU4OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODEwIj48TnVtQ29kZT43MTA8L051bUNvZGU+PENoYXJDb2RlPlpBUjwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+3ubt7uD08Ojq4O3x6uj1IPD97eTu4jwvTmFtZT48VmFsdWU+NDgsMDgxMTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODE1Ij48TnVtQ29kZT40MTA8L051bUNvZGU+PENoYXJDb2RlPktSVzwvQ2hhckNvZGU+PE5vbWluYWw+MTAwMDwvTm9taW5hbD48TmFtZT7C7u0g0OXx7/Ph6+jq6CDK7vDl/zwvTmFtZT48VmFsdWU+NTMsMjI1MjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODIwIj48TnVtQ29kZT4zOTI8L051bUNvZGU+PENoYXJDb2RlPkpQWTwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPt/v7u3x6uj1IOjl7TwvTmFtZT48VmFsdWU+NTMsNjg5ODwvVmFsdWU+PC9WYWx1dGU+PC9WYWxDdXJzPg== - http_version: - recorded_at: Mon, 12 Mar 2018 21:00:00 GMT -- request: - method: get - uri: http://www.cbr.ru/scripts/XML_daily.asp?date_req=12/03/2018 - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - response: - status: - code: 200 - message: OK - headers: - Server: - - nginx/1.12.2 - Date: - - Thu, 26 Jul 2018 09:02:06 GMT - Content-Type: - - application/xml; charset=windows-1251 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Vary: - - Accept-Encoding - Cache-Control: - - no-cache - Pragma: - - no-cache - Expires: - - "-1" - X-Aspnet-Version: - - 4.0.30319 - X-Powered-By: - - ASP.NET - body: - encoding: ASCII-8BIT - string: !binary |- - PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+PFZhbEN1cnMgRGF0ZT0iMDguMDMuMjAxOCIgbmFtZT0iRm9yZWlnbiBDdXJyZW5jeSBNYXJrZXQiPjxWYWx1dGUgSUQ9IlIwMTAxMCI+PE51bUNvZGU+MDM2PC9OdW1Db2RlPjxDaGFyQ29kZT5BVUQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wOLx8vDg6+jp8ero6SDk7uvr4PA8L05hbWU+PFZhbHVlPjQ0LDM2MTc8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTAyMEEiPjxOdW1Db2RlPjk0NDwvTnVtQ29kZT48Q2hhckNvZGU+QVpOPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPsDn5fDh4Onk5uDt8ero6SDs4O3g8jwvTmFtZT48VmFsdWU+MzMsMzYzMzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDM1Ij48TnVtQ29kZT44MjY8L051bUNvZGU+PENoYXJDb2RlPkdCUDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7U8+3yIPHy5fDr6O3j7uIg0e7l5Ojt5e3t7uPuIOru8O7r5eLx8uLgPC9OYW1lPjxWYWx1ZT43OCw4MDAyPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEwNjAiPjxOdW1Db2RlPjA1MTwvTnVtQ29kZT48Q2hhckNvZGU+QU1EPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+wPDs/+3x6uj1IOTw4Ozu4jwvTmFtZT48VmFsdWU+MTEsNzk2NzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDkwQiI+PE51bUNvZGU+OTMzPC9OdW1Db2RlPjxDaGFyQ29kZT5CWU48L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+weXr7vDz8fHq6Okg8PPh6/w8L05hbWU+PFZhbHVlPjI5LDAyNDY8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTEwMCI+PE51bUNvZGU+OTc1PC9OdW1Db2RlPjxDaGFyQ29kZT5CR048L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+we7r4+Dw8ero6SDr5eI8L05hbWU+PFZhbHVlPjM2LDAzNjc8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTExNSI+PE51bUNvZGU+OTg2PC9OdW1Db2RlPjxDaGFyQ29kZT5CUkw8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wfDg5+jr/PHq6Okg8OXg6zwvTmFtZT48VmFsdWU+MTcsNjk3ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMTM1Ij48TnVtQ29kZT4zNDg8L051bUNvZGU+PENoYXJDb2RlPkhVRjwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPsLl7ePl8PHq6PUg9O7w6O3y7uI8L05hbWU+PFZhbHVlPjIyLDUzMzk8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIwMCI+PE51bUNvZGU+MzQ0PC9OdW1Db2RlPjxDaGFyQ29kZT5IS0Q8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPsPu7eru7ePx6uj1IOTu6+vg8O7iPC9OYW1lPjxWYWx1ZT43Miw1MDIyPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyMTUiPjxOdW1Db2RlPjIwODwvTnVtQ29kZT48Q2hhckNvZGU+REtLPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7E4PLx6uj1IOrw7u08L05hbWU+PFZhbHVlPjk0LDYxMDI8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIzNSI+PE51bUNvZGU+ODQwPC9OdW1Db2RlPjxDaGFyQ29kZT5VU0Q8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+xO7r6+DwINHYwDwvTmFtZT48VmFsdWU+NTYsODAxMTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMjM5Ij48TnVtQ29kZT45Nzg8L051bUNvZGU+PENoYXJDb2RlPkVVUjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7F4vDuPC9OYW1lPjxWYWx1ZT43MCw1Mjk5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyNzAiPjxOdW1Db2RlPjM1NjwvTnVtQ29kZT48Q2hhckNvZGU+SU5SPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yO3k6Onx6uj1IPDz7+jpPC9OYW1lPjxWYWx1ZT44Nyw0MjMzPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzMzUiPjxOdW1Db2RlPjM5ODwvTnVtQ29kZT48Q2hhckNvZGU+S1pUPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yuDn4PXx8uDt8ero9SDy5e3j5TwvTmFtZT48VmFsdWU+MTcsNzUxNTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzUwIj48TnVtQ29kZT4xMjQ8L051bUNvZGU+PENoYXJDb2RlPkNBRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7K4O3g5PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40Myw5Mzk5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzNzAiPjxOdW1Db2RlPjQxNzwvTnVtQ29kZT48Q2hhckNvZGU+S0dTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yujw4+jn8ero9SDx7uzu4jwvTmFtZT48VmFsdWU+ODMsNTk4NjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzc1Ij48TnVtQ29kZT4xNTY8L051bUNvZGU+PENoYXJDb2RlPkNOWTwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+yujy4Onx6uj1IP7g7eXpPC9OYW1lPjxWYWx1ZT44OSw4MzM5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE1MDAiPjxOdW1Db2RlPjQ5ODwvTnVtQ29kZT48Q2hhckNvZGU+TURMPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7M7uvk4OLx6uj1IOvl5eI8L05hbWU+PFZhbHVlPjM0LDExNDg8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTUzNSI+PE51bUNvZGU+NTc4PC9OdW1Db2RlPjxDaGFyQ29kZT5OT0s8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPs3u8OLl5vHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NzIsOTA4OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNTY1Ij48TnVtQ29kZT45ODU8L051bUNvZGU+PENoYXJDb2RlPlBMTjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7P7uv88ero6SDn6+7y++k8L05hbWU+PFZhbHVlPjE2LDgyODA8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4NUYiPjxOdW1Db2RlPjk0NjwvTnVtQ29kZT48Q2hhckNvZGU+Uk9OPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPtDz7Pvt8ero6SDr5ek8L05hbWU+PFZhbHVlPjE1LDEyMjA8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4OSI+PE51bUNvZGU+OTYwPC9OdW1Db2RlPjxDaGFyQ29kZT5YRFI8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0cTQICjx7+X26ODr/O375SDv8ODi4CDn4Ojs8fLi7uLg7ej/KTwvTmFtZT48VmFsdWU+ODIsNTk1MDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNjI1Ij48TnVtQ29kZT43MDI8L051bUNvZGU+PENoYXJDb2RlPlNHRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7R6O3j4O/z8PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40MywxODE2PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE2NzAiPjxOdW1Db2RlPjk3MjwvTnVtQ29kZT48Q2hhckNvZGU+VEpTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7S4OTm6Orx6uj1IPHu7O7t6DwvTmFtZT48VmFsdWU+NjQsMzQ3MTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzAwSiI+PE51bUNvZGU+OTQ5PC9OdW1Db2RlPjxDaGFyQ29kZT5UUlk8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0vPw5fbq4P8g6+jw4DwvTmFtZT48VmFsdWU+MTQsOTU1OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzEwQSI+PE51bUNvZGU+OTM0PC9OdW1Db2RlPjxDaGFyQ29kZT5UTVQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+ze7i++kg8vPw6uzl7fHq6Okg7ODt4PI8L05hbWU+PFZhbHVlPjE2LDI1MjE8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTcxNyI+PE51bUNvZGU+ODYwPC9OdW1Db2RlPjxDaGFyQ29kZT5VWlM8L0NoYXJDb2RlPjxOb21pbmFsPjEwMDAwPC9Ob21pbmFsPjxOYW1lPtPn4eXq8ero9SDx8+zu4jwvTmFtZT48VmFsdWU+NjksNTc5ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzIwIj48TnVtQ29kZT45ODA8L051bUNvZGU+PENoYXJDb2RlPlVBSDwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+0+rw4Ojt8ero9SDj8Oji5e08L05hbWU+PFZhbHVlPjIxLDU5NzQ8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTc2MCI+PE51bUNvZGU+MjAzPC9OdW1Db2RlPjxDaGFyQ29kZT5DWks8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPtfl+PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+MjcsNzUzOTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzcwIj48TnVtQ29kZT43NTI8L051bUNvZGU+PENoYXJDb2RlPlNFSzwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+2OLl5PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NjksMDUzMjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzc1Ij48TnVtQ29kZT43NTY8L051bUNvZGU+PENoYXJDb2RlPkNIRjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7Y4uXp9uDw8ero6SD08ODt6jwvTmFtZT48VmFsdWU+NjAsNDU4OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODEwIj48TnVtQ29kZT43MTA8L051bUNvZGU+PENoYXJDb2RlPlpBUjwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+3ubt7uD08Ojq4O3x6uj1IPD97eTu4jwvTmFtZT48VmFsdWU+NDgsMDgxMTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODE1Ij48TnVtQ29kZT40MTA8L051bUNvZGU+PENoYXJDb2RlPktSVzwvQ2hhckNvZGU+PE5vbWluYWw+MTAwMDwvTm9taW5hbD48TmFtZT7C7u0g0OXx7/Ph6+jq6CDK7vDl/zwvTmFtZT48VmFsdWU+NTMsMjI1MjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODIwIj48TnVtQ29kZT4zOTI8L051bUNvZGU+PENoYXJDb2RlPkpQWTwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPt/v7u3x6uj1IOjl7TwvTmFtZT48VmFsdWU+NTMsNjg5ODwvVmFsdWU+PC9WYWx1dGU+PC9WYWxDdXJzPg== - http_version: - recorded_at: Mon, 12 Mar 2018 21:00:00 GMT -- request: - method: get - uri: http://www.cbr.ru/scripts/XML_daily.asp?date_req=13/03/2018 - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - response: - status: - code: 200 - message: OK - headers: - Server: - - nginx/1.12.2 - Date: - - Thu, 26 Jul 2018 09:02:07 GMT - Content-Type: - - application/xml; charset=windows-1251 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Vary: - - Accept-Encoding - Cache-Control: - - no-cache - Pragma: - - no-cache - Expires: - - "-1" - X-Aspnet-Version: - - 4.0.30319 - X-Powered-By: - - ASP.NET - body: - encoding: ASCII-8BIT - string: !binary |- - PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+PFZhbEN1cnMgRGF0ZT0iMTMuMDMuMjAxOCIgbmFtZT0iRm9yZWlnbiBDdXJyZW5jeSBNYXJrZXQiPjxWYWx1dGUgSUQ9IlIwMTAxMCI+PE51bUNvZGU+MDM2PC9OdW1Db2RlPjxDaGFyQ29kZT5BVUQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wOLx8vDg6+jp8ero6SDk7uvr4PA8L05hbWU+PFZhbHVlPjQ0LDU0ODE8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTAyMEEiPjxOdW1Db2RlPjk0NDwvTnVtQ29kZT48Q2hhckNvZGU+QVpOPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPsDn5fDh4Onk5uDt8ero6SDs4O3g8jwvTmFtZT48VmFsdWU+MzMsMjUyNDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDM1Ij48TnVtQ29kZT44MjY8L051bUNvZGU+PENoYXJDb2RlPkdCUDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7U8+3yIPHy5fDr6O3j7uIg0e7l5Ojt5e3t7uPuIOru8O7r5eLx8uLgPC9OYW1lPjxWYWx1ZT43OCw1MjExPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEwNjAiPjxOdW1Db2RlPjA1MTwvTnVtQ29kZT48Q2hhckNvZGU+QU1EPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+wPDs/+3x6uj1IOTw4Ozu4jwvTmFtZT48VmFsdWU+MTEsNzc1ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDkwQiI+PE51bUNvZGU+OTMzPC9OdW1Db2RlPjxDaGFyQ29kZT5CWU48L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+weXr7vDz8fHq6Okg8PPh6/w8L05hbWU+PFZhbHVlPjI4LDk1MDI8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTEwMCI+PE51bUNvZGU+OTc1PC9OdW1Db2RlPjxDaGFyQ29kZT5CR048L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+we7r4+Dw8ero6SDr5eI8L05hbWU+PFZhbHVlPjM1LDY3OTI8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTExNSI+PE51bUNvZGU+OTg2PC9OdW1Db2RlPjxDaGFyQ29kZT5CUkw8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wfDg5+jr/PHq6Okg8OXg6zwvTmFtZT48VmFsdWU+MTcsMzkwODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMTM1Ij48TnVtQ29kZT4zNDg8L051bUNvZGU+PENoYXJDb2RlPkhVRjwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPsLl7ePl8PHq6PUg9O7w6O3y7uI8L05hbWU+PFZhbHVlPjIyLDM4MDg8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIwMCI+PE51bUNvZGU+MzQ0PC9OdW1Db2RlPjxDaGFyQ29kZT5IS0Q8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPsPu7eru7ePx6uj1IOTu6+vg8O7iPC9OYW1lPjxWYWx1ZT43MiwyMDQ4PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyMTUiPjxOdW1Db2RlPjIwODwvTnVtQ29kZT48Q2hhckNvZGU+REtLPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7E4PLx6uj1IOrw7u08L05hbWU+PFZhbHVlPjkzLDY4Mzg8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIzNSI+PE51bUNvZGU+ODQwPC9OdW1Db2RlPjxDaGFyQ29kZT5VU0Q8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+xO7r6+DwINHYwDwvTmFtZT48VmFsdWU+NTYsNjEyMjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMjM5Ij48TnVtQ29kZT45Nzg8L051bUNvZGU+PENoYXJDb2RlPkVVUjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7F4vDuPC9OYW1lPjxWYWx1ZT42OSw3OTcyPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyNzAiPjxOdW1Db2RlPjM1NjwvTnVtQ29kZT48Q2hhckNvZGU+SU5SPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yO3k6Onx6uj1IPDz7+jpPC9OYW1lPjxWYWx1ZT44NywwODIzPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzMzUiPjxOdW1Db2RlPjM5ODwvTnVtQ29kZT48Q2hhckNvZGU+S1pUPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yuDn4PXx8uDt8ero9SDy5e3j5TwvTmFtZT48VmFsdWU+MTcsNjQ2NDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzUwIj48TnVtQ29kZT4xMjQ8L051bUNvZGU+PENoYXJDb2RlPkNBRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7K4O3g5PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40NCwxNTkzPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzNzAiPjxOdW1Db2RlPjQxNzwvTnVtQ29kZT48Q2hhckNvZGU+S0dTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yujw4+jn8ero9SDx7uzu4jwvTmFtZT48VmFsdWU+ODIsOTQ4MzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzc1Ij48TnVtQ29kZT4xNTY8L051bUNvZGU+PENoYXJDb2RlPkNOWTwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+yujy4Onx6uj1IP7g7eXpPC9OYW1lPjxWYWx1ZT44OSw1MjM5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE1MDAiPjxOdW1Db2RlPjQ5ODwvTnVtQ29kZT48Q2hhckNvZGU+TURMPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7M7uvk4OLx6uj1IOvl5eI8L05hbWU+PFZhbHVlPjM0LDEyNDM8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTUzNSI+PE51bUNvZGU+NTc4PC9OdW1Db2RlPjxDaGFyQ29kZT5OT0s8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPs3u8OLl5vHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NzIsODAyODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNTY1Ij48TnVtQ29kZT45ODU8L051bUNvZGU+PENoYXJDb2RlPlBMTjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7P7uv88ero6SDn6+7y++k8L05hbWU+PFZhbHVlPjE2LDYzNzQ8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4NUYiPjxOdW1Db2RlPjk0NjwvTnVtQ29kZT48Q2hhckNvZGU+Uk9OPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPtDz7Pvt8ero6SDr5ek8L05hbWU+PFZhbHVlPjE0LDk3ODg8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4OSI+PE51bUNvZGU+OTYwPC9OdW1Db2RlPjxDaGFyQ29kZT5YRFI8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0cTQICjx7+X26ODr/O375SDv8ODi4CDn4Ojs8fLi7uLg7ej/KTwvTmFtZT48VmFsdWU+ODIsMDI4ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNjI1Ij48TnVtQ29kZT43MDI8L051bUNvZGU+PENoYXJDb2RlPlNHRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7R6O3j4O/z8PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40MywwNjQyPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE2NzAiPjxOdW1Db2RlPjk3MjwvTnVtQ29kZT48Q2hhckNvZGU+VEpTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7S4OTm6Orx6uj1IPHu7O7t6DwvTmFtZT48VmFsdWU+NjQsMTQ5ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzAwSiI+PE51bUNvZGU+OTQ5PC9OdW1Db2RlPjxDaGFyQ29kZT5UUlk8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0vPw5fbq4P8g6+jw4DwvTmFtZT48VmFsdWU+MTQsODQzMzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzEwQSI+PE51bUNvZGU+OTM0PC9OdW1Db2RlPjxDaGFyQ29kZT5UTVQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+ze7i++kg8vPw6uzl7fHq6Okg7ODt4PI8L05hbWU+PFZhbHVlPjE2LDE5ODE8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTcxNyI+PE51bUNvZGU+ODYwPC9OdW1Db2RlPjxDaGFyQ29kZT5VWlM8L0NoYXJDb2RlPjxOb21pbmFsPjEwMDAwPC9Ob21pbmFsPjxOYW1lPtPn4eXq8ero9SDx8+zu4jwvTmFtZT48VmFsdWU+NjksMzQ4NDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzIwIj48TnVtQ29kZT45ODA8L051bUNvZGU+PENoYXJDb2RlPlVBSDwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+0+rw4Ojt8ero9SDj8Oji5e08L05hbWU+PFZhbHVlPjIxLDgzNjk8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTc2MCI+PE51bUNvZGU+MjAzPC9OdW1Db2RlPjxDaGFyQ29kZT5DWks8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPtfl+PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+MjcsMzk1MjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzcwIj48TnVtQ29kZT43NTI8L051bUNvZGU+PENoYXJDb2RlPlNFSzwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+2OLl5PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NjgsNjc1ODwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzc1Ij48TnVtQ29kZT43NTY8L051bUNvZGU+PENoYXJDb2RlPkNIRjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7Y4uXp9uDw8ero6SD08ODt6jwvTmFtZT48VmFsdWU+NTksNjA0MzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODEwIj48TnVtQ29kZT43MTA8L051bUNvZGU+PENoYXJDb2RlPlpBUjwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+3ubt7uD08Ojq4O3x6uj1IPD97eTu4jwvTmFtZT48VmFsdWU+NDcsOTM4NzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODE1Ij48TnVtQ29kZT40MTA8L051bUNvZGU+PENoYXJDb2RlPktSVzwvQ2hhckNvZGU+PE5vbWluYWw+MTAwMDwvTm9taW5hbD48TmFtZT7C7u0g0OXx7/Ph6+jq6CDK7vDl/zwvTmFtZT48VmFsdWU+NTMsMDc5NTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODIwIj48TnVtQ29kZT4zOTI8L051bUNvZGU+PENoYXJDb2RlPkpQWTwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPt/v7u3x6uj1IOjl7TwvTmFtZT48VmFsdWU+NTMsMTU5NTwvVmFsdWU+PC9WYWx1dGU+PC9WYWxDdXJzPg== - http_version: - recorded_at: Mon, 12 Mar 2018 21:00:00 GMT -- request: - method: get - uri: http://www.cbr.ru/scripts/XML_daily.asp?date_req=14/03/2018 - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - response: - status: - code: 200 - message: OK - headers: - Server: - - nginx/1.12.2 - Date: - - Thu, 26 Jul 2018 09:02:08 GMT - Content-Type: - - application/xml; charset=windows-1251 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Vary: - - Accept-Encoding - Cache-Control: - - no-cache - Pragma: - - no-cache - Expires: - - "-1" - X-Aspnet-Version: - - 4.0.30319 - X-Powered-By: - - ASP.NET - body: - encoding: ASCII-8BIT - string: !binary |- - PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+PFZhbEN1cnMgRGF0ZT0iMTQuMDMuMjAxOCIgbmFtZT0iRm9yZWlnbiBDdXJyZW5jeSBNYXJrZXQiPjxWYWx1dGUgSUQ9IlIwMTAxMCI+PE51bUNvZGU+MDM2PC9OdW1Db2RlPjxDaGFyQ29kZT5BVUQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wOLx8vDg6+jp8ero6SDk7uvr4PA8L05hbWU+PFZhbHVlPjQ0LDc5MTU8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTAyMEEiPjxOdW1Db2RlPjk0NDwvTnVtQ29kZT48Q2hhckNvZGU+QVpOPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPsDn5fDh4Onk5uDt8ero6SDs4O3g8jwvTmFtZT48VmFsdWU+MzMsNDQyNTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDM1Ij48TnVtQ29kZT44MjY8L051bUNvZGU+PENoYXJDb2RlPkdCUDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7U8+3yIPHy5fDr6O3j7uIg0e7l5Ojt5e3t7uPuIOru8O7r5eLx8uLgPC9OYW1lPjxWYWx1ZT43OSwxMTgxPC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEwNjAiPjxOdW1Db2RlPjA1MTwvTnVtQ29kZT48Q2hhckNvZGU+QU1EPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+wPDs/+3x6uj1IOTw4Ozu4jwvTmFtZT48VmFsdWU+MTEsODM3MDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMDkwQiI+PE51bUNvZGU+OTMzPC9OdW1Db2RlPjxDaGFyQ29kZT5CWU48L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+weXr7vDz8fHq6Okg8PPh6/w8L05hbWU+PFZhbHVlPjI5LDEwMDk8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTEwMCI+PE51bUNvZGU+OTc1PC9OdW1Db2RlPjxDaGFyQ29kZT5CR048L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+we7r4+Dw8ero6SDr5eI8L05hbWU+PFZhbHVlPjM1LDg3ODc8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTExNSI+PE51bUNvZGU+OTg2PC9OdW1Db2RlPjxDaGFyQ29kZT5CUkw8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+wfDg5+jr/PHq6Okg8OXg6zwvTmFtZT48VmFsdWU+MTcsNDU3MDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMTM1Ij48TnVtQ29kZT4zNDg8L051bUNvZGU+PENoYXJDb2RlPkhVRjwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPsLl7ePl8PHq6PUg9O7w6O3y7uI8L05hbWU+PFZhbHVlPjIyLDUyODQ8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIwMCI+PE51bUNvZGU+MzQ0PC9OdW1Db2RlPjxDaGFyQ29kZT5IS0Q8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPsPu7eru7ePx6uj1IOTu6+vg8O7iPC9OYW1lPjxWYWx1ZT43Miw2MTk1PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyMTUiPjxOdW1Db2RlPjIwODwvTnVtQ29kZT48Q2hhckNvZGU+REtLPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7E4PLx6uj1IOrw7u08L05hbWU+PFZhbHVlPjk0LDIzMjA8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTIzNSI+PE51bUNvZGU+ODQwPC9OdW1Db2RlPjxDaGFyQ29kZT5VU0Q8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+xO7r6+DwINHYwDwvTmFtZT48VmFsdWU+NTYsOTM1OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMjM5Ij48TnVtQ29kZT45Nzg8L051bUNvZGU+PENoYXJDb2RlPkVVUjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7F4vDuPC9OYW1lPjxWYWx1ZT43MCwxNTY0PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEyNzAiPjxOdW1Db2RlPjM1NjwvTnVtQ29kZT48Q2hhckNvZGU+SU5SPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yO3k6Onx6uj1IPDz7+jpPC9OYW1lPjxWYWx1ZT44Nyw2MDM4PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzMzUiPjxOdW1Db2RlPjM5ODwvTnVtQ29kZT48Q2hhckNvZGU+S1pUPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yuDn4PXx8uDt8ero9SDy5e3j5TwvTmFtZT48VmFsdWU+MTcsNzA1OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzUwIj48TnVtQ29kZT4xMjQ8L051bUNvZGU+PENoYXJDb2RlPkNBRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7K4O3g5PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40NCwyODc0PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDEzNzAiPjxOdW1Db2RlPjQxNzwvTnVtQ29kZT48Q2hhckNvZGU+S0dTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDA8L05vbWluYWw+PE5hbWU+yujw4+jn8ero9SDx7uzu4jwvTmFtZT48VmFsdWU+ODMsNDgzNzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxMzc1Ij48TnVtQ29kZT4xNTY8L051bUNvZGU+PENoYXJDb2RlPkNOWTwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+yujy4Onx6uj1IP7g7eXpPC9OYW1lPjxWYWx1ZT44OSw5NTg5PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE1MDAiPjxOdW1Db2RlPjQ5ODwvTnVtQ29kZT48Q2hhckNvZGU+TURMPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7M7uvk4OLx6uj1IOvl5eI8L05hbWU+PFZhbHVlPjM0LDM1MDU8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTUzNSI+PE51bUNvZGU+NTc4PC9OdW1Db2RlPjxDaGFyQ29kZT5OT0s8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPs3u8OLl5vHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NzMsNDE0NTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNTY1Ij48TnVtQ29kZT45ODU8L051bUNvZGU+PENoYXJDb2RlPlBMTjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7P7uv88ero6SDn6+7y++k8L05hbWU+PFZhbHVlPjE2LDY4NTU8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4NUYiPjxOdW1Db2RlPjk0NjwvTnVtQ29kZT48Q2hhckNvZGU+Uk9OPC9DaGFyQ29kZT48Tm9taW5hbD4xPC9Ob21pbmFsPjxOYW1lPtDz7Pvt8ero6SDr5ek8L05hbWU+PFZhbHVlPjE1LDA2MTI8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTU4OSI+PE51bUNvZGU+OTYwPC9OdW1Db2RlPjxDaGFyQ29kZT5YRFI8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0cTQICjx7+X26ODr/O375SDv8ODi4CDn4Ojs8fLi7uLg7ej/KTwvTmFtZT48VmFsdWU+ODIsNTE4OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNjI1Ij48TnVtQ29kZT43MDI8L051bUNvZGU+PENoYXJDb2RlPlNHRDwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7R6O3j4O/z8PHq6Okg5O7r6+DwPC9OYW1lPjxWYWx1ZT40MywzMzM1PC9WYWx1ZT48L1ZhbHV0ZT48VmFsdXRlIElEPSJSMDE2NzAiPjxOdW1Db2RlPjk3MjwvTnVtQ29kZT48Q2hhckNvZGU+VEpTPC9DaGFyQ29kZT48Tm9taW5hbD4xMDwvTm9taW5hbD48TmFtZT7S4OTm6Orx6uj1IPHu7O7t6DwvTmFtZT48VmFsdWU+NjQsNTE5NTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzAwSiI+PE51bUNvZGU+OTQ5PC9OdW1Db2RlPjxDaGFyQ29kZT5UUlk8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+0vPw5fbq4P8g6+jw4DwvTmFtZT48VmFsdWU+MTQsNzUzMzwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzEwQSI+PE51bUNvZGU+OTM0PC9OdW1Db2RlPjxDaGFyQ29kZT5UTVQ8L0NoYXJDb2RlPjxOb21pbmFsPjE8L05vbWluYWw+PE5hbWU+ze7i++kg8vPw6uzl7fHq6Okg7ODt4PI8L05hbWU+PFZhbHVlPjE2LDI5MDc8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTcxNyI+PE51bUNvZGU+ODYwPC9OdW1Db2RlPjxDaGFyQ29kZT5VWlM8L0NoYXJDb2RlPjxOb21pbmFsPjEwMDAwPC9Ob21pbmFsPjxOYW1lPtPn4eXq8ero9SDx8+zu4jwvTmFtZT48VmFsdWU+NjksOTQ1NDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzIwIj48TnVtQ29kZT45ODA8L051bUNvZGU+PENoYXJDb2RlPlVBSDwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+0+rw4Ojt8ero9SDj8Oji5e08L05hbWU+PFZhbHVlPjIxLDk3MjQ8L1ZhbHVlPjwvVmFsdXRlPjxWYWx1dGUgSUQ9IlIwMTc2MCI+PE51bUNvZGU+MjAzPC9OdW1Db2RlPjxDaGFyQ29kZT5DWks8L0NoYXJDb2RlPjxOb21pbmFsPjEwPC9Ob21pbmFsPjxOYW1lPtfl+PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+MjcsNTg3OTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzcwIj48TnVtQ29kZT43NTI8L051bUNvZGU+PENoYXJDb2RlPlNFSzwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+2OLl5PHq6PUg6vDu7TwvTmFtZT48VmFsdWU+NjksMDgzNjwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxNzc1Ij48TnVtQ29kZT43NTY8L051bUNvZGU+PENoYXJDb2RlPkNIRjwvQ2hhckNvZGU+PE5vbWluYWw+MTwvTm9taW5hbD48TmFtZT7Y4uXp9uDw8ero6SD08ODt6jwvTmFtZT48VmFsdWU+NjAsMDc4MDwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODEwIj48TnVtQ29kZT43MTA8L051bUNvZGU+PENoYXJDb2RlPlpBUjwvQ2hhckNvZGU+PE5vbWluYWw+MTA8L05vbWluYWw+PE5hbWU+3ubt7uD08Ojq4O3x6uj1IPD97eTu4jwvTmFtZT48VmFsdWU+NDgsMDgyOTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODE1Ij48TnVtQ29kZT40MTA8L051bUNvZGU+PENoYXJDb2RlPktSVzwvQ2hhckNvZGU+PE5vbWluYWw+MTAwMDwvTm9taW5hbD48TmFtZT7C7u0g0OXx7/Ph6+jq6CDK7vDl/zwvTmFtZT48VmFsdWU+NTMsNDcyNTwvVmFsdWU+PC9WYWx1dGU+PFZhbHV0ZSBJRD0iUjAxODIwIj48TnVtQ29kZT4zOTI8L051bUNvZGU+PENoYXJDb2RlPkpQWTwvQ2hhckNvZGU+PE5vbWluYWw+MTAwPC9Ob21pbmFsPjxOYW1lPt/v7u3x6uj1IOjl7TwvTmFtZT48VmFsdWU+NTMsMjQzNTwvVmFsdWU+PC9WYWx1dGU+PC9WYWxDdXJzPg== - http_version: - recorded_at: Mon, 12 Mar 2018 21:00:00 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/workers/gera/binance_rates_worker_spec.rb b/spec/workers/gera/binance_rates_worker_spec.rb deleted file mode 100644 index bd67167f..00000000 --- a/spec/workers/gera/binance_rates_worker_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Gera - RSpec.describe BinanceRatesWorker do - let!(:rate_source) { create(:rate_source_binance) } - - it 'should approve new snapshot if it has the same count of external rates' do - actual_snapshot = create(:external_rate_snapshot, rate_source: rate_source) - actual_snapshot.external_rates << create(:external_rate, source: rate_source, snapshot: actual_snapshot) - actual_snapshot.external_rates << create(:inverse_external_rate, source: rate_source, snapshot: actual_snapshot) - rate_source.update_column(:actual_snapshot_id, actual_snapshot.id) - - expect(rate_source.actual_snapshot_id).to eq(actual_snapshot.id) - VCR.use_cassette :binance_with_two_external_rates do - expect(BinanceRatesWorker.new.perform).to be_truthy - end - expect(rate_source.reload.actual_snapshot_id).not_to eq(actual_snapshot.id) - end - - it 'should not approve new snapshot if it has different count of external rates' do - actual_snapshot = create(:external_rate_snapshot, rate_source: rate_source) - actual_snapshot.external_rates << create(:external_rate, source: rate_source, snapshot: actual_snapshot) - rate_source.update_column(:actual_snapshot_id, actual_snapshot.id) - - expect(rate_source.actual_snapshot_id).to eq(actual_snapshot.id) - VCR.use_cassette :binance_with_two_external_rates do - expect(BinanceRatesWorker.new.perform).to be_truthy - end - expect(rate_source.reload.actual_snapshot_id).to eq(actual_snapshot.id) - end - end -end diff --git a/spec/workers/gera/cbr_rates_worker_spec.rb b/spec/workers/gera/cbr_rates_worker_spec.rb deleted file mode 100644 index 15473d00..00000000 --- a/spec/workers/gera/cbr_rates_worker_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Gera - RSpec.describe CbrRatesWorker do - before do - create :rate_source_exmo - create :rate_source_cbr_avg - create :rate_source_cbr - end - let(:today) { Date.parse '13/03/2018' } - it do - expect(ExternalRate.count).to be_zero - - # На teamcity почему-то дата возвращается как 2018-03-12 - allow(Date).to receive(:today).and_return today - Timecop.freeze(today) do - VCR.use_cassette :cbrf do - expect(CbrRatesWorker.new.perform).to be_truthy - end - end - - expect(ExternalRate.count).to eq 12 - end - end -end diff --git a/spec/workers/gera/currency_rates_worker_spec.rb b/spec/workers/gera/currency_rates_worker_spec.rb deleted file mode 100644 index 86ed67da..00000000 --- a/spec/workers/gera/currency_rates_worker_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Gera - RSpec.describe CurrencyRatesWorker do - it do - expect(CurrencyRatesWorker.new.perform).to be_truthy - end - end -end