redis_exchange 0.3.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.
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Export an immutable JSON snapshot of all Redis Exchange state.
5
+ # Includes markets, assets, balances, order books, queues, user indexes,
6
+ # streams (trades/orders/account_ledger), tickers, and sequence counters.
7
+
8
+ require 'optparse'
9
+ require 'json'
10
+ require 'redis'
11
+ require 'time'
12
+ require 'fileutils'
13
+ require 'set'
14
+
15
+ begin
16
+ require_relative '../lib/redis_exchange/version'
17
+ rescue LoadError
18
+ end
19
+
20
+ options = {
21
+ redis_url: ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0',
22
+ output: File.join('tmp', "redis_exchange_export-#{Time.now.utc.strftime('%Y%m%d%H%M%S')}.json"),
23
+ pretty: true,
24
+ stream_batch: 1000,
25
+ max_stream_entries: nil,
26
+ quiet: false
27
+ }
28
+
29
+ OptionParser.new do |parser|
30
+ parser.banner = 'Usage: bundle exec ruby script/export_snapshot.rb [options]'
31
+
32
+ parser.on('--redis-url=URL', 'Redis connection URL (default: REDIS_URL or local)') do |url|
33
+ options[:redis_url] = url
34
+ end
35
+
36
+ parser.on('--output=PATH', 'Output file (default: tmp/redis_exchange_export-<timestamp>.json)') do |path|
37
+ options[:output] = path
38
+ end
39
+
40
+ parser.on('--[no-]pretty', 'Pretty print JSON (default: true)') do |flag|
41
+ options[:pretty] = flag
42
+ end
43
+
44
+ parser.on('--stream-batch=N', Integer, 'Stream pagination batch size (default: 1000)') do |size|
45
+ options[:stream_batch] = [size.to_i, 1].max
46
+ end
47
+
48
+ parser.on('--max-stream-entries=N', Integer, 'Optional limit per stream (default: all entries)') do |limit|
49
+ options[:max_stream_entries] = limit.to_i if limit
50
+ end
51
+
52
+ parser.on('--quiet', 'Suppress progress output') do
53
+ options[:quiet] = true
54
+ end
55
+ end.parse!
56
+
57
+ class SnapshotExporter
58
+ NUMERIC_REGEX = /\A-?\d+\z/
59
+ FLOAT_REGEX = /\A-?\d+\.\d+\z/
60
+
61
+ def initialize(redis, stream_batch:, max_stream_entries: nil)
62
+ @redis = redis
63
+ @stream_batch = [stream_batch.to_i, 1].max
64
+ @max_stream_entries = max_stream_entries && max_stream_entries > 0 ? max_stream_entries : nil
65
+ end
66
+
67
+ def call
68
+ markets = gather_hashes('market:*', strip_prefix: 'market:')
69
+ symbols = collect_symbols(markets.keys)
70
+
71
+ {
72
+ exported_at: Time.now.utc.iso8601,
73
+ metadata: metadata,
74
+ assets: gather_hashes('asset:*', strip_prefix: 'asset:'),
75
+ markets: markets,
76
+ tickers: gather_hashes('ticker:*', strip_prefix: 'ticker:'),
77
+ balances: gather_balances,
78
+ orders: gather_orders,
79
+ books: gather_books(symbols),
80
+ queues: gather_lists('queue:*', strip_prefix: 'queue:'),
81
+ user_order_indexes: gather_zsets('user_orders:*', strip_prefix: 'user_orders:'),
82
+ streams: {
83
+ trades: gather_streams('trades:*', strip_prefix: 'trades:'),
84
+ orders: gather_streams('orders:*', strip_prefix: 'orders:'),
85
+ account_ledger: read_stream('account_ledger')
86
+ },
87
+ sequences: {
88
+ market: gather_sequences('seq:*', strip_prefix: 'seq:'),
89
+ ledger: gather_ledger_sequences
90
+ }
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ def collect_symbols(existing)
97
+ symbols = Set.new(existing)
98
+ pattern_index = {
99
+ 'book:*' => 1,
100
+ 'volume:*' => 1,
101
+ 'trades:*' => 1,
102
+ 'orders:*' => 1,
103
+ 'ticker:*' => 1,
104
+ 'queue:*' => 1
105
+ }
106
+ pattern_index.each do |pattern, idx|
107
+ scan_keys(pattern).each do |key|
108
+ parts = key.split(':')
109
+ symbols << parts[idx] if parts[idx]
110
+ end
111
+ end
112
+ symbols.to_a
113
+ end
114
+
115
+ def metadata
116
+ meta = { ruby_version: RUBY_VERSION }
117
+ if defined?(RedisExchange::VERSION)
118
+ meta[:redis_exchange_version] = RedisExchange::VERSION
119
+ end
120
+ meta
121
+ end
122
+
123
+ def gather_hashes(pattern, strip_prefix: nil)
124
+ result = {}
125
+ scan_keys(pattern).sort.each do |key|
126
+ data = normalize_hash(@redis.hgetall(key))
127
+ next if data.empty?
128
+ result[strip_key(key, strip_prefix)] = data
129
+ end
130
+ result
131
+ end
132
+
133
+ def gather_balances
134
+ result = {}
135
+ scan_keys('balance:*').sort.each do |key|
136
+ raw = @redis.hgetall(key)
137
+ next if raw.empty?
138
+ user = strip_key(key, 'balance:')
139
+ structured = {}
140
+ raw.each do |field, value|
141
+ if field.include?(':')
142
+ asset, bucket = field.split(':', 2)
143
+ next unless bucket
144
+ structured[asset] ||= {}
145
+ structured[asset][bucket] = coerce_number(value)
146
+ else
147
+ structured[field] = coerce_number(value)
148
+ end
149
+ end
150
+ result[user] = structured unless structured.empty?
151
+ end
152
+ result
153
+ end
154
+
155
+ def gather_orders
156
+ result = {}
157
+ scan_keys('order:*').sort.each do |key|
158
+ data = normalize_hash(@redis.hgetall(key))
159
+ next if data.empty?
160
+ result[strip_key(key, 'order:')] = data
161
+ end
162
+ result
163
+ end
164
+
165
+ def gather_books(symbols)
166
+ result = {}
167
+ symbols.sort.each do |symbol|
168
+ buy = fetch_book_side(symbol, 'BUY')
169
+ sell = fetch_book_side(symbol, 'SELL')
170
+ volume_buy = normalize_hash(@redis.hgetall("volume:#{symbol}:BUY"))
171
+ volume_sell = normalize_hash(@redis.hgetall("volume:#{symbol}:SELL"))
172
+ next if buy.empty? && sell.empty? && volume_buy.empty? && volume_sell.empty?
173
+ result[symbol] = {
174
+ buy: buy,
175
+ sell: sell,
176
+ volume: {
177
+ buy: volume_buy,
178
+ sell: volume_sell
179
+ }
180
+ }
181
+ end
182
+ result
183
+ end
184
+
185
+ def gather_lists(pattern, strip_prefix: nil)
186
+ result = {}
187
+ scan_keys(pattern).sort.each do |key|
188
+ values = @redis.lrange(key, 0, -1)
189
+ next if values.empty?
190
+ result[strip_key(key, strip_prefix)] = values
191
+ end
192
+ result
193
+ end
194
+
195
+ def gather_zsets(pattern, strip_prefix: nil)
196
+ result = {}
197
+ scan_keys(pattern).sort.each do |key|
198
+ entries = fetch_zset(key)
199
+ next if entries.empty?
200
+ result[strip_key(key, strip_prefix)] = entries
201
+ end
202
+ result
203
+ end
204
+
205
+ def gather_streams(pattern, strip_prefix: nil)
206
+ result = {}
207
+ scan_keys(pattern).sort.each do |key|
208
+ entries = read_stream(key)
209
+ next if entries.empty?
210
+ result[strip_key(key, strip_prefix)] = entries
211
+ end
212
+ result
213
+ end
214
+
215
+ def gather_sequences(pattern, strip_prefix: nil)
216
+ result = {}
217
+ scan_keys(pattern).sort.each do |key|
218
+ value = @redis.get(key)
219
+ next unless value
220
+ result[strip_key(key, strip_prefix)] = coerce_number(value)
221
+ end
222
+ result
223
+ end
224
+
225
+ def gather_ledger_sequences
226
+ result = {}
227
+ scan_keys('ledger_seq:*').sort.each do |key|
228
+ parts = key.split(':', 3)
229
+ next unless parts.length == 3
230
+ user = parts[1]
231
+ asset = parts[2]
232
+ seq = @redis.get(key)
233
+ next unless seq
234
+ result[user] ||= {}
235
+ result[user][asset] = coerce_number(seq)
236
+ end
237
+ result
238
+ end
239
+
240
+ def fetch_book_side(symbol, side)
241
+ book_key = "book:#{symbol}:#{side}"
242
+ normalize_zset(@redis.zrange(book_key, 0, -1, with_scores: true)).map do |member, score|
243
+ entry = { value: member, score: coerce_number(score) }
244
+ numeric = integer_like?(member) ? member.to_i : nil
245
+ entry[:value_numeric] = numeric if numeric
246
+ entry
247
+ end
248
+ end
249
+
250
+ def fetch_zset(key)
251
+ normalize_zset(@redis.zrange(key, 0, -1, with_scores: true)).map do |member, score|
252
+ entry = { member: member, score: coerce_number(score) }
253
+ entry[:member_numeric] = member.to_i if integer_like?(member)
254
+ entry
255
+ end
256
+ end
257
+
258
+ def read_stream(key)
259
+ entries = []
260
+ start = '-'
261
+ remaining = @max_stream_entries
262
+
263
+ loop do
264
+ count = @stream_batch
265
+ count = [count, remaining].min if remaining
266
+ batch = count ? @redis.xrange(key, start, '+', count: count) : @redis.xrange(key, start, '+')
267
+ break if batch.nil? || batch.empty?
268
+
269
+ batch.each do |unit|
270
+ id, fields = unit
271
+ entries << { id: id, fields: normalize_hash(fields_to_hash(fields)) }
272
+ end
273
+
274
+ break if batch.length < (count || @stream_batch)
275
+
276
+ if remaining
277
+ remaining -= batch.length
278
+ break if remaining <= 0
279
+ end
280
+
281
+ start = increment_stream_id(batch.last[0])
282
+ end
283
+
284
+ entries
285
+ end
286
+
287
+ def scan_keys(pattern)
288
+ keys = []
289
+ cursor = '0'
290
+ loop do
291
+ cursor, batch = @redis.scan(cursor, match: pattern, count: 200)
292
+ keys.concat(Array(batch))
293
+ break if cursor == '0'
294
+ end
295
+ keys
296
+ end
297
+
298
+ def strip_key(key, strip_prefix)
299
+ return key unless strip_prefix
300
+ key.start_with?(strip_prefix) ? key[strip_prefix.length..-1] : key
301
+ end
302
+
303
+ def normalize_hash(hash)
304
+ hash.each_with_object({}) do |(k, v), acc|
305
+ acc[k] = coerce_number(v)
306
+ end
307
+ end
308
+
309
+ def fields_to_hash(fields)
310
+ return fields if fields.is_a?(Hash)
311
+ array = Array(fields)
312
+ result = {}
313
+ array.each_slice(2) do |field, value|
314
+ result[field] = value
315
+ end
316
+ result
317
+ end
318
+
319
+ def normalize_zset(raw)
320
+ array = Array(raw)
321
+ return array if array.empty?
322
+ if array.first.is_a?(Array)
323
+ array
324
+ else
325
+ array.each_slice(2).to_a
326
+ end
327
+ end
328
+
329
+ def coerce_number(value)
330
+ return value if value.nil?
331
+ return value if value.is_a?(Numeric)
332
+ string = value.to_s
333
+ return string.to_i if integer_like?(string)
334
+ return string.to_f if string.match?(FLOAT_REGEX)
335
+ string
336
+ end
337
+
338
+ def integer_like?(value)
339
+ return false if value.nil?
340
+ value = value.to_s unless value.is_a?(String)
341
+ value.match?(NUMERIC_REGEX)
342
+ end
343
+
344
+ def increment_stream_id(last_id)
345
+ ms, seq = last_id.split('-', 2)
346
+ ms_i = ms.to_i
347
+ seq_i = seq.to_i
348
+ "#{ms_i}-#{seq_i + 1}"
349
+ end
350
+ end
351
+
352
+ redis = Redis.new(url: options[:redis_url])
353
+ exporter = SnapshotExporter.new(
354
+ redis,
355
+ stream_batch: options[:stream_batch],
356
+ max_stream_entries: options[:max_stream_entries]
357
+ )
358
+
359
+ payload = exporter.call
360
+
361
+ FileUtils.mkdir_p(File.dirname(options[:output]))
362
+ json = options[:pretty] ? JSON.pretty_generate(payload) : JSON.generate(payload)
363
+ File.write(options[:output], json)
364
+
365
+ puts "Export completed: #{options[:output]}" unless options[:quiet]
366
+
367
+ begin
368
+ redis.close if redis.respond_to?(:close)
369
+ rescue StandardError
370
+ end
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../../lib/redis_exchange'
5
+ require 'optparse'
6
+ require 'json'
7
+ require 'fileutils'
8
+
9
+ options = {}
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: adjust_precision.rb [options]"
12
+
13
+ opts.on("-s", "--symbol SYMBOL", "Market symbol (e.g. BTCUSDT)") { |v| options[:symbol] = v }
14
+ opts.on("-p", "--price-scale SCALE", Integer, "New price scale") { |v| options[:price_scale] = v }
15
+ opts.on("-q", "--quantity-scale SCALE", Integer, "New quantity scale") { |v| options[:quantity_scale] = v }
16
+ opts.on("-y", "--yes", "Skip confirmation") { |v| options[:yes] = true }
17
+ opts.on("-o", "--output DIR", "Output directory for snapshots (default: ./tmp/snapshots)") { |v| options[:output] = v }
18
+ end.parse!
19
+
20
+ if options[:symbol].nil? || options[:price_scale].nil? || options[:quantity_scale].nil?
21
+ puts "Missing required arguments."
22
+ puts "Usage: bundle exec ruby script/maintenance/adjust_precision.rb -s BTCUSDT -p 100 -q 1000"
23
+ exit 1
24
+ end
25
+
26
+ SYMBOL = options[:symbol]
27
+ NEW_PRICE_SCALE = options[:price_scale]
28
+ NEW_QTY_SCALE = options[:quantity_scale]
29
+ OUTPUT_DIR = options[:output] || './tmp/snapshots'
30
+
31
+ FileUtils.mkdir_p(OUTPUT_DIR)
32
+
33
+ client = RedisExchange::Client.new
34
+
35
+ puts "šŸš€ Starting Precision Adjustment for #{SYMBOL}..."
36
+ puts " New Price Scale: #{NEW_PRICE_SCALE}"
37
+ puts " New Quantity Scale: #{NEW_QTY_SCALE}"
38
+
39
+ # 1. Snapshot Old State
40
+ puts "\nšŸ“ø Taking initial snapshots..."
41
+ old_balances_path = File.join(OUTPUT_DIR, "balances_old_#{Time.now.to_i}.json")
42
+ old_book_path = File.join(OUTPUT_DIR, "book_old_#{Time.now.to_i}.json")
43
+
44
+ client.maintenance.snapshot_balances(output_path: old_balances_path)
45
+ client.maintenance.snapshot_order_book(symbol: SYMBOL, output_path: old_book_path)
46
+ puts " Saved to #{old_balances_path} and #{old_book_path}"
47
+
48
+ # Load snapshots into memory for verification
49
+ old_balances = JSON.parse(File.read(old_balances_path))
50
+ old_book = JSON.parse(File.read(old_book_path), symbolize_names: true)
51
+
52
+ # 2. Simulate
53
+ puts "\nšŸ”® Simulating precision update (Dry Run)..."
54
+ simulation = client.maintenance.update_market_precision(
55
+ symbol: SYMBOL,
56
+ new_price_scale: NEW_PRICE_SCALE,
57
+ new_quantity_scale: NEW_QTY_SCALE,
58
+ dry: true
59
+ )
60
+
61
+ if simulation[:error]
62
+ puts "āŒ Simulation Failed: #{simulation[:error]}"
63
+ exit 1
64
+ end
65
+
66
+ new_book_simulated = simulation[:simulated_order_book]
67
+ revised_balances = simulation[:simulated_balance_adjustments]
68
+
69
+ puts " Simulation Result:"
70
+ puts " - Orders in New Book: #{new_book_simulated[:bids].size + new_book_simulated[:asks].size}"
71
+ puts " - Balance Revisions (Dust Refunds): #{revised_balances.size}"
72
+
73
+ # 3. Verify Pre-Check
74
+ puts "\nmagnifying_glass_tilted_left: Verifying Simulation..."
75
+
76
+ # Check Balance Invariant (Total Balance should remain constant)
77
+ # Apply revisions to old balances and see if it makes sense?
78
+ # Actually, the invariant is:
79
+ # Old Total (Avail + Rsrv) == New Total (Avail + Rsrv)
80
+ # The simulation returns revisions: delta is added to Available and removed from Reserved.
81
+ # So Total Balance IS preserved by definition of the simulation logic.
82
+ # Let's verify that revisions don't exceed reserved balances?
83
+ errors = []
84
+ revised_balances.each do |rev|
85
+ uid = rev[:user_id]
86
+ asset = rev[:asset]
87
+ delta = rev[:delta]
88
+
89
+ user_bal = old_balances[uid]
90
+ unless user_bal && user_bal[asset]
91
+ errors << "User #{uid} asset #{asset} not found in old balances but has revision"
92
+ next
93
+ end
94
+
95
+ if user_bal[asset]['reserved'] < delta
96
+ errors << "User #{uid} asset #{asset} reserved balance (#{user_bal[asset]['reserved']}) < refund delta (#{delta})"
97
+ end
98
+ end
99
+
100
+ if errors.any?
101
+ puts "āŒ Verification Failed:"
102
+ errors.each { |e| puts " - #{e}" }
103
+ exit 1
104
+ else
105
+ puts "āœ… Balance Invariant Check Passed"
106
+ end
107
+
108
+ # 4. Execute
109
+ unless options[:yes]
110
+ print "\nāš ļø Ready to execute? This will lock the market and modify data. (y/N): "
111
+ ans = $stdin.gets.chomp
112
+ unless ans.downcase == 'y'
113
+ puts "Aborted."
114
+ exit 0
115
+ end
116
+ end
117
+
118
+ puts "\n⚔ Executing Precision Update..."
119
+ result = client.maintenance.update_market_precision(
120
+ symbol: SYMBOL,
121
+ new_price_scale: NEW_PRICE_SCALE,
122
+ new_quantity_scale: NEW_QTY_SCALE,
123
+ dry: false
124
+ )
125
+
126
+ if result[:error]
127
+ puts "āŒ Execution Failed: #{result[:error]}"
128
+ exit 1
129
+ end
130
+ puts " Result: #{result}"
131
+
132
+ # 5. Verify Post-Check
133
+ puts "\nšŸ“ø Taking post-execution snapshots..."
134
+ new_balances_path = File.join(OUTPUT_DIR, "balances_new_#{Time.now.to_i}.json")
135
+ new_book_path = File.join(OUTPUT_DIR, "book_new_#{Time.now.to_i}.json")
136
+
137
+ client.maintenance.snapshot_balances(output_path: new_balances_path)
138
+ client.maintenance.snapshot_order_book(symbol: SYMBOL, output_path: new_book_path)
139
+
140
+ puts " Saved to #{new_balances_path} and #{new_book_path}"
141
+
142
+ puts "\nšŸ” Verifying Invariants (Old vs New)..."
143
+
144
+ # Load new balances
145
+ new_balances = JSON.parse(File.read(new_balances_path))
146
+
147
+ # Check Total Balance Invariant
148
+ balance_errors = []
149
+ old_balances.each do |uid, assets|
150
+ assets.each do |asset, old_b|
151
+ old_total = old_b['available'] + old_b['reserved']
152
+
153
+ new_b = new_balances.dig(uid, asset)
154
+ unless new_b
155
+ # Maybe user removed? Unlikely.
156
+ balance_errors << "User #{uid} asset #{asset} missing in new balances"
157
+ next
158
+ end
159
+
160
+ new_total = new_b['available'] + new_b['reserved']
161
+
162
+ if old_total != new_total
163
+ balance_errors << "User #{uid} asset #{asset} total mismatch: Old #{old_total} != New #{new_total}"
164
+ end
165
+ end
166
+ end
167
+
168
+ if balance_errors.any?
169
+ puts "āŒ CRITICAL: Balance Invariant Failed!"
170
+ balance_errors.first(10).each { |e| puts " - #{e}" }
171
+ puts " ... and #{balance_errors.size - 10} more" if balance_errors.size > 10
172
+ else
173
+ puts "āœ… All Balance Invariants Passed (Total Assets Preserved)"
174
+ end
175
+
176
+ puts "\n✨ Precision Adjustment Completed Successfully."