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,641 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisExchange
4
+ class Maintenance
5
+ MAINTENANCE_KEY = 'system:maintenance_mode'
6
+
7
+ def initialize(redis)
8
+ @redis = redis
9
+
10
+ migrate_batch_path = File.expand_path('../../redis_scripts/migrate_orders_batch.lua', __dir__)
11
+ @migrate_batch_script = register_script(read_script(migrate_batch_path))
12
+
13
+ finalize_precision_path = File.expand_path('../../redis_scripts/finalize_precision_update.lua', __dir__)
14
+ @finalize_precision_script = register_script(read_script(finalize_precision_path))
15
+
16
+ force_cancel_path = File.expand_path('../../redis_scripts/force_cancel_order.lua', __dir__)
17
+ @force_cancel_script = register_script(read_script(force_cancel_path))
18
+
19
+ audit_balance_path = File.expand_path('../../redis_scripts/audit_user_balance.lua', __dir__)
20
+ @audit_balance_script = register_script(read_script(audit_balance_path))
21
+ end
22
+
23
+ # Audit and reconcile user balances against active orders.
24
+ # @param user_id [String,nil] specific user to audit, or nil for all users
25
+ # @param fix [Boolean] whether to apply fixes automatically
26
+ # @return [Array<Hash>] report of issues found/fixed per user
27
+ def audit_user_balances(user_id: nil, fix: true)
28
+ # 1. Gather Symbols
29
+ symbols = []
30
+ cursor = '0'
31
+ loop do
32
+ cursor, keys = @redis.scan(cursor, match: 'market:*', count: 100)
33
+ keys.each { |k| symbols << k.sub('market:', '') }
34
+ break if cursor == '0'
35
+ end
36
+
37
+ # 2. Gather Users
38
+ users = []
39
+ if user_id
40
+ users << user_id
41
+ else
42
+ cursor = '0'
43
+ loop do
44
+ cursor, keys = @redis.scan(cursor, match: 'balance:*', count: 1000)
45
+ keys.each { |k| users << k.sub('balance:', '') }
46
+ break if cursor == '0'
47
+ end
48
+ end
49
+
50
+ # 3. Run Audit per User
51
+ reports = []
52
+ ts = current_millis
53
+
54
+ users.each do |uid|
55
+ payload = {
56
+ keys: ["balance:#{uid}"],
57
+ argv: [uid, fix ? '1' : '0', ts.to_s, *symbols]
58
+ }
59
+
60
+ raw = call_script(@audit_balance_script, payload)
61
+ res = JSON.parse(raw, symbolize_names: true)
62
+
63
+ if !res[:deficits].empty? || !res[:fixes].empty?
64
+ reports << { user_id: uid, deficits: res[:deficits], fixes: res[:fixes] }
65
+ end
66
+ end
67
+
68
+ reports
69
+ end
70
+
71
+ # Replay ledger events from a starting point and compare against actual balances.
72
+ # @param initial_balances [Hash] { user_id => { asset => { bucket => amount } } }
73
+ # @param start_id [String] Ledger ID to start replay from (exclusive)
74
+ # @param end_id [String] Ledger ID to stop replay at (inclusive)
75
+ # @param actual_balances [Hash] Current actual balances to compare against
76
+ # @return [Hash] { success: Boolean, discrepancies: Array<Hash>, expected_balances: Hash }
77
+ def replay_ledger_audit(initial_balances:, start_id:, end_id:, actual_balances:)
78
+ # Deep copy initial balances to avoid mutation
79
+ calculated = JSON.parse(JSON.generate(initial_balances))
80
+
81
+ # Replay Ledger
82
+ batch_size = 1000
83
+ current_id = start_id
84
+
85
+ loop do
86
+ # XRANGE is inclusive, so we handle the exclusive start logic carefully.
87
+ # If start_id is '0-0', we use '-'. Otherwise we use '(' + start_id if supported,
88
+ # but Redis XRANGE min is inclusive.
89
+ # Standard pattern: use '(' prefix for exclusive start in XRANGE (Redis 6.2+).
90
+ # Assuming Redis 6.2+. If older, we fetch and skip first if matches.
91
+
92
+ range_start = current_id == '0-0' ? '-' : "(#{current_id}"
93
+ entries = @redis.xrange('account_ledger', range_start, end_id, count: batch_size)
94
+
95
+ break if entries.empty?
96
+
97
+ entries.each do |id, fields|
98
+ user_id = fields['user_id']
99
+ asset = fields['asset']
100
+
101
+ calculated[user_id] ||= {}
102
+ calculated[user_id][asset] ||= { RedisExchange::BUCKET_AVAILABLE => 0, RedisExchange::BUCKET_RESERVED => 0 }
103
+
104
+ if fields['action']
105
+ # New format: action + amount
106
+ action = fields['action']
107
+ amount = fields['amount'].to_i
108
+
109
+ case action
110
+ when RedisExchange::ACTION_ADD
111
+ calculated[user_id][asset][RedisExchange::BUCKET_AVAILABLE] += amount
112
+ when RedisExchange::ACTION_SUB
113
+ calculated[user_id][asset][RedisExchange::BUCKET_AVAILABLE] -= amount
114
+ when RedisExchange::ACTION_LOCK
115
+ calculated[user_id][asset][RedisExchange::BUCKET_AVAILABLE] -= amount
116
+ calculated[user_id][asset][RedisExchange::BUCKET_RESERVED] += amount
117
+ when RedisExchange::ACTION_UNLOCK
118
+ calculated[user_id][asset][RedisExchange::BUCKET_RESERVED] -= amount
119
+ calculated[user_id][asset][RedisExchange::BUCKET_AVAILABLE] += amount
120
+ when RedisExchange::ACTION_SUB_LOCKED
121
+ calculated[user_id][asset][RedisExchange::BUCKET_RESERVED] -= amount
122
+ when RedisExchange::ACTION_ADD_LOCKED
123
+ calculated[user_id][asset][RedisExchange::BUCKET_RESERVED] += amount
124
+ end
125
+ else
126
+ # Legacy format: bucket + delta
127
+ bucket = fields['bucket']
128
+ delta = fields['delta'].to_i
129
+ if bucket && calculated[user_id][asset][bucket]
130
+ calculated[user_id][asset][bucket] += delta
131
+ end
132
+ end
133
+
134
+ current_id = id
135
+ end
136
+
137
+ break if entries.size < batch_size
138
+ end
139
+
140
+ # Compare
141
+ discrepancies = []
142
+
143
+ # Check all calculated against actual
144
+ calculated.each do |uid, assets|
145
+ assets.each do |asset, buckets|
146
+ buckets.each do |bucket, expected_amt|
147
+ actual_amt = actual_balances.dig(uid, asset, bucket) || 0
148
+ if expected_amt != actual_amt
149
+ discrepancies << {
150
+ user_id: uid,
151
+ asset: asset,
152
+ bucket: bucket,
153
+ expected: expected_amt,
154
+ actual: actual_amt
155
+ }
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ # Check for any actual balances that shouldn't exist (not in calculated)
162
+ # We skip this for now as initial_balances might be partial?
163
+ # Assuming initial_balances + ledger covers everything.
164
+
165
+ {
166
+ success: discrepancies.empty?,
167
+ discrepancies: discrepancies,
168
+ expected_balances: calculated
169
+ }
170
+ end
171
+
172
+ # Force cancel an order, bypassing status checks.
173
+ # @param order_id [String]
174
+ # @param mode [Symbol] :delete_only or :best_effort_unlock
175
+ # @return [Hash]
176
+ def force_cancel_order(order_id:, mode: :best_effort_unlock)
177
+ mode_str = case mode
178
+ when :delete_only then RedisExchange::CANCEL_MODE_DELETE_ONLY
179
+ when :best_effort_unlock then RedisExchange::CANCEL_MODE_BEST_EFFORT_UNLOCK
180
+ else raise ArgumentError, "invalid mode: #{mode}"
181
+ end
182
+
183
+ payload = {
184
+ keys: ["order:#{order_id}"],
185
+ argv: [order_id, mode_str]
186
+ }
187
+ raw = call_script(@force_cancel_script, payload)
188
+ JSON.parse(raw, symbolize_names: true)
189
+ end
190
+
191
+ # Update market precision and revise all active orders using batched processing.
192
+ # Handles market status changes to ensure safety.
193
+ # @param symbol [String]
194
+ # @param new_price_scale [Integer]
195
+ # @param new_quantity_scale [Integer]
196
+ # @param batch_size [Integer] number of price levels to process per batch
197
+ # @param dry [Boolean] if true, simulate only and return predicted changes
198
+ # @return [Hash] { updated_orders: Integer, status: 'OK' } or { error: String } or { new_order_book: Hash, revised_balances: Array, status: 'SIMULATED' }
199
+ def update_market_precision(symbol:, new_price_scale:, new_quantity_scale:, batch_size: 20, dry: false)
200
+ # 1. Ensure Market is NOT OPEN (Use MAINTENANCE mode)
201
+ initial_status = @redis.hget("market:#{symbol}", 'status')
202
+ return { error: 'UNKNOWN_MARKET' } unless initial_status
203
+
204
+ if initial_status == RedisExchange::MARKET_STATUS_OPEN
205
+ enable_market(symbol) # Sets status to MAINTENANCE
206
+ end
207
+
208
+ if dry
209
+ begin
210
+ res = simulate_precision_update(symbol, new_price_scale, new_quantity_scale)
211
+ res[:dry] = true
212
+ return res
213
+ ensure
214
+ # Restore status for dry run
215
+ if initial_status == RedisExchange::MARKET_STATUS_OPEN
216
+ current_status = @redis.hget("market:#{symbol}", 'status')
217
+ disable_market(symbol) if current_status == RedisExchange::MARKET_STATUS_MAINTENANCE
218
+ end
219
+ end
220
+ end
221
+
222
+ updated_count = 0
223
+
224
+ begin
225
+ # 2. Process BUY and SELL sides
226
+ [RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
227
+ book_key = "book:#{symbol}:#{side}"
228
+ price_members = @redis.zrange(book_key, 0, -1)
229
+
230
+ price_members.each_slice(batch_size) do |batch|
231
+ res = run_migrate_batch(symbol, side, new_price_scale, new_quantity_scale, batch)
232
+ if res[:error]
233
+ # Restore status and raise/return error
234
+ disable_market(symbol) if initial_status == RedisExchange::MARKET_STATUS_OPEN
235
+ return res
236
+ end
237
+ updated_count += res[:processed_orders].to_i
238
+ end
239
+ end
240
+
241
+ # 3. Finalize and switch
242
+ finalize_res = run_finalize_precision(symbol, new_price_scale, new_quantity_scale)
243
+ if finalize_res[:error]
244
+ disable_market(symbol) if initial_status == RedisExchange::MARKET_STATUS_OPEN
245
+ return finalize_res
246
+ end
247
+
248
+ ensure
249
+ # 4. Restore Status (if it was OPEN originally and we didn't crash)
250
+ if initial_status == RedisExchange::MARKET_STATUS_OPEN
251
+ current_status = @redis.hget("market:#{symbol}", 'status')
252
+ if current_status == RedisExchange::MARKET_STATUS_MAINTENANCE
253
+ disable_market(symbol)
254
+ end
255
+ end
256
+ end
257
+
258
+ { updated_orders: updated_count, status: 'OK' }
259
+ end
260
+
261
+
262
+ def enable
263
+ @redis.set(MAINTENANCE_KEY, '1')
264
+ end
265
+
266
+ def disable
267
+ @redis.del(MAINTENANCE_KEY)
268
+ end
269
+
270
+ def enabled?
271
+ @redis.get(MAINTENANCE_KEY) == '1'
272
+ end
273
+
274
+ def enable_market(symbol)
275
+ # Store previous status if we want to be fancy, but simple maintenance overwrite is standard.
276
+ # However, snapshot_order_book relies on disable_market restoring order.
277
+ # Let's change enable_market to return previous status?
278
+ # No, public API change. Let's just set status=MAINTENANCE.
279
+ # But snapshot_order_book needs to handle restoration.
280
+ # Since snapshot_order_book is inside this class, we can update it.
281
+ @redis.hset("market:#{symbol}", 'status', 'MAINTENANCE')
282
+ end
283
+
284
+ def disable_market(symbol)
285
+ # Default to OPEN when disabling maintenance manually
286
+ @redis.hset("market:#{symbol}", 'status', 'OPEN')
287
+ end
288
+
289
+ def market_enabled?(symbol)
290
+ @redis.hget("market:#{symbol}", 'status') == 'MAINTENANCE'
291
+ end
292
+
293
+ private
294
+
295
+ def run_migrate_batch(symbol, side, new_price_scale, new_quantity_scale, prices)
296
+ payload = {
297
+ keys: [
298
+ "market:#{symbol}",
299
+ "book:#{symbol}:#{side}:TMP",
300
+ "volume:#{symbol}:#{side}:TMP",
301
+ "orders:#{symbol}"
302
+ ],
303
+ argv: [
304
+ symbol,
305
+ side,
306
+ new_price_scale.to_s,
307
+ new_quantity_scale.to_s,
308
+ current_millis.to_s,
309
+ *prices.map(&:to_s)
310
+ ]
311
+ }
312
+ raw = call_script(@migrate_batch_script, payload)
313
+ JSON.parse(raw, symbolize_names: true)
314
+ end
315
+
316
+ def run_finalize_precision(symbol, new_price_scale, new_quantity_scale)
317
+ payload = {
318
+ keys: [
319
+ "market:#{symbol}",
320
+ "book:#{symbol}:BUY",
321
+ "book:#{symbol}:SELL",
322
+ "volume:#{symbol}:BUY",
323
+ "volume:#{symbol}:SELL",
324
+ "book:#{symbol}:BUY:TMP",
325
+ "book:#{symbol}:SELL:TMP",
326
+ "volume:#{symbol}:BUY:TMP",
327
+ "volume:#{symbol}:SELL:TMP"
328
+ ],
329
+ argv: [
330
+ symbol,
331
+ new_price_scale.to_s,
332
+ new_quantity_scale.to_s
333
+ ]
334
+ }
335
+ raw = call_script(@finalize_precision_script, payload)
336
+ JSON.parse(raw, symbolize_names: true)
337
+ end
338
+
339
+ def current_millis
340
+ (Time.now.to_f * 1000).floor
341
+ end
342
+
343
+ def read_script(path)
344
+ return nil unless path && File.exist?(path)
345
+ File.read(path)
346
+ end
347
+
348
+ def register_script(lua_source)
349
+ raise ArgumentError, 'missing Lua source' unless lua_source && !lua_source.empty?
350
+ LuaScript.new(lua_source)
351
+ end
352
+
353
+ def call_script(script, payload)
354
+ script.call(@redis, **payload)
355
+ end
356
+
357
+ class LuaScript
358
+ def initialize(source)
359
+ @source = source
360
+ @sha = nil
361
+ end
362
+
363
+ def call(redis, keys:, argv:)
364
+ @sha ||= redis.script(:load, @source)
365
+ redis.evalsha(@sha, keys: keys, argv: argv)
366
+ rescue ::Redis::CommandError => e
367
+ if e.message =~ /NOSCRIPT/
368
+ @sha = redis.script(:load, @source)
369
+ retry
370
+ else
371
+ raise
372
+ end
373
+ end
374
+ end
375
+
376
+ public
377
+
378
+
379
+ # Atomically snapshot order book for a symbol.
380
+ # Uses market-level maintenance mode to ensure consistency without blocking Redis.
381
+ # @param symbol [String] market symbol
382
+ # @param output_path [String,nil] if provided, streams JSON output to this file; otherwise returns Hash
383
+ # @return [Hash,String] returns Hash if output_path is nil; otherwise returns output_path
384
+ def snapshot_order_book(symbol:, output_path: nil)
385
+ # Capture previous status to restore correctly
386
+ previous_status = @redis.hget("market:#{symbol}", 'status') || 'OPEN'
387
+ enable_market(symbol)
388
+ output = output_path ? File.open(output_path, 'w') : nil
389
+
390
+ begin
391
+ result_hash = output ? nil : { symbol: symbol, bids: [], asks: [] }
392
+
393
+ if output
394
+ output.write("{\"symbol\":\"#{symbol}\",\"timestamp\":#{Time.now.to_i},\"bids\":[")
395
+ end
396
+
397
+ # Stream Bids
398
+ stream_book_side(symbol, 'BUY', output, result_hash ? result_hash[:bids] : nil)
399
+
400
+ if output
401
+ output.write("],\"asks\":[")
402
+ end
403
+
404
+ # Stream Asks
405
+ stream_book_side(symbol, 'SELL', output, result_hash ? result_hash[:asks] : nil)
406
+
407
+ if output
408
+ output.write("]}")
409
+ output.close
410
+ output_path
411
+ else
412
+ result_hash
413
+ end
414
+ ensure
415
+ if output && !output.closed?
416
+ output.close
417
+ end
418
+ # Restore previous status instead of blind disable
419
+ @redis.hset("market:#{symbol}", 'status', previous_status)
420
+ end
421
+ end
422
+
423
+ # Atomically snapshot all user balances.
424
+ # Uses system maintenance mode to ensure consistency without blocking Redis.
425
+ # @param output_path [String,nil] if provided and is a file path, writes to it.
426
+ # If provided and is a directory, generates a filename using the latest ledger stream ID.
427
+ # @param lock_system [Boolean] whether to lock the system in maintenance mode during snapshot (default: true)
428
+ # @return [Hash,String] returns Hash if output_path is nil; otherwise returns output_path
429
+ def snapshot_balances(output_path: nil, lock_system: true)
430
+ enable if lock_system
431
+
432
+ # Determine output file path
433
+ final_output_path = output_path
434
+ if output_path && File.directory?(output_path)
435
+ last_entry = @redis.xrevrange('account_ledger', '+', '-', count: 1).first
436
+ ledger_id = last_entry ? last_entry[0] : '0-0'
437
+ final_output_path = File.join(output_path, "balances_#{ledger_id}.json")
438
+ end
439
+
440
+ output = final_output_path ? File.open(final_output_path, 'w') : {}
441
+
442
+ begin
443
+ if output.is_a?(File)
444
+ output.write('{')
445
+ end
446
+
447
+ first_entry = true
448
+ cursor = '0'
449
+ loop do
450
+ cursor, keys = @redis.scan(cursor, match: 'balance:*', count: 1000)
451
+
452
+ if keys.any?
453
+ # Pipeline HGETALL for this batch
454
+ responses = @redis.pipelined do |pipeline|
455
+ keys.each { |k| pipeline.hgetall(k) }
456
+ end
457
+
458
+ keys.each_with_index do |key, idx|
459
+ user_id = key.sub('balance:', '')
460
+ fields = responses[idx]
461
+
462
+ user_balance = {}
463
+ fields.each do |field, value|
464
+ # Field format: "ASSET:available" or "ASSET:reserved"
465
+ parts = field.split(':')
466
+ if parts.length == 2
467
+ asset = parts[0]
468
+ type = parts[1]
469
+ if type == 'available' || type == 'reserved'
470
+ user_balance[asset] ||= { 'available' => 0, 'reserved' => 0 }
471
+ user_balance[asset][type] = value.to_i
472
+ end
473
+ end
474
+ end
475
+
476
+ unless user_balance.empty?
477
+ if output.is_a?(File)
478
+ output.write(",\n") unless first_entry
479
+ output.write("#{JSON.generate(user_id)}:#{JSON.generate(user_balance)}")
480
+ first_entry = false
481
+ else
482
+ output[user_id] = user_balance
483
+ end
484
+ end
485
+ end
486
+ end
487
+
488
+ break if cursor == '0'
489
+ end
490
+
491
+ if output.is_a?(File)
492
+ output.write('}')
493
+ output.close
494
+ final_output_path
495
+ else
496
+ output
497
+ end
498
+ ensure
499
+ if output.is_a?(File) && !output.closed?
500
+ output.close
501
+ end
502
+ disable if lock_system
503
+ end
504
+ end
505
+
506
+ private
507
+
508
+ def stream_book_side(symbol, side, file_output, array_output)
509
+ book_key = "book:#{symbol}:#{side}"
510
+ volume_key = "volume:#{symbol}:#{side}"
511
+
512
+ cursor = '0'
513
+ first_entry = true
514
+
515
+ loop do
516
+ cursor, prices_with_scores = @redis.zscan(book_key, cursor, count: 1000)
517
+
518
+ if prices_with_scores.any?
519
+ prices = prices_with_scores.map(&:first)
520
+ volumes = @redis.hmget(volume_key, *prices)
521
+
522
+ prices.each_with_index do |price, idx|
523
+ entry = { price: price.to_i, volume: volumes[idx].to_i }
524
+
525
+ if file_output
526
+ file_output.write(',') unless first_entry
527
+ file_output.write(JSON.generate(entry))
528
+ first_entry = false
529
+ else
530
+ array_output << entry
531
+ end
532
+ end
533
+ end
534
+
535
+ break if cursor == '0'
536
+ end
537
+ end
538
+
539
+ def simulate_precision_update(symbol, new_price_scale, new_quantity_scale)
540
+ market_config = @redis.hgetall("market:#{symbol}")
541
+ base_asset = market_config['base_asset']
542
+ quote_asset = market_config['quote_asset']
543
+ old_price_scale = market_config['price_scale'].to_i
544
+ old_quantity_scale = market_config['quantity_scale'].to_i
545
+ maker_fee_bps = market_config['maker_fee_bps'].to_i
546
+
547
+ base_balance_scale = @redis.hget("asset:#{base_asset}", 'balance_scale').to_i
548
+ quote_balance_scale = @redis.hget("asset:#{quote_asset}", 'balance_scale').to_i
549
+
550
+ new_base_factor = (base_balance_scale / new_quantity_scale.to_f).floor
551
+ new_quote_factor = (quote_balance_scale / new_price_scale.to_f).floor
552
+
553
+ if (new_base_factor * new_quantity_scale) != base_balance_scale ||
554
+ (new_quote_factor * new_price_scale) != quote_balance_scale
555
+ return { error: 'ASSET_SCALE_MISMATCH' }
556
+ end
557
+
558
+ new_order_book = { symbol: symbol, bids: [], asks: [] }
559
+ revised_balances = []
560
+ updated_orders = 0
561
+
562
+ # Helper to simulate processing an order
563
+ process_order = lambda do |order_id, price, side|
564
+ order = @redis.hgetall("order:#{order_id}")
565
+ return unless order['order_id']
566
+ updated_orders += 1
567
+
568
+ old_qty = order['quantity'].to_i
569
+ old_rem = order['remaining'].to_i
570
+ old_reserved_base = order['reserved_base'].to_i
571
+ old_reserved_quote = order['reserved_quote'].to_i
572
+ user_id = order['user_id']
573
+
574
+ new_price = (price * new_price_scale.to_f / old_price_scale).floor
575
+ new_qty = (old_qty * new_quantity_scale.to_f / old_quantity_scale).floor
576
+ new_rem = (old_rem * new_quantity_scale.to_f / old_quantity_scale).floor
577
+
578
+ new_reserved_base = 0
579
+ new_reserved_quote = 0
580
+
581
+ if side == RedisExchange::SIDE_SELL
582
+ new_reserved_base = new_rem * new_base_factor
583
+ else
584
+ quote_val = ((new_price * new_rem) / new_quantity_scale.to_f).floor
585
+ fee = ((quote_val * maker_fee_bps) / 10000.0).floor
586
+ total_quote = quote_val + fee
587
+ new_reserved_quote = total_quote * new_quote_factor
588
+ end
589
+
590
+ refund_base = old_reserved_base - new_reserved_base
591
+ refund_quote = old_reserved_quote - new_reserved_quote
592
+
593
+ if refund_base > 0
594
+ revised_balances << { user_id: user_id, asset: base_asset, delta: refund_base, type: 'DUST_REFUND' }
595
+ end
596
+ if refund_quote > 0
597
+ revised_balances << { user_id: user_id, asset: quote_asset, delta: refund_quote, type: 'DUST_REFUND' }
598
+ end
599
+
600
+ if new_rem > 0
601
+ entry = { price: new_price, volume: new_rem }
602
+ target_list = (side == RedisExchange::SIDE_BUY) ? new_order_book[:bids] : new_order_book[:asks]
603
+
604
+ # Aggregate volume for same price
605
+ existing = target_list.find { |e| e[:price] == new_price }
606
+ if existing
607
+ existing[:volume] += new_rem
608
+ else
609
+ target_list << entry
610
+ end
611
+ end
612
+ end
613
+
614
+ # Process Asks (SELL)
615
+ price_members = @redis.zrange("book:#{symbol}:#{RedisExchange::SIDE_SELL}", 0, -1)
616
+ price_members.each do |pm|
617
+ orders = @redis.lrange("queue:#{symbol}:#{RedisExchange::SIDE_SELL}:#{pm}", 0, -1)
618
+ orders.each { |oid| process_order.call(oid, pm.to_i, RedisExchange::SIDE_SELL) }
619
+ end
620
+
621
+ # Process Bids (BUY)
622
+ # Bids are stored with negative scores, but price members are positive strings
623
+ price_members = @redis.zrange("book:#{symbol}:#{RedisExchange::SIDE_BUY}", 0, -1)
624
+ price_members.each do |pm|
625
+ orders = @redis.lrange("queue:#{symbol}:#{RedisExchange::SIDE_BUY}:#{pm}", 0, -1)
626
+ orders.each { |oid| process_order.call(oid, pm.to_i, RedisExchange::SIDE_BUY) }
627
+ end
628
+
629
+ # Sort simulated books
630
+ new_order_book[:asks].sort_by! { |e| e[:price] }
631
+ new_order_book[:bids].sort_by! { |e| -e[:price] }
632
+
633
+ {
634
+ simulated_order_book: new_order_book,
635
+ simulated_balance_adjustments: revised_balances,
636
+ updated_orders: updated_orders,
637
+ status: 'SIMULATED'
638
+ }
639
+ end
640
+ end
641
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stream_consumer'
4
+
5
+ module RedisExchange
6
+ # OrderStreamConsumer
7
+ # -------------------
8
+ # Encapsulates Redis Streams consumer-group operations for `orders:{symbol}`.
9
+ class OrderStreamConsumer < StreamConsumer
10
+ attr_reader :symbol
11
+
12
+ def initialize(redis:, symbol:, group:, consumer:)
13
+ @symbol = symbol
14
+ super(redis: redis, group: group, consumer: consumer, stream_key: "orders:#{symbol}")
15
+ end
16
+ end
17
+ end