abot-info 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bbe9530de84f99fcba646a78e0c4a61bb02ebf2e484159817721724cfe6b7733
4
+ data.tar.gz: 5f1decd77bea3d406a7abe300c9a887391afe9a7991d27561c9a5a34ea8350e4
5
+ SHA512:
6
+ metadata.gz: 4544c13838da0d2ed4af0e559b02d8c3bac17555a2cd7bdad62d0b1fcf496a13ab8e192205c537e2244775b1e4306c001eb60df1f211ae4244719265fb1565ee
7
+ data.tar.gz: b647a8cd960afb5e5bbaf739949cddfd6ab1fb4e2055ec9ab6e7228681df5e10cb76d8b4a92a528c48dad85eff855cd96275421da508f5bcdd5c8bc81bd6fcc9
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'activerecord', '~> 6.1.3'
4
+ gem 'paint', '~> 2.2.1'
5
+ gem 'sqlite3', '~> 1.4.2'
6
+ gem 'terminal-table', '~> 3.0.1'
7
+ gem 'binance-ruby', '~> 1.3.1'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 w_dmitrii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Abot::Info
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/abot/info`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'abot-info'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install abot-info
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/abot-info. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
+
41
+ ## Code of Conduct
42
+
43
+ Everyone interacting in the Abot::Info project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/abot-info/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/abot-info.gemspec ADDED
@@ -0,0 +1,41 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "abot/info/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'abot-info'
8
+ spec.version = Abot::Info::VERSION
9
+ spec.authors = ['w_dmitrii']
10
+ spec.email = ['wiz.work2021@gmail.com']
11
+
12
+ spec.summary = 'Abot Info'
13
+ spec.license = 'MIT'
14
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.6.6')
15
+
16
+ spec.files = Dir['lib/**/*.rb', 'exe/*'] + %w[
17
+ abot-info.gemspec
18
+ Gemfile
19
+ LICENSE
20
+ Rakefile
21
+ README.md
22
+ ]
23
+
24
+ spec.test_files = Dir['spec/**/*_spec.rb']
25
+
26
+ spec.extra_rdoc_files = %w[LICENSE README.md]
27
+
28
+ spec.bindir = 'exe'
29
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ['lib']
31
+
32
+ spec.add_development_dependency "bundler", "~> 1.17"
33
+ spec.add_development_dependency "rake", "~> 10.0"
34
+ spec.add_development_dependency "rspec", "~> 3.0"
35
+
36
+ spec.add_dependency "activerecord", '~> 6.1.3'
37
+ spec.add_dependency "paint", '~> 2.2.1'
38
+ spec.add_dependency "sqlite3", '~> 1.4.2'
39
+ spec.add_dependency "terminal-table", '~> 3.0.1'
40
+ spec.add_dependency "binance-ruby", '~> 1.3.1'
41
+ end
data/exe/abot-info ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/abot/info'
5
+
6
+ Abot::Info.run
data/lib/abot/info.rb ADDED
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'sqlite3'
5
+ require 'terminal-table'
6
+ require 'paint'
7
+ require 'binance-ruby'
8
+
9
+ require_relative 'info/option_parser'
10
+ require_relative 'info/coin'
11
+ require_relative 'info/binance_account'
12
+ require_relative 'info/table'
13
+ require_relative 'info/version'
14
+
15
+ module Abot
16
+ module Info
17
+ class << self
18
+ def run
19
+ Info::OptionParser.instance do |parser|
20
+ parser.program_name = 'abot-info'
21
+ parser.version = Abot::Info::VERSION
22
+
23
+ parser.separator ''
24
+ parser.separator 'Application options:'
25
+
26
+ parser.add_option(
27
+ :template, '--template=TEMPLATE',
28
+ 'Шаблон (default || mobile)',
29
+ )
30
+ parser.add_option(
31
+ :add, '--add=COLUMN1,COLUMN2,COLUMN3',
32
+ "Добавить колонки:\n" \
33
+ " name: Названия монет\n" \
34
+ " name_mobile: Название монет(без изменения по дню)\n" \
35
+ " sell_up: Прибыль со сделки\n" \
36
+ " sell_price: Цена продажи\n" \
37
+ " max_price: Максимальная цена за 24ч.\n" \
38
+ " min_price: Минимальная цена за 24ч.\n" \
39
+ " current_price: Текущая цена\n" \
40
+ " next_average_price: Цена следующего усреднения\n" \
41
+ " current_quote: Текущая стоимость позиции, $\n" \
42
+ " buy_date: Дата покупки\n" \
43
+ " timer: Время с покупки\n" \
44
+ " current_profit: Текущая прибыль\n" \
45
+ " potential_profit: Потенциальная прибыль\n",
46
+ )
47
+ parser.add_option(
48
+ :del, '--del=COLUMN1,COLUMN2,COLUMN3',
49
+ "Удалить колонки:\n" \
50
+ " name: Названия монет\n" \
51
+ " name_mobile: Название монет(без изменения по дню)\n" \
52
+ " sell_up: Прибыль со сделки\n" \
53
+ " sell_price: Цена продажи\n" \
54
+ " max_price: Максимальная цена за 24ч.\n" \
55
+ " min_price: Минимальная цена за 24ч.\n" \
56
+ " current_price: Текущая цена\n" \
57
+ " next_average_price: Цена следующего усреднения\n" \
58
+ " current_quote: Текущая стоимость позиции, $\n" \
59
+ " buy_date: Дата покупки\n" \
60
+ " timer: Время с покупки\n" \
61
+ " current_profit: Текущая прибыль\n" \
62
+ " potential_profit: Потенциальная прибыль\n",
63
+ )
64
+ parser.add_option(
65
+ :db_path, '--db_path=DB_PATH',
66
+ 'Абсолютный путь до базы A-bot',
67
+ required: true
68
+ )
69
+ parser.add_option(
70
+ :symbols, '--symbols=SYMBOL1,SYMBOL2,SYMBOL3',
71
+ 'Пары для отображения курсов',
72
+ )
73
+ end.final!
74
+
75
+ require_relative 'info/database_table'
76
+
77
+ start_stats({ rate_coins: rate_coins, columns: columns })
78
+ end
79
+
80
+ def start_stats(opts)
81
+ while true do
82
+ begin
83
+ table = Table.new(opts.merge({ account: binance_account })).generate
84
+
85
+ system 'clear'
86
+ puts table
87
+ rescue StandardError => e
88
+ puts "ERROR: #{e.message}"
89
+ end
90
+
91
+ sleep 10
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def rate_coins
98
+ @rate_coins ||= Info::OptionParser.instance.options[:symbols]&.split(',') || []
99
+ end
100
+
101
+ def template
102
+ @template ||= Info::OptionParser.instance.options[:template]
103
+ end
104
+
105
+ def columns
106
+ add = Info::OptionParser.instance.options[:add]&.split(',')&.map { |m| m.to_sym } || []
107
+ del = Info::OptionParser.instance.options[:del]&.split(',')&.map { |m| m.to_sym } || []
108
+ columns = case template
109
+ when 'default'
110
+ Table::HEADINGS_COINS_TABLE_DEFAULT
111
+ when 'mobile'
112
+ Table::HEADINGS_COINS_TABLE_MOBILE
113
+ else
114
+ []
115
+ end
116
+ columns += add
117
+ columns -= del
118
+ Table.last_columns_set(columns)
119
+ end
120
+
121
+ def binance_account
122
+ BinanceAccount.new(DatabaseTable::API_KEY, DatabaseTable::SECRET_KEY)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abot
4
+ module Info
5
+ class BinanceAccount
6
+ attr_reader :api_key, :secret_key
7
+
8
+ def initialize(api_key, secret_key)
9
+ @api_key = api_key
10
+ @secret_key = secret_key
11
+ end
12
+
13
+ def free_balance(quote)
14
+ account_info[:balances].find { |f| f[:asset] == quote }.try(:[], :free).to_f
15
+ end
16
+
17
+ def current_balance_btc
18
+ @current_balance_btc ||= calculation_current_balance_btc
19
+ end
20
+
21
+ def current_balance(balance_btc, quote)
22
+ calculation_current_balance(balance_btc, quote)
23
+ end
24
+
25
+ def current_balance_wl(current_coins, quote)
26
+ calculation_current_balance_wl(current_coins, quote)
27
+ end
28
+
29
+ def percent_free_balance(balance, quote)
30
+ ((free_balance(quote) / balance) * 100).round(2)
31
+ end
32
+
33
+ def account_info
34
+ @account_info ||= Binance::Api::Account.info!(
35
+ api_key: api_key,
36
+ api_secret_key: secret_key,
37
+ )
38
+ end
39
+
40
+ def potential_balance(current_coins, trade_params)
41
+ return @potential_balance if @potential_balance
42
+
43
+ balance = current_balance(current_balance_btc, 'USDT')
44
+ quote_assets = trade_params['quote_asset'].split(' ')
45
+ potential = 0.0
46
+ btcusdt = get_symbols.find { |f| f[:symbol] == 'BTCUSDT' }
47
+
48
+ quote_assets.each do |q|
49
+ begin
50
+ pp_q = Coin.sum_potential_profit(current_coins, q) - Coin.sum_current_profit(current_coins, q)
51
+ if q == 'USDT'
52
+ potential += pp_q
53
+ elsif q == 'BTC'
54
+ potential += pp_q * btcusdt[:askPrice].to_f
55
+ elsif Coin::FIAT.include?(q)
56
+ btc = pp_q / get_symbols.find { |f| f[:symbol] == "BTC#{quote_asset}" }[:askPrice].to_f
57
+ potential += btc * btcusdt[:askPrice].to_f
58
+ else
59
+ c = pp_q / get_symbols.find { |f| f[:symbol] == "#{quote_asset}BTC" }[:askPrice].to_f
60
+ potential += btcusdt[:askPrice].to_f / c
61
+ end
62
+ rescue StandardError
63
+ puts "Ошибка подсчета потенц. баланса: #{q}"
64
+ end
65
+ end
66
+
67
+ @potential_balance ||= potential + balance
68
+ end
69
+
70
+ def symbol_info(symbol)
71
+ get_symbols.find { |f| f[:symbol] == symbol }
72
+ end
73
+
74
+ def symbol_min_price(symbol)
75
+ symbol_info(symbol).try(:[], :lowPrice).to_f
76
+ end
77
+
78
+ def symbol_max_price(symbol)
79
+ symbol_info(symbol).try(:[], :highPrice).to_f
80
+ end
81
+
82
+ private
83
+
84
+ def calculation_current_balance_btc
85
+ balance = 0.0
86
+ account_coins = {}
87
+ account_info[:balances].each do |coin|
88
+ if coin[:free].to_f != 0 || coin[:locked].to_f != 0
89
+ account_coins[coin[:asset].to_s] = coin[:free].to_f + coin[:locked].to_f
90
+ end
91
+ end
92
+ if account_coins.present?
93
+ symbols = get_symbols
94
+ btcusdt = symbols.find { |f| f[:symbol] == 'BTCUSDT' }[:askPrice].to_f
95
+ account_coins.each do |name, value|
96
+ begin
97
+ if name == 'BTC'
98
+ balance += account_coins[name]
99
+ elsif Coin::FIAT.include?(name)
100
+ coin = symbols.find { |f| f[:symbol] == "BTC#{name}" }
101
+ balance += value / coin[:askPrice].to_f
102
+ elsif symbols.find { |f| f[:symbol] == "#{name}USDT" }.nil?
103
+ coin = symbols.find { |f| f[:symbol] == "#{name}BTC" }
104
+ balance += coin[:askPrice].to_f * value
105
+ else
106
+ coin = symbols.find { |f| f[:symbol] == "#{name}USDT" }
107
+ balance += coin[:askPrice].to_f * value / btcusdt
108
+ end
109
+ rescue StandardError
110
+ puts "Ошибка при подсчете: монета #{name}"
111
+ end
112
+ end
113
+ end
114
+ balance
115
+ end
116
+
117
+ def calculation_current_balance(balance_btc, quote)
118
+ symbols = get_symbols
119
+ if Coin::FIAT.include?(quote)
120
+ coin = symbols.find { |f| f[:symbol] == "BTC#{quote}" }
121
+ balance_btc * coin.try(:[], :askPrice).to_f
122
+ else
123
+ coin = symbols.find { |f| f[:symbol] == "#{quote}BTC" }
124
+ balance_btc / coin.try(:[], :askPrice).to_f
125
+ end
126
+ end
127
+
128
+ def calculation_current_balance_wl(current_coins, quote)
129
+ balance = 0.0
130
+ current_coins = current_coins.select { |s| s.quote_asset == quote }
131
+ current_coins.each { |coin| balance += coin.current_quote }
132
+ quote_info = account_info[:balances].find { |f| f[:asset] == quote }
133
+ if quote_info.present?
134
+ balance += (quote_info.try(:[], :free).to_f + quote_info.try(:[], :locked).to_f)
135
+ end
136
+ balance
137
+ end
138
+
139
+ def get_symbols
140
+ @get_symbols ||= Binance::Api.ticker!(
141
+ type: 'daily',
142
+ api_key: api_key,
143
+ api_secret_key: secret_key
144
+ )
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'decorators/coin_decorator'
4
+
5
+ module Abot
6
+ module Info
7
+ class Coin
8
+ include Abot::Info::Coin::CoinDecorator
9
+
10
+ attr_reader :base_asset, :quote_asset, :average_price, :current_price, :sell_price, :volume, :num_averaging,
11
+ :total_quote, :buy_price, :step_averaging, :tick_size, :account, :timer, :trade_params
12
+
13
+ FIAT = ['USDT', 'BUSD', 'AUD', 'BIDR', 'BRL', 'EUR', 'GBR', 'RUB', 'TRY', 'TUSD', 'USDC', 'DAI', 'IDRT', 'PAX', 'UAH', 'NGN', 'VAI', 'BVND']
14
+
15
+ def initialize(options = {})
16
+ @trade_params = options[:trade_params]
17
+ @account = options[:account]
18
+ @base_asset = options[:base_asset]
19
+ @quote_asset = options[:quote_asset]
20
+ @average_price = options[:average_price]
21
+ @current_price = options[:current_price]
22
+ @sell_price = options[:sell_price]
23
+ @volume = options[:volume]
24
+ @num_averaging = options[:num_averaging]
25
+ @total_quote = options[:total_quote]
26
+ @buy_price = options[:buy_price]
27
+ @step_averaging = options[:step_averaging]
28
+ @tick_size = options[:tick_size]
29
+ @timer = options[:timer]
30
+ end
31
+
32
+ def self.current_coins(account, trade_params)
33
+ DatabaseTable.data_coins.map do |coin|
34
+ Coin.new(
35
+ trade_params: trade_params,
36
+ account: account,
37
+ base_asset: coin['baseAsset'],
38
+ quote_asset: coin['quoteAsset'],
39
+ average_price: coin['averagePrice'].to_f,
40
+ current_price: coin['askPrice'].to_f,
41
+ sell_price: coin['sellPrice'].to_f,
42
+ volume: coin['allQuantity'].to_f,
43
+ num_averaging: (coin['numAveraging'].to_i - 1),
44
+ total_quote: coin['totalQuote'].to_f.round(2),
45
+ buy_price: coin['buyPrice'].to_f,
46
+ step_averaging: coin['stepAveraging'].to_f,
47
+ tick_size: coin['tickSize'],
48
+ timer: coin['timer'].to_s[0..9],
49
+ )
50
+ end
51
+ end
52
+
53
+ def self.available_coins_number(current_coins, data_settings, free_balance)
54
+ aver_arr = []
55
+ current_coins.each_with_index {|m| aver_arr[m.num_averaging] = ((aver_arr[m.num_averaging].to_i) + 1) }
56
+ aver_weight = 0
57
+ aver_arr.each_with_index { |e, idx| aver_weight += (e.to_i * idx) }
58
+ max_pairs = (free_balance / 100) * data_settings["max_trade_pairs"].to_i
59
+ progressive_max_pairs = max_pairs - aver_weight
60
+ progressive_max_pairs.positive? ? progressive_max_pairs.round(0) : 0
61
+ end
62
+
63
+ def self.sum_current_profit(current_coins, quote)
64
+ current_coins = current_coins.select { |s| s.quote_asset == quote }
65
+ current_coins.sum(&:current_profit)
66
+ end
67
+
68
+ def self.sum_potential_profit(current_coins, quote)
69
+ current_coins = current_coins.select { |s| s.quote_asset == quote }
70
+ current_coins.sum(&:potential_profit)
71
+ end
72
+
73
+ def percent_to_order
74
+ @percent_to_order ||= ((sell_price / current_price - 1) * 100)
75
+ end
76
+
77
+ def percent_from_min_to_average
78
+ @percent_to_min ||= ((min_price / next_average_price - 1) * 100)
79
+ end
80
+
81
+ def percent_to_max
82
+ @percent_to_max ||= ((sell_price / max_price - 1) * 100)
83
+ end
84
+
85
+ def min_price
86
+ @min_price ||= account.symbol_min_price(symbol)
87
+ end
88
+
89
+ def max_price
90
+ @max_price ||= account.symbol_max_price(symbol)
91
+ end
92
+
93
+ def current_profit
94
+ @current_profit ||= ((current_price - average_price) * volume)
95
+ end
96
+
97
+ def potential_profit
98
+ ((sell_price - average_price) * volume)
99
+ end
100
+
101
+ def current_quote
102
+ @current_quote ||= (total_quote + current_profit)
103
+ end
104
+
105
+ def sell_up
106
+ @sell_up ||= ((potential_profit / total_quote) * 100 - 0.15)
107
+ end
108
+
109
+ def next_average_price
110
+ @next_average_price ||=
111
+ (buy_price - (buy_price / 100 * (step_averaging - trade_params['buy_down'].to_f))).round(tick_round_size)
112
+ end
113
+
114
+ def next_average_price_percent
115
+ @next_average_price_percent ||= ((current_price / next_average_price - 1) * 100)
116
+ end
117
+
118
+ def name
119
+ quote_asset == 'USDT' ? base_asset : symbol
120
+ end
121
+
122
+ def symbol
123
+ base_asset + quote_asset
124
+ end
125
+
126
+ private
127
+
128
+ def tick_round_size
129
+ @tick_round_size ||= tick_size.length - 2
130
+ end
131
+
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abot
4
+ module Info
5
+ class DatabaseTable
6
+ DB_PATH = Info::OptionParser.instance.options[:db_path]
7
+
8
+ ActiveRecord::Base.establish_connection(
9
+ adapter: 'sqlite3',
10
+ database: DB_PATH,
11
+ )
12
+
13
+ BASE_CONNECTION = ActiveRecord::Base.connection
14
+ API_KEY_TABLE = BASE_CONNECTION.execute('SELECT * FROM api_key').freeze
15
+ API_KEY = API_KEY_TABLE.first['api'].freeze
16
+ SECRET_KEY = API_KEY_TABLE.first['secret'].freeze
17
+
18
+ class << self
19
+ def data_coins
20
+ BASE_CONNECTION.execute('SELECT * FROM symbols WHERE statusOrder IS NOT "NO_ORDER"')
21
+ end
22
+
23
+ def data_settings
24
+ BASE_CONNECTION.execute('SELECT * FROM trade_params').first
25
+ end
26
+
27
+ def data_daily_profit
28
+ BASE_CONNECTION.execute("SELECT quote, sum(profit) FROM daily_profit GROUP BY quote")
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abot
4
+ module Info
5
+ class Coin
6
+ module CoinDecorator
7
+ def decorated_sell_up
8
+ rounded_sell_up = sell_up.round(2)
9
+ case num_averaging
10
+ when 1, 2
11
+ rounded_sell_up > 2 ? Paint[rounded_sell_up, :red] : rounded_sell_up
12
+ when 3, 4, 5
13
+ rounded_sell_up > 2 ? rounded_sell_up : Paint[rounded_sell_up, :red]
14
+ else
15
+ rounded_sell_up
16
+ end
17
+ end
18
+
19
+ def decorated_current_profit
20
+ rounded_current_profit = if trade_params['quote_asset'].split(' ').count > 1
21
+ "#{current_profit.round(2)} #{quote_asset}"
22
+ else
23
+ current_profit.round(2)
24
+ end
25
+ if current_profit >= 0
26
+ Paint[rounded_current_profit, :green]
27
+ else
28
+ Paint[rounded_current_profit, :red]
29
+ end
30
+ end
31
+
32
+ def decorated_num_averaging
33
+ Paint[num_averaging, (Helpers::AVERAGE_COLORS[num_averaging] || :red)]
34
+ end
35
+
36
+ def decorated_next_average_price_percent
37
+ "#{next_average_price_percent.round(2)}%"
38
+ end
39
+
40
+ def decorated_next_average_price
41
+ "#{next_average_price} (#{decorated_next_average_price_percent})"
42
+ end
43
+
44
+ def decorated_name
45
+ arrow = if account.symbol_info(symbol).try(:[], :lastPrice).to_f < account.symbol_info(symbol).try(:[], :askPrice).to_f
46
+ Paint[' ↑', :green]
47
+ elsif account.symbol_info(symbol).try(:[], :lastPrice).to_f > account.symbol_info(symbol).try(:[], :askPrice).to_f
48
+ Paint[' ↓', :red]
49
+ else
50
+ ''
51
+ end
52
+ "#{name}(#{account.symbol_info(symbol).try(:[], :priceChangePercent)}%)#{arrow}"
53
+ end
54
+
55
+ def decorated_name_mobile
56
+ name
57
+ end
58
+
59
+ def decorated_sell_price
60
+ sell_price
61
+ end
62
+
63
+ def decorated_current_price
64
+ "#{current_price} (#{percent_to_order.round(2)}%)"
65
+ end
66
+
67
+ def decorated_min_price
68
+ "#{min_price} (#{percent_from_min_to_average.round(2)}%)"
69
+ end
70
+
71
+ def decorated_max_price
72
+ "#{max_price} (#{percent_to_max.round(2)}%)"
73
+ end
74
+
75
+ def decorated_current_quote
76
+ current_quote.round(2)
77
+ end
78
+
79
+ def decorated_potential_profit
80
+ if trade_params['quote_asset'].split(' ').count > 1
81
+ "#{potential_profit.round(2)} #{quote_asset}"
82
+ else
83
+ potential_profit.round(2)
84
+ end
85
+ end
86
+
87
+ def decorated_buy_date
88
+ Time.at(timer.to_s[0..9].to_f).strftime('%d %h %H:%M')
89
+ rescue StandardError
90
+ '-'
91
+ end
92
+
93
+ def decorated_timer
94
+ medidas = ["г", "мес", "д", "ч", "м", "c"]
95
+ array = [1970, 1, 1, 0, 0, 0]
96
+ text = ''
97
+ Time.at(Time.now.to_i - timer.to_s[0..9].to_f).utc.to_a.take(6).reverse.each_with_index do |k, i|
98
+ case i
99
+ when 0, 1, 2
100
+ next if text.blank? && (k - array[i]).zero?
101
+
102
+ text = "#{text} #{k - array[i]}#{medidas[i]}"
103
+ when 3
104
+ next_text = (k - array[i]).to_s.rjust(2, '0')
105
+ text = "#{text} #{next_text}"
106
+ when 4
107
+ next_text = (k - array[i]).to_s.rjust(2, '0')
108
+ text = "#{text}:#{next_text}"
109
+ end
110
+ end
111
+ text
112
+ rescue StandardError
113
+ '-'
114
+ end
115
+
116
+ def decorated_cell(name)
117
+ str = public_send("decorated_#{name}")
118
+ value = Paint[str, (Helpers::AVERAGE_COLORS[num_averaging] || :red)]
119
+ row = { value: value }
120
+ row[:alignment] = :right if name == :timer
121
+ row
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abot
4
+ module Info
5
+ # helper
6
+ module Helpers
7
+ AVERAGE_COLORS = %i[
8
+ white
9
+ green
10
+ cyan
11
+ yellow
12
+ magenta
13
+ ].freeze
14
+
15
+ def self.daily_profit_str
16
+ profit = 'Профит сегодня:'
17
+ DatabaseTable.data_daily_profit.each do |c|
18
+ tick_size = (c['quote'] == 'BTC' ? 7 : 2)
19
+ profit += Paint[" #{c['sum(profit)'].round(tick_size)} #{c['quote']};", :green]
20
+ end
21
+ profit
22
+ end
23
+
24
+ def self.balance_str(account, current_coins, trade_params)
25
+ quote_assets = trade_params['quote_asset'].split(' ')
26
+ result = ''
27
+ balance_btc = account.current_balance_btc
28
+ quote_assets.each_with_index do |quote, idx|
29
+ result += "\n" unless idx.zero?
30
+ tick_size = (quote == 'BTC' ? 6 : 2)
31
+
32
+ balance = quote == 'BTC' ? balance_btc : account.current_balance(balance_btc, quote)
33
+ balance_wl = account.current_balance_wl(current_coins, quote)
34
+
35
+ cb = "Б: #{Paint["#{balance.round(tick_size)} ", :green]}"
36
+ cbwl = "Б(WL): #{Paint["#{balance_wl.round(tick_size)} ", :green]}"
37
+ fb = "С: #{Paint["#{account.free_balance(quote).round(tick_size)} " \
38
+ "(#{account.percent_free_balance(balance_wl, quote)}%)", :green]} "
39
+ result += (quote + ' ' + cb + cbwl + fb)
40
+ end
41
+ result
42
+ rescue StandardError
43
+ ''
44
+ end
45
+
46
+ def self.potential_balance_str(account, current_coins, trade_params)
47
+ "П: #{Paint["#{account.potential_balance(current_coins, trade_params).round(2)} USDT", :green]}"
48
+ end
49
+
50
+ def self.symbol_price_str(account, symbol_name)
51
+ symbol = account.symbol_info(symbol_name.to_s)
52
+ return '' if symbol.nil?
53
+
54
+ percent_color = symbol[:priceChangePercent].to_f.positive? ? :green : :red
55
+ "#{symbol_name}: " + Paint["#{symbol[:askPrice].to_f} (#{symbol[:priceChangePercent]}%)", percent_color]
56
+ end
57
+
58
+ def self.check_abot
59
+ Paint['WARNING: ПРОВЕРЬТЕ БОТА !!!', :black, :red] + "\n" if (Time.now - File.mtime(DatabaseTable::DB_PATH)) > 10
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'ostruct'
5
+
6
+ module Abot
7
+ module Info
8
+ # Класс раширяет возможности стандартного gem'а
9
+ #
10
+ class OptionParser < OptionParser
11
+ class Error < StandardError; end
12
+
13
+ Option = Struct.new(:type, :value, :required)
14
+ private_class_method :new
15
+
16
+ private
17
+
18
+ def initialize
19
+ super
20
+ @options = {}
21
+ end
22
+
23
+ protected
24
+
25
+ def add_help_about_envs
26
+ envs = @options.select { |_, option| option[:type] == :environment }
27
+
28
+ return if envs.empty?
29
+
30
+ separator ''
31
+ separator 'Accessed environments:'
32
+ envs.each do |key, v|
33
+ separator ' ' * 4 + [key.to_s.upcase, v[:value]].join('=')
34
+ end
35
+ end
36
+
37
+ def show_help!
38
+ show_help = @options.fetch(
39
+ :help,
40
+ Option.new(:option, false, false)
41
+ )
42
+
43
+ return unless show_help[:value]
44
+
45
+ add_help_about_envs
46
+
47
+ print help
48
+ # получение справочной информации
49
+ Process.exit! unless ENV.fetch('RUN_TEST', nil)
50
+ end
51
+
52
+ def show_version!
53
+ show_version = @options.fetch(
54
+ :version,
55
+ Option.new(:option, false, false)
56
+ )
57
+
58
+ return unless show_version[:value]
59
+
60
+ puts ver
61
+ # аналогично методу #show_help!
62
+ Process.exit! unless ENV.fetch('RUN_TEST', nil)
63
+ end
64
+
65
+ def validate!
66
+ required_options = @options.select do |_, option|
67
+ option[:required] && option[:value].nil?
68
+ end
69
+
70
+ return if required_options.empty?
71
+
72
+ raise Error, <<~ERROR
73
+ Having problems:
74
+ #{
75
+ required_options.map do |key, option|
76
+ case option[:type]
77
+ when :environment
78
+ "- environment '#{key.to_s.upcase}' is required"
79
+ when :option
80
+ "- option '#{key}' is required"
81
+ end
82
+ end.join("\n")
83
+ }
84
+ ERROR
85
+ end
86
+
87
+ public
88
+
89
+ def self.instance
90
+ @instance ||= new
91
+
92
+ yield @instance if block_given?
93
+
94
+ @instance
95
+ end
96
+
97
+ # Обрабатывает переменную среды окружения и регистрирует его в опциях
98
+ #
99
+ # Указанный ключ в аргументе метода, при поиске переменной среды окружения
100
+ # будет преобразован сл. образом: `:env_key => "ENV_KEY"`
101
+ #
102
+ # @param key [Symbol]
103
+ #
104
+ # @param default [Object]
105
+ #
106
+ # @param required [FalseClass,TrueClass]
107
+ #
108
+ def add_env(key, default: nil, required: false)
109
+ raise Error, 'call after parse' if frozen?
110
+
111
+ @options[key] = Option.new(
112
+ :environment,
113
+ environment(key.to_s.upcase) || default,
114
+ required
115
+ )
116
+ end
117
+
118
+ # Добавляет обработчик опций командной строки
119
+ #
120
+ # @param key [Symbol]
121
+ #
122
+ # @param args [Array]
123
+ #
124
+ # @param default [Object]
125
+ #
126
+ # @param required [FalseClass,TrueClass]
127
+ #
128
+ def add_option(key, *args, default: nil, required: false)
129
+ raise Error, 'call after parse' if frozen?
130
+
131
+ @options[key] = Option.new(
132
+ :option,
133
+ default,
134
+ required
135
+ )
136
+
137
+ on(*args) { |value| @options[key][:value] = value }
138
+ end
139
+
140
+ # Выполняет обработку входящих аргументов и читает конфигурационый файл
141
+ #
142
+ # @return [void]
143
+ #
144
+ def final!(args = ARGV)
145
+ raise Error, 'duplicate call #final!' if frozen?
146
+
147
+ parse args
148
+ load @options[:config_file] ? @options[:config_file][:value] : nil
149
+ # заполняет значение до заморозки экземпляра класса
150
+ @banner = "Usage: #{program_name} [options]" if @banner.nil?
151
+
152
+ freeze
153
+
154
+ show_help!
155
+ show_version!
156
+ validate!
157
+ end
158
+
159
+ # Возвращает полученные в результате работы парсера опции
160
+ #
161
+ # @param keys_filter [Array<Symbol>] исключает отсуствующие ключи
162
+ #
163
+ # @return [Hash]
164
+ #
165
+ def options(keys_filter = [])
166
+ raise Error, 'call before #final!' unless frozen?
167
+
168
+ result = @options.each_with_object({}) do |option, acc|
169
+ acc[option[0]] = option[1][:value] unless option[1][:value].nil?
170
+ end
171
+
172
+ return result if keys_filter.empty?
173
+
174
+ result.select { |k, _| keys_filter.include? k }
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'helpers'
3
+
4
+ module Abot
5
+ module Info
6
+ class Table < Terminal::Table
7
+ include Abot::Info::Helpers
8
+
9
+ attr_reader :current_coins, :account, :rate_coins, :columns, :trade_params
10
+
11
+ ALL_HEADINGS = {
12
+ name: 'Coin',
13
+ name_mobile: 'Coin',
14
+ sell_up: "sell\nup",
15
+ sell_price: 'Ордер',
16
+ max_price: 'Максимум 24h',
17
+ min_price: 'Минимум 24h',
18
+ current_price: 'Тек.цена',
19
+ next_average_price: "Следующее\nусреднение",
20
+ current_quote: "Тек.\nстоим.",
21
+ current_profit: "Тек.\nпрофит",
22
+ potential_profit: "Пот.\nпрофит",
23
+ buy_date: "Дата покупки",
24
+ timer: 'Время',
25
+ }.freeze
26
+ HEADINGS_COINS_TABLE_DEFAULT = %i[
27
+ name
28
+ sell_up
29
+ sell_price
30
+ max_price
31
+ current_price
32
+ min_price
33
+ next_average_price
34
+ current_quote
35
+ buy_date
36
+ timer
37
+ current_profit
38
+ potential_profit
39
+ ].freeze
40
+ HEADINGS_COINS_TABLE_MOBILE = %i[
41
+ name_mobile
42
+ sell_up
43
+ sell_price
44
+ current_price
45
+ next_average_price
46
+ ].freeze
47
+ COLUMN_WITH_TOTAL = %i[
48
+ current_profit
49
+ potential_profit
50
+ ].freeze
51
+
52
+ def initialize(options)
53
+ @account = options[:account]
54
+ @columns = options[:columns] || HEADINGS_COINS_TABLE_DEFAULT
55
+ @rate_coins = options.fetch(:rate_coins, [])
56
+ @trade_params ||= DatabaseTable.data_settings
57
+ @current_coins = Coin.current_coins(account, trade_params)
58
+
59
+
60
+ options = {
61
+ headings: columns.map { |h| { value: ALL_HEADINGS[h], alignment: :center } },
62
+ style: { border_x: '=' }
63
+ }
64
+ super options
65
+ end
66
+
67
+ def self.last_columns_set(col)
68
+ col = col.map { |m| ALL_HEADINGS.keys.include?(m) ? m : nil }.compact
69
+ COLUMN_WITH_TOTAL.each { |e| col << col.delete(e) }
70
+ col.compact
71
+ end
72
+
73
+ def generate
74
+ sorted_current_coins.each do |coin|
75
+ row = columns.map do |m|
76
+ coin.decorated_cell(m)
77
+ end
78
+ add_row row
79
+ end
80
+ add_separator
81
+ add_row row_total
82
+ self
83
+ end
84
+
85
+ def total_averages
86
+ av_arr = []
87
+ current_coins.each { |coin| av_arr[coin.num_averaging] = av_arr[coin.num_averaging].to_i + 1 }
88
+ count = av_arr.compact.sum
89
+ av_arr = av_arr.map.with_index do |av, idx|
90
+ Paint["#{idx}: #{av.to_i}", (Helpers::AVERAGE_COLORS[idx] || :red)] if av.to_i != 0
91
+ end.compact
92
+ av_arr << "∑: #{count}"
93
+ # av_arr << "M: #{available_coins_number}" if available_coins_number
94
+ av_arr.join("\n")
95
+ end
96
+
97
+ def info_str
98
+ "#{Helpers.check_abot}" \
99
+ "#{Helpers.daily_profit_str}\n" \
100
+ "#{Helpers.balance_str(account, current_coins, trade_params)}\n" \
101
+ "#{Helpers.potential_balance_str(account, current_coins, trade_params)}\n" \
102
+ "#{rate_coins_str}"
103
+ end
104
+
105
+ def rate_coins_str
106
+ result = ''
107
+ rate_coins.each_with_index do |c, idx|
108
+ number = colspan / 2
109
+ number += 1 if number.zero?
110
+
111
+ result += if idx != 0 && (idx % number).zero?
112
+ "\n"
113
+ elsif idx != 0
114
+ ' '
115
+ else
116
+ ''
117
+ end
118
+ str = Helpers.symbol_price_str(account, c.upcase)
119
+ str.slice!('USDT')
120
+ result += str
121
+ end
122
+ result
123
+ end
124
+
125
+ def available_coins_number
126
+ @available_coins_number ||= Coin.available_coins_number(
127
+ current_coins,
128
+ trade_params,
129
+ account.percent_free_balance(current_coins)
130
+ )
131
+ end
132
+
133
+ def colspan
134
+ (columns - COLUMN_WITH_TOTAL).count - 1
135
+ end
136
+
137
+ def row_total
138
+ row_total = [
139
+ total_averages,
140
+ { value: info_str, colspan: colspan }
141
+ ]
142
+ quote_assets = trade_params['quote_asset'].split(' ')
143
+ cell_current_profit = ''
144
+ cell_potential_profit = ''
145
+ quote_assets.each do |qa|
146
+ if quote_assets.count > 1
147
+ cell_current_profit += "#{Coin.sum_current_profit(current_coins, qa).round(2)} #{qa}\n"if columns.include?(:current_profit)
148
+ cell_potential_profit += "#{Coin.sum_potential_profit(current_coins, qa).round(2)} #{qa}\n" if columns.include?(:potential_profit)
149
+ else
150
+ cell_current_profit += Coin.sum_current_profit(current_coins, qa).round(2).to_s if columns.include?(:current_profit)
151
+ cell_potential_profit += Coin.sum_potential_profit(current_coins, qa).round(2).to_s if columns.include?(:potential_profit)
152
+ end
153
+ end
154
+ row_total << cell_current_profit if columns.include?(:current_profit)
155
+ row_total << cell_potential_profit if columns.include?(:potential_profit)
156
+ row_total
157
+ end
158
+
159
+ def sorted_current_coins
160
+ current_coins.sort_by(&:percent_to_order)
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abot
4
+ module Info
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ RSpec.describe Abot::Info do
2
+ it "has a version number" do
3
+ expect(Abot::Info::VERSION).not_to be nil
4
+ end
5
+
6
+ it "does something useful" do
7
+ expect(false).to eq(true)
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,175 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: abot-info
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - w_dmitrii
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-07-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.17'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.17'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activerecord
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 6.1.3
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 6.1.3
69
+ - !ruby/object:Gem::Dependency
70
+ name: paint
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 2.2.1
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 2.2.1
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 1.4.2
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 1.4.2
97
+ - !ruby/object:Gem::Dependency
98
+ name: terminal-table
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 3.0.1
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 3.0.1
111
+ - !ruby/object:Gem::Dependency
112
+ name: binance-ruby
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 1.3.1
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 1.3.1
125
+ description:
126
+ email:
127
+ - wiz.work2021@gmail.com
128
+ executables:
129
+ - abot-info
130
+ extensions: []
131
+ extra_rdoc_files:
132
+ - LICENSE
133
+ - README.md
134
+ files:
135
+ - Gemfile
136
+ - LICENSE
137
+ - README.md
138
+ - Rakefile
139
+ - abot-info.gemspec
140
+ - exe/abot-info
141
+ - lib/abot/info.rb
142
+ - lib/abot/info/binance_account.rb
143
+ - lib/abot/info/coin.rb
144
+ - lib/abot/info/database_table.rb
145
+ - lib/abot/info/decorators/coin_decorator.rb
146
+ - lib/abot/info/helpers.rb
147
+ - lib/abot/info/option_parser.rb
148
+ - lib/abot/info/table.rb
149
+ - lib/abot/info/version.rb
150
+ - spec/abot/statistics_spec.rb
151
+ homepage:
152
+ licenses:
153
+ - MIT
154
+ metadata: {}
155
+ post_install_message:
156
+ rdoc_options: []
157
+ require_paths:
158
+ - lib
159
+ required_ruby_version: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ version: 2.6.6
164
+ required_rubygems_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ requirements: []
170
+ rubygems_version: 3.0.9
171
+ signing_key:
172
+ specification_version: 4
173
+ summary: Abot Info
174
+ test_files:
175
+ - spec/abot/statistics_spec.rb