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,1544 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RedisExchange::Client
4
+ # ----------------------
5
+ # High-level Ruby façade for the Redis/Lua matching engine.
6
+ #
7
+ # Responsibilities:
8
+ # - Load and register the `match_order.lua` script against a provided Redis
9
+ # connection.
10
+ # - Expose convenience helpers to seed market metadata, manage balances,
11
+ # submit limit and market orders, and clear a market during tests.
12
+ # - Surfaces `ticker(symbol)` which reads the `ticker:{symbol}` hash maintained
13
+ # by the Lua script (last trade + best bid/ask snapshot).
14
+ #
15
+ # Numeric conventions:
16
+ # - All amounts are integer minor units; scales are defined in `seed_market`.
17
+ # - Keep values < 9e15 to stay within Lua double precision.
18
+
19
+ require 'json'
20
+ require 'bigdecimal'
21
+ require 'bigdecimal/util'
22
+ require 'redis' unless defined?(Redis)
23
+ require 'time'
24
+ require 'securerandom'
25
+
26
+ module RedisExchange
27
+ # Returns the root directory of the gem
28
+ def self.root
29
+ File.expand_path('..', __dir__)
30
+ end
31
+
32
+ TICKER_FIELDS = %w[
33
+ last_price
34
+ last_quantity
35
+ last_quote
36
+ last_timestamp
37
+ best_bid_price
38
+ best_bid_volume
39
+ best_ask_price
40
+ best_ask_volume
41
+ ].freeze
42
+
43
+ # Order Sides
44
+ SIDE_BUY = 'BUY'
45
+ SIDE_SELL = 'SELL'
46
+ SIDES = [SIDE_BUY, SIDE_SELL].freeze
47
+
48
+ # Order Types
49
+ ORDER_TYPE_LIMIT = 'LIMIT'
50
+ ORDER_TYPE_MARKET = 'MARKET'
51
+ ORDER_TYPES = [ORDER_TYPE_LIMIT, ORDER_TYPE_MARKET].freeze
52
+
53
+ # Order Statuses
54
+ ORDER_STATUS_OPEN = 'OPEN'
55
+ ORDER_STATUS_PARTIALLY_FILLED = 'PARTIALLY_FILLED'
56
+ ORDER_STATUS_FILLED = 'FILLED'
57
+ ORDER_STATUS_CANCELLED = 'CANCELLED'
58
+ ORDER_STATUS_REJECTED = 'REJECTED'
59
+ ORDER_STATUS_UNKNOWN = 'UNKNOWN'
60
+ ORDER_STATUS_NOT_FOUND = 'NOT_FOUND'
61
+
62
+ # Market Statuses
63
+ MARKET_STATUS_OPEN = 'OPEN'
64
+ MARKET_STATUS_HALTED = 'HALTED'
65
+ MARKET_STATUS_CLOSED = 'CLOSED'
66
+ MARKET_STATUS_MAINTENANCE = 'MAINTENANCE'
67
+ MARKET_STATUSES = [
68
+ MARKET_STATUS_OPEN,
69
+ MARKET_STATUS_HALTED,
70
+ MARKET_STATUS_CLOSED,
71
+ MARKET_STATUS_MAINTENANCE
72
+ ].freeze
73
+
74
+ # Asset Statuses
75
+ ASSET_STATUS_ACTIVE = 'ACTIVE'
76
+ ASSET_STATUS_HALTED = 'HALTED'
77
+ ASSET_STATUSES = [ASSET_STATUS_ACTIVE, ASSET_STATUS_HALTED].freeze
78
+
79
+ # Balance Actions
80
+ ACTION_ADD = 'ADD'
81
+ ACTION_SUB = 'SUB'
82
+ ACTION_LOCK = 'LOCK'
83
+ ACTION_UNLOCK = 'UNLOCK'
84
+ ACTION_ADD_LOCKED = 'ADD_LOCKED'
85
+ ACTION_SUB_LOCKED = 'SUB_LOCKED'
86
+ BALANCE_ACTIONS = [
87
+ ACTION_ADD, ACTION_SUB, ACTION_LOCK, ACTION_UNLOCK,
88
+ ACTION_ADD_LOCKED, ACTION_SUB_LOCKED
89
+ ].freeze
90
+
91
+ # Balance Buckets
92
+ BUCKET_AVAILABLE = 'available'
93
+ BUCKET_RESERVED = 'reserved'
94
+ BUCKETS = [BUCKET_AVAILABLE, BUCKET_RESERVED].freeze
95
+
96
+ # Ledger Reasons
97
+ REASON_DEPOSIT = 'DEPOSIT'
98
+ REASON_WITHDRAWAL = 'WITHDRAWAL'
99
+ REASON_MANUAL = 'MANUAL'
100
+ REASON_MANUAL_ADJUST = 'MANUAL_ADJUST'
101
+ REASON_LOCK = 'LOCK'
102
+ REASON_UNLOCK = 'UNLOCK'
103
+
104
+ # Force Cancel Modes
105
+ CANCEL_MODE_DELETE_ONLY = 'DELETE_ONLY'
106
+ CANCEL_MODE_BEST_EFFORT_UNLOCK = 'BEST_EFFORT_UNLOCK'
107
+ CANCEL_MODES = [CANCEL_MODE_DELETE_ONLY, CANCEL_MODE_BEST_EFFORT_UNLOCK].freeze
108
+
109
+ # Error Codes
110
+ # System/Market Status Errors
111
+ ERR_SYSTEM_MAINTENANCE = 'SYSTEM_MAINTENANCE'
112
+ ERR_MARKET_MAINTENANCE = 'MARKET_MAINTENANCE'
113
+ ERR_MARKET_HALTED = 'MARKET_HALTED'
114
+ ERR_MARKET_CLOSED = 'MARKET_CLOSED'
115
+
116
+ # Argument Validation Errors
117
+ ERR_INVALID_ARGUMENTS = 'INVALID_ARGUMENTS'
118
+ ERR_INVALID_ORDER_TYPE = 'INVALID_ORDER_TYPE'
119
+ ERR_INVALID_SIDE = 'INVALID_SIDE'
120
+ ERR_INVALID_PRICE_OR_QUANTITY = 'INVALID_PRICE_OR_QUANTITY'
121
+ ERR_INVALID_AMOUNT = 'INVALID_AMOUNT'
122
+ ERR_INVALID_ACTION = 'INVALID_ACTION'
123
+ ERR_INVALID_ORDER = 'INVALID_ORDER'
124
+
125
+ # Order Errors
126
+ ERR_DUPLICATE_ORDER_ID = 'DUPLICATE_ORDER_ID'
127
+ ERR_UNKNOWN_MARKET = 'UNKNOWN_MARKET'
128
+
129
+ # Asset Errors
130
+ ERR_ASSET_NOT_CONFIGURED = 'ASSET_NOT_CONFIGURED'
131
+ ERR_ASSET_SCALE_MISMATCH = 'ASSET_SCALE_MISMATCH'
132
+ ERR_ASSET_SCALE_MISMATCH_WITH_NEW_CONFIG = 'ASSET_SCALE_MISMATCH_WITH_NEW_CONFIG'
133
+
134
+ # Trading Rule Errors
135
+ ERR_MIN_QTY_NOT_SATISFIED = 'MIN_QTY_NOT_SATISFIED'
136
+ ERR_MIN_NOTIONAL_NOT_SATISFIED = 'MIN_NOTIONAL_NOT_SATISFIED'
137
+ ERR_PRECISION_OVERFLOW = 'PRECISION_OVERFLOW'
138
+
139
+ # Balance Errors
140
+ ERR_INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS'
141
+ ERR_INSUFFICIENT_FUNDS_TO_REST = 'INSUFFICIENT_FUNDS_TO_REST'
142
+
143
+ # Max integer safe for Lua double precision (approx 9e15)
144
+ MAX_SAFE_INTEGER = 9_000_000_000_000_000
145
+
146
+ # Orders used to re-read market, asset and user-fee configuration on every
147
+ # request (four extra round trips per order). Those hashes only change when an
148
+ # admin mutates them, so the client caches them for this many seconds; every
149
+ # mutator in this file invalidates the cache immediately.
150
+ DEFAULT_CONFIG_CACHE_TTL = 5.0
151
+ end
152
+
153
+ require_relative 'redis_exchange/version'
154
+ ## No Ruby fallback or in-memory shim; all logic uses real Redis + Lua
155
+
156
+ module RedisExchange
157
+ # Client encapsulates Lua script execution and convenience helpers.
158
+ class Client
159
+ attr_reader :redis
160
+
161
+ # @param redis [Redis,nil] configured Redis connection; if nil, auto-connect
162
+ # Auto-connect priority: ENV['REDIS_URL'] -> 'redis://127.0.0.1:6379/0'
163
+ def initialize(redis: nil)
164
+ @redis = redis || Redis.new(url: (ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0'))
165
+
166
+ @match_order_script = load_script('match_order.lua')
167
+ @cancel_order_script = load_script('cancel_order.lua')
168
+ @balance_op_script = load_script('balance_op.lua')
169
+ @audit_script = load_script('audit_user_balance.lua')
170
+ @update_precision_script = load_script('update_market_precision.lua')
171
+
172
+ # Cache for market/asset/user-fee config used by the order path. Filled by
173
+ # #order_config, invalidated by every mutator that touches those hashes.
174
+ @config_cache = {}
175
+ @config_cache_ttl = RedisExchange::DEFAULT_CONFIG_CACHE_TTL
176
+ end
177
+
178
+ # Seconds a cached market/asset/user-fee entry stays valid. Set to 0 to
179
+ # disable caching (every order then re-reads configuration from Redis).
180
+ attr_accessor :config_cache_ttl
181
+
182
+ # Drop every cached configuration entry. Called by the seed/set/delete APIs;
183
+ # call it directly after mutating configuration outside this client.
184
+ def clear_config_cache!
185
+ @config_cache.clear
186
+ nil
187
+ end
188
+
189
+ # Submit a limit order and return the Redis/Lua response hash.
190
+ # @param symbol [String] market symbol
191
+ # @param side [String, Symbol] 'BUY' or 'SELL'
192
+ # @param order_id [String] unique order identifier
193
+ # @param user_id [String] owner of the order
194
+ # @param price [Integer, Float, BigDecimal] limit price
195
+ # @param quantity [Integer, Float, BigDecimal] quantity
196
+ # @param timestamp_ms [Integer,nil] optional epoch in milliseconds
197
+ # @param auto_scale [Boolean] if true, scales price and quantity using market config
198
+ # @return [Hash] parsed Lua response
199
+ def place_limit_order(symbol:, side:, order_id:, user_id:, price:, quantity:, timestamp_ms: nil, auto_scale: false)
200
+ final_price = auto_scale ? scale_price(symbol, price) : price.to_i
201
+ final_quantity = auto_scale ? scale_quantity(symbol, quantity) : quantity.to_i
202
+
203
+ raise ArgumentError, 'price must be positive for limit orders' unless final_price.positive?
204
+ raise ArgumentError, 'quantity must be positive for limit orders' unless final_quantity.positive?
205
+
206
+ execute_order(
207
+ symbol: symbol,
208
+ side: side,
209
+ order_type: RedisExchange::ORDER_TYPE_LIMIT,
210
+ order_id: order_id,
211
+ user_id: user_id,
212
+ price_limit: final_price,
213
+ quantity: final_quantity,
214
+ timestamp_ms: timestamp_ms
215
+ )
216
+ end
217
+
218
+ # Submit a market order; optional price_cap acts as a worst-case limit.
219
+ # @param price_cap [Integer, Float, BigDecimal] 0 for uncapped markets
220
+ # @param auto_scale [Boolean] if true, scales price_cap and quantity using market config
221
+ # Other params mirror #place_limit_order
222
+ def place_market_order(symbol:, side:, order_id:, user_id:, quantity:, price_cap: 0, timestamp_ms: nil, auto_scale: false)
223
+ final_price_cap = auto_scale ? scale_price(symbol, price_cap) : price_cap.to_i
224
+ final_quantity = auto_scale ? scale_quantity(symbol, quantity) : quantity.to_i
225
+
226
+ raise ArgumentError, 'price_cap must be non-negative' if final_price_cap.negative?
227
+
228
+ execute_order(
229
+ symbol: symbol,
230
+ side: side,
231
+ order_type: RedisExchange::ORDER_TYPE_MARKET,
232
+ order_id: order_id,
233
+ user_id: user_id,
234
+ price_limit: final_price_cap,
235
+ quantity: final_quantity,
236
+ timestamp_ms: timestamp_ms
237
+ )
238
+ end
239
+
240
+ # Return the ticker hash for a market with integer values.
241
+ # @param symbol [String] market symbol
242
+ # @return [Hash{Symbol=>Integer}] last trade + top-of-book snapshot
243
+ def ticker(symbol)
244
+ raw = @redis.hgetall("ticker:#{symbol}")
245
+ return {} if raw.empty?
246
+
247
+ RedisExchange::TICKER_FIELDS.to_h do |field|
248
+ [field.to_sym, raw.fetch(field, '0').to_i]
249
+ end
250
+ end
251
+
252
+ # Seed or update market metadata.
253
+ # @param symbol [String] market symbol (e.g., 'BTCUSDT')
254
+ # @param base_asset [String] base asset code (e.g., 'BTC')
255
+ # @param quote_asset [String] quote asset code (e.g., 'USDT')
256
+ # @param price_scale [Integer] price precision scale (price in quote minor units)
257
+ # @param quantity_scale [Integer] quantity precision scale (quantity in base minor units)
258
+ # @param maker_fee_bps [Integer] maker fee in basis points (0..10000)
259
+ # @param taker_fee_bps [Integer] taker fee in basis points (0..10000)
260
+ # @param min_qty [Integer] minimum order quantity in base minor units
261
+ # @param min_notional [Integer] minimum notional in quote minor units
262
+ # @param trades_maxlen [Integer,nil] max trades stream length; nil uses default (1_000_000)
263
+ # @param orders_maxlen [Integer,nil] max orders stream length; nil uses default (1_000_000)
264
+ def seed_market(symbol:, base_asset:, quote_asset:, price_scale: 1, quantity_scale: 1,
265
+ maker_fee_bps: 0, taker_fee_bps: 0, min_qty: 1, min_notional: 1,
266
+ status: RedisExchange::MARKET_STATUS_OPEN, trades_maxlen: nil, orders_maxlen: nil)
267
+ key = "market:#{symbol}"
268
+ stat = status.to_s.upcase
269
+ raise ArgumentError, 'invalid market status' unless RedisExchange::MARKET_STATUSES.include?(stat)
270
+ mapping = {
271
+ 'base_asset' => base_asset,
272
+ 'quote_asset' => quote_asset,
273
+ 'price_scale' => price_scale,
274
+ 'quantity_scale' => quantity_scale,
275
+ 'maker_fee_bps' => maker_fee_bps,
276
+ 'taker_fee_bps' => taker_fee_bps,
277
+ 'min_qty' => min_qty,
278
+ 'min_notional' => min_notional,
279
+ 'status' => stat
280
+ }
281
+ mapping['trades_maxlen'] = trades_maxlen.to_i if trades_maxlen
282
+ mapping['orders_maxlen'] = orders_maxlen.to_i if orders_maxlen
283
+ @redis.hset(key, mapping)
284
+ clear_config_cache!
285
+ end
286
+
287
+ def market_exist?(symbol)
288
+ @redis.exists?("market:#{symbol}")
289
+ end
290
+
291
+ def set_market_status(symbol:, status:)
292
+ key = "market:#{symbol}"
293
+ return { error: 'UNKNOWN_MARKET' } unless @redis.exists?(key)
294
+
295
+ stat = status.to_s.upcase
296
+ raise ArgumentError, 'invalid market status' unless RedisExchange::MARKET_STATUSES.include?(stat)
297
+
298
+ @redis.hset(key, 'status', stat)
299
+ clear_config_cache!
300
+ market_config(symbol)
301
+ end
302
+
303
+ def set_market_fees(symbol:, maker_fee_bps:, taker_fee_bps:)
304
+ key = "market:#{symbol}"
305
+ return { error: 'UNKNOWN_MARKET' } unless @redis.exists?(key)
306
+
307
+ maker_fee_bps = maker_fee_bps.to_i
308
+ taker_fee_bps = taker_fee_bps.to_i
309
+ raise ArgumentError, 'maker_fee_bps must be between 0 and 10000' unless (0..10000).include?(maker_fee_bps)
310
+ raise ArgumentError, 'taker_fee_bps must be between 0 and 10000' unless (0..10000).include?(taker_fee_bps)
311
+
312
+ @redis.hset(key, 'maker_fee_bps', maker_fee_bps, 'taker_fee_bps', taker_fee_bps)
313
+ clear_config_cache!
314
+ { ok: true, symbol: symbol, maker_fee_bps: maker_fee_bps, taker_fee_bps: taker_fee_bps }
315
+ end
316
+
317
+ # Seed or update an asset's balance scale and status.
318
+ # Balances and ledger entries MUST use this scale; matching converts market deltas into asset units.
319
+ # @param asset [String] asset code (e.g., 'BTC')
320
+ # @param balance_scale [Integer] integer minor unit scale for balances/ledger
321
+ # @param status [String] 'ACTIVE' | 'HALTED'
322
+ def seed_asset(asset:, balance_scale:, status: RedisExchange::ASSET_STATUS_ACTIVE)
323
+ raise ArgumentError, 'balance_scale must be positive' unless balance_scale.to_i > 0
324
+ stat = status.to_s.upcase
325
+ raise ArgumentError, 'invalid status' unless RedisExchange::ASSET_STATUSES.include?(stat)
326
+ key = "asset:#{asset}"
327
+ mapping = {
328
+ 'balance_scale' => balance_scale.to_i,
329
+ 'status' => stat
330
+ }
331
+ @redis.hset(key, mapping)
332
+ clear_config_cache!
333
+ end
334
+
335
+ # Read asset configuration.
336
+ # @param asset [String]
337
+ # @return [Hash] { balance_scale: Integer, status: String } or {}
338
+ def asset_config(asset)
339
+ parse_asset_config(@redis.hgetall("asset:#{asset}"))
340
+ end
341
+
342
+ # Read market configuration.
343
+ # @param symbol [String]
344
+ # @return [Hash] full market config or {} if not found
345
+ def market_config(symbol)
346
+ raw = @redis.hgetall("market:#{symbol}")
347
+ return {} if raw.empty?
348
+ {
349
+ base_asset: raw.fetch('base_asset', nil),
350
+ quote_asset: raw.fetch('quote_asset', nil),
351
+ price_scale: raw.fetch('price_scale', '1').to_i,
352
+ quantity_scale: raw.fetch('quantity_scale', '1').to_i,
353
+ maker_fee_bps: raw.fetch('maker_fee_bps', '0').to_i,
354
+ taker_fee_bps: raw.fetch('taker_fee_bps', '0').to_i,
355
+ min_qty: raw.fetch('min_qty', '1').to_i,
356
+ min_notional: raw.fetch('min_notional', '1').to_i,
357
+ status: raw.fetch('status', RedisExchange::MARKET_STATUS_OPEN),
358
+ trades_maxlen: raw.fetch('trades_maxlen', '1000000').to_i,
359
+ orders_maxlen: raw.fetch('orders_maxlen', '1000000').to_i,
360
+ order_tombstone_ttl_sec: raw.fetch('order_tombstone_ttl_sec', '86400').to_i
361
+ }
362
+ end
363
+
364
+ # Validate that asset balance scales are compatible with a market's scales (integer multiples).
365
+ # @return [Hash] { ok: true, base_factor: Integer, quote_factor: Integer } or { ok: false, error: 'ASSET_NOT_CONFIGURED'|'ASSET_SCALE_MISMATCH', asset: String, balance_scale: Integer, market_scale: Integer }
366
+ def validate_asset_scales_for_market(base_asset:, quote_asset:, price_scale:, quantity_scale:)
367
+ base_cfg = asset_config(base_asset)
368
+ quote_cfg = asset_config(quote_asset)
369
+ base_bs = base_cfg[:balance_scale].to_i
370
+ quote_bs = quote_cfg[:balance_scale].to_i
371
+ unless base_bs.positive? && quote_bs.positive?
372
+ missing = !base_bs.positive? ? base_asset : quote_asset
373
+ return { ok: false, error: 'ASSET_NOT_CONFIGURED', asset: missing }
374
+ end
375
+ ps = price_scale.to_i
376
+ qs = quantity_scale.to_i
377
+ base_factor = base_bs / qs
378
+ quote_factor = quote_bs / ps
379
+ unless (base_factor * qs) == base_bs
380
+ return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: base_asset, balance_scale: base_bs, market_scale: qs }
381
+ end
382
+ unless (quote_factor * ps) == quote_bs
383
+ return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: quote_asset, balance_scale: quote_bs, market_scale: ps }
384
+ end
385
+ { ok: true, base_factor: base_factor, quote_factor: quote_factor }
386
+ end
387
+
388
+ # Overwrite a user's available/reserved buckets for an asset.
389
+ def set_balance(user_id:, asset:, available: 0, reserved: 0)
390
+ key = "balance:#{user_id}"
391
+ mapping = {
392
+ "#{asset}:available" => available,
393
+ "#{asset}:reserved" => reserved
394
+ }
395
+ @redis.hset(key, mapping)
396
+ end
397
+
398
+ # Fetch all balance fields for a user as integers keyed by symbols.
399
+ # @param user_id [String]
400
+ # @return [Hash{Symbol=>Integer}]
401
+ # Get all balances for a user.
402
+ # @param user_id [String] user identifier
403
+ # @param auto_scale [Boolean] if true, converts integer balances to human-readable floats
404
+ # @return [Hash{Symbol=>Integer,Float}]
405
+ def balances(user_id, auto_scale: false)
406
+ raw = @redis.hgetall("balance:#{user_id}")
407
+ scales = {} if auto_scale
408
+
409
+ raw.each_with_object({}) do |(field, value), result|
410
+ val = value.to_i
411
+ if auto_scale
412
+ asset = field.split(':').first
413
+ scales[asset] ||= (asset_config(asset)[:balance_scale] || 1)
414
+ val = val.to_f / scales[asset]
415
+ end
416
+ result[field.to_sym] = val
417
+ end
418
+ end
419
+
420
+ # Get balance for a specific asset.
421
+ # @param user_id [String]
422
+ # @param asset [String]
423
+ # @param auto_scale [Boolean]
424
+ # @param include_ledger_seq [Boolean] include ledger_seq in result
425
+ # @return [Hash] { available: Integer|Float, reserved: Integer|Float, ledger_seq: Integer|nil }
426
+ def balance(user_id:, asset:, auto_scale: false, include_ledger_seq: false)
427
+ key = "balance:#{user_id}"
428
+ vals = @redis.hmget(key, "#{asset}:available", "#{asset}:reserved")
429
+ available = vals[0].to_i
430
+ reserved = vals[1].to_i
431
+
432
+ result = { available: available, reserved: reserved }
433
+
434
+ if include_ledger_seq
435
+ ledger_seq = @redis.get("ledger_seq:#{user_id}:#{asset}")&.to_i || 0
436
+ result[:ledger_seq] = ledger_seq
437
+ end
438
+
439
+ if auto_scale
440
+ scale = asset_config(asset)[:balance_scale] || 1
441
+ result[:available] = available.to_d / scale
442
+ result[:reserved] = reserved.to_d / scale
443
+ end
444
+
445
+ result
446
+ end
447
+
448
+ # Retrieve an order snapshot by id with typed fields.
449
+ # @param order_id [String]
450
+ # @return [Hash] empty hash when not found; otherwise typed fields
451
+ # Keys:
452
+ # - :order_id, :user_id, :symbol, :side, :type, :status (String)
453
+ # - :price, :quantity, :remaining, :created_at, :sequence (Integer)
454
+ def order(order_id)
455
+ raw = @redis.hgetall("order:#{order_id}")
456
+ return {} if raw.empty?
457
+
458
+ {
459
+ order_id: raw.fetch('order_id', order_id),
460
+ user_id: raw['user_id'],
461
+ symbol: raw['symbol'],
462
+ side: raw['side'],
463
+ type: raw['type'],
464
+ status: raw['status'],
465
+ price: raw.fetch('price', '0').to_i,
466
+ quantity: raw.fetch('quantity', '0').to_i,
467
+ remaining: raw.fetch('remaining', '0').to_i,
468
+ created_at: raw.fetch('created_at', '0').to_i,
469
+ sequence: raw.fetch('sequence', '0').to_i
470
+ }
471
+ end
472
+
473
+ # Cancel a resting order and release any reserved balances.
474
+ # Returns a status hash mirroring Lua responses.
475
+ def cancel_order(id=nil, order_id: nil)
476
+ # We need symbol to determine seq key.
477
+ # However, cancel_order.lua currently only takes order_key as KEYS[1].
478
+ # To provide seq key as KEYS[2], we need to know the symbol beforehand.
479
+ # But the caller only provides order_id.
480
+ # Option 1: Look up symbol from order hash first (requires extra RTT).
481
+ # Option 2: Change Lua script to accept symbol as ARGV and construct seq key? No, dynamic keys are bad for cluster.
482
+ # But seq key is {seq:{symbol}}, so we need symbol in KEYS for cluster compatibility.
483
+ order_id ||= id
484
+ raise ArgumentError, 'order_id or id must be provided' unless order_id
485
+ # Let's read the order first to get the symbol.
486
+ # This adds a round trip, but ensures correctness for cluster/key slots.
487
+ symbol = @redis.hget("order:#{order_id}", "symbol")
488
+
489
+ # If order is not found, the Lua script will handle it gracefully if we just pass a dummy key or handle it here.
490
+ # If we don't find symbol, the order likely doesn't exist.
491
+ return { status: 'NOT_FOUND', order_id: order_id } unless symbol
492
+
493
+ payload = {
494
+ keys: ["order:#{order_id}", "seq:#{symbol}"],
495
+ argv: [order_id]
496
+ }
497
+ raw = call_script(@cancel_order_script, payload)
498
+ JSON.parse(raw, symbolize_names: true)
499
+ end
500
+
501
+ # Cancel all resting orders for a user across all markets.
502
+ # Implemented in Ruby to avoid Lua script timeouts on large data sets.
503
+ # @param user_id [String]
504
+ # @param return_results [Boolean] if true, returns cancellation results; if false, returns nil
505
+ # @return [Array<Hash>, nil] cancellation results if return_results is true, otherwise nil
506
+ def cancel_all_orders(user_id, return_results: true)
507
+ if maintenance.enabled?
508
+ return { error: 'SYSTEM_MAINTENANCE' }
509
+ end
510
+
511
+ results = []
512
+ cursor = '0'
513
+ loop do
514
+ # 1. Scan user's order indexes
515
+ cursor, keys = @redis.scan(cursor, match: "user_orders:#{user_id}:*", count: 100)
516
+
517
+ keys.each do |index_key|
518
+ # index_key format: user_orders:{user_id}:{symbol}
519
+ symbol = index_key.split(':').last
520
+
521
+ # 2. Get all order IDs for this symbol
522
+ order_ids = @redis.zrange(index_key, 0, -1)
523
+
524
+ order_ids.each do |oid|
525
+ # 3. Cancel each order individually
526
+ # This calls cancel_order.lua which handles status checks, balance release, etc.
527
+ # It returns a Hash with :status, :order_id, :symbol etc. or :error
528
+ res = cancel_order(oid)
529
+
530
+ # 4. Collect successful cancellations
531
+ if res[:status] == RedisExchange::ORDER_STATUS_CANCELLED
532
+ results << res
533
+ elsif res[:error] == 'MARKET_MAINTENANCE'
534
+ # Capture error but continue processing other orders (which might be in different markets)
535
+ results << { order_id: oid, error: 'MARKET_MAINTENANCE' }
536
+ elsif res[:status] == RedisExchange::ORDER_STATUS_NOT_FOUND || res[:status] == RedisExchange::ORDER_STATUS_UNKNOWN
537
+ # Order might be already gone or finished, clean up index just in case?
538
+ # cancel_order.lua doesn't clean up index if order not found in hash.
539
+ # But if it's not found, we should probably remove it from ZSET to avoid stuck keys.
540
+ # However, standard cancel_order doesn't know about user_id if order hash is gone.
541
+ # Here we know the index_key, so we can clean it.
542
+ @redis.zrem(index_key, oid)
543
+ end
544
+ end
545
+ end
546
+
547
+ break if cursor == '0'
548
+ end
549
+
550
+ # 5. Return results (or nil if return_results is false)
551
+ return_results ? results : nil
552
+ end
553
+
554
+ # Generic atomic balance operation.
555
+ # @param user_id [String]
556
+ # @param asset [String]
557
+ # @param action [String] LOCK, UNLOCK, ADD, SUB, SUB_LOCKED, ADD_LOCKED
558
+ # @param amount [Integer, Float, BigDecimal] positive amount
559
+ # @param reason [String]
560
+ # @param timestamp_ms [Integer,nil]
561
+ # @param biz_id [String,nil]
562
+ # @param auto_scale [Boolean]
563
+ # @return [Hash]
564
+ def operate_balance(user_id:, asset:, action:, amount:, reason: RedisExchange::REASON_MANUAL, timestamp_ms: nil, biz_id: nil, auto_scale: false)
565
+ raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
566
+ raise ArgumentError, 'asset is required' if asset.nil? || asset.to_s.empty?
567
+ raise ArgumentError, 'amount must be positive' unless amount.to_f.positive?
568
+
569
+ final_amount = if auto_scale
570
+ scale_asset_amount(asset, amount)
571
+ else
572
+ amount.to_i
573
+ end
574
+
575
+ timestamp = timestamp_ms || current_millis
576
+ biz = (biz_id && !biz_id.to_s.empty?) ? biz_id.to_s : SecureRandom.uuid
577
+
578
+ payload = {
579
+ keys: ["balance:#{user_id}"],
580
+ argv: [user_id, asset, final_amount.to_s, action, reason, timestamp.to_s, biz]
581
+ }
582
+ raw = call_script(@balance_op_script, payload)
583
+ resp = JSON.parse(raw, symbolize_names: true)
584
+ resp[:biz_id] = biz
585
+ resp
586
+ end
587
+
588
+ def lock_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_LOCK, timestamp_ms: nil, biz_id: nil, auto_scale: false)
589
+ operate_balance(
590
+ user_id: user_id,
591
+ asset: asset,
592
+ action: RedisExchange::ACTION_LOCK,
593
+ amount: amount,
594
+ reason: reason,
595
+ timestamp_ms: timestamp_ms,
596
+ biz_id: biz_id,
597
+ auto_scale: auto_scale
598
+ )
599
+ end
600
+
601
+ def unlock_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_UNLOCK, timestamp_ms: nil, biz_id: nil, auto_scale: false)
602
+ operate_balance(
603
+ user_id: user_id,
604
+ asset: asset,
605
+ action: RedisExchange::ACTION_UNLOCK,
606
+ amount: amount,
607
+ reason: reason,
608
+ timestamp_ms: timestamp_ms,
609
+ biz_id: biz_id,
610
+ auto_scale: auto_scale
611
+ )
612
+ end
613
+
614
+ # Deposit funds into user's available balance for an asset.
615
+ # @param user_id [String]
616
+ # @param asset [String]
617
+ # @param amount [Integer, Float, BigDecimal] positive amount
618
+ # @param reason [String] optional reason (defaults to 'DEPOSIT')
619
+ # @param timestamp_ms [Integer,nil]
620
+ # @param biz_id [String,nil]
621
+ # @param auto_scale [Boolean] if true, scales amount using asset's balance_scale
622
+ # @return [Hash]
623
+ def add_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_DEPOSIT, timestamp_ms: nil, biz_id: nil, auto_scale: false)
624
+ operate_balance(
625
+ user_id: user_id,
626
+ asset: asset,
627
+ action: RedisExchange::ACTION_ADD,
628
+ amount: amount,
629
+ reason: reason,
630
+ timestamp_ms: timestamp_ms,
631
+ biz_id: biz_id,
632
+ auto_scale: auto_scale
633
+ )
634
+ end
635
+
636
+ # Withdraw funds from user's available balance for an asset.
637
+ # @param user_id [String]
638
+ # @param asset [String]
639
+ # @param amount [Integer, Float, BigDecimal] positive amount
640
+ # @param reason [String] optional reason (defaults to 'WITHDRAWAL')
641
+ # @param timestamp_ms [Integer,nil]
642
+ # @param biz_id [String,nil]
643
+ # @param auto_scale [Boolean] if true, scales amount using asset's balance_scale
644
+ # @return [Hash]
645
+ def sub_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_WITHDRAWAL, timestamp_ms: nil, biz_id: nil, auto_scale: false)
646
+ operate_balance(
647
+ user_id: user_id,
648
+ asset: asset,
649
+ action: RedisExchange::ACTION_SUB,
650
+ amount: amount,
651
+ reason: reason,
652
+ timestamp_ms: timestamp_ms,
653
+ biz_id: biz_id,
654
+ auto_scale: auto_scale
655
+ )
656
+ end
657
+
658
+ # Withdraw funds from user's reserved (locked) balance for an asset.
659
+ # Useful for manual corrections or external liquidations.
660
+ # @param user_id [String]
661
+ # @param asset [String]
662
+ # @param amount [Integer, Float, BigDecimal] positive amount
663
+ # @param reason [String] optional reason (defaults to 'WITHDRAWAL')
664
+ # @param timestamp_ms [Integer,nil]
665
+ # @param biz_id [String,nil]
666
+ # @param auto_scale [Boolean] if true, scales amount using asset's balance_scale
667
+ # @return [Hash]
668
+ def sub_locked_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_WITHDRAWAL, timestamp_ms: nil, biz_id: nil, auto_scale: false)
669
+ operate_balance(
670
+ user_id: user_id,
671
+ asset: asset,
672
+ action: RedisExchange::ACTION_SUB_LOCKED,
673
+ amount: amount,
674
+ reason: reason,
675
+ timestamp_ms: timestamp_ms,
676
+ biz_id: biz_id,
677
+ auto_scale: auto_scale
678
+ )
679
+ end
680
+
681
+ # Set user-specific trading fee rates.
682
+ # User fees take precedence over market default fees when placing orders.
683
+ # @param user_id [String] user identifier
684
+ # @param maker_fee_bps [Integer] maker fee in basis points (0..10000)
685
+ # @param taker_fee_bps [Integer] taker fee in basis points (0..10000)
686
+ # @return [Hash] { ok: true } or { error: String }
687
+ def set_user_fees(user_id:, maker_fee_bps:, taker_fee_bps:)
688
+ raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
689
+ raise ArgumentError, 'maker_fee_bps must be between 0 and 10000' unless (0..10000).include?(maker_fee_bps.to_i)
690
+ raise ArgumentError, 'taker_fee_bps must be between 0 and 10000' unless (0..10000).include?(taker_fee_bps.to_i)
691
+
692
+ key = "user_fees:#{user_id}"
693
+ @redis.hset(key, 'maker_fee_bps', maker_fee_bps.to_i, 'taker_fee_bps', taker_fee_bps.to_i)
694
+ clear_config_cache!
695
+ { ok: true }
696
+ end
697
+
698
+ # Get user-specific trading fee rates.
699
+ # @param user_id [String] user identifier
700
+ # @return [Hash] { maker_fee_bps: Integer, taker_fee_bps: Integer } or {} if not set
701
+ def get_user_fees(user_id:)
702
+ raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
703
+
704
+ parse_user_fees(@redis.hgetall("user_fees:#{user_id}"))
705
+ end
706
+
707
+ # Delete user-specific trading fee rates, reverting to market defaults.
708
+ # @param user_id [String] user identifier
709
+ # @return [Hash] { ok: true } or { error: String }
710
+ def delete_user_fees(user_id:)
711
+ raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
712
+
713
+ key = "user_fees:#{user_id}"
714
+ unless @redis.exists?(key)
715
+ return { error: 'USER_FEES_NOT_FOUND' }
716
+ end
717
+
718
+ @redis.del(key)
719
+ clear_config_cache!
720
+ { ok: true }
721
+ end
722
+
723
+ def fees_total(asset:, auto_scale: false)
724
+ raise ArgumentError, 'asset is required' if asset.nil? || asset.to_s.empty?
725
+
726
+ total = @redis.hget("fees:#{asset}", 'total').to_i
727
+ return total unless auto_scale
728
+
729
+ scale = asset_config(asset)[:balance_scale] || 1
730
+ total.to_d / scale
731
+ end
732
+
733
+ def fees(asset:, auto_scale: false)
734
+ { total: fees_total(asset: asset, auto_scale: auto_scale) }
735
+ end
736
+
737
+ # 分页查询用户在某交易对的活跃订单(OPEN/PARTIALLY_FILLED 且 remaining>0)。
738
+ # 依赖 Lua 维护的 ZSET 索引:`user_orders:{user_id}:{symbol}`,score 使用撮合序列。
739
+ # @param user_id [String]
740
+ # @param symbol [String]
741
+ # @param page [Integer] 从 1 开始的页码
742
+ # @param per_page [Integer] 每页条数,默认 50
743
+ # @param direction [Symbol] :asc 或 :desc(根据序列顺序)
744
+ # @param auto_scale [Boolean] if true, unscales price/quantity using market config
745
+ # @return [Array<Hash>] 订单快照数组
746
+ def active_orders(user_id:, symbol:, page: 1, per_page: 50, direction: :desc, auto_scale: false)
747
+ page = page.to_i
748
+ per_page = per_page.to_i
749
+ page = 1 if page <= 0
750
+ per_page = 50 if per_page <= 0
751
+
752
+ index_key = "user_orders:#{user_id}:#{symbol}"
753
+ start = (page - 1) * per_page
754
+ stop = start + per_page - 1
755
+
756
+ range_method = direction.to_sym == :asc ? :zrange : :zrevrange
757
+ ids = Array(@redis.public_send(range_method, index_key, start, stop))
758
+ return [] if ids.empty?
759
+
760
+ orders = ids.map { |id| order(id) }.select { |o| !o.empty? && o[:remaining].positive? && (o[:status] == RedisExchange::ORDER_STATUS_OPEN || o[:status] == RedisExchange::ORDER_STATUS_PARTIALLY_FILLED) }
761
+
762
+ if auto_scale && !orders.empty?
763
+ cfg = market_config(symbol)
764
+ p_scale = (cfg[:price_scale] || 1).to_d
765
+ q_scale = (cfg[:quantity_scale] || 1).to_d
766
+
767
+ orders.each do |o|
768
+ o[:price] = o[:price] / p_scale
769
+ o[:quantity] = o[:quantity] / q_scale
770
+ o[:remaining] = o[:remaining] / q_scale
771
+ end
772
+ end
773
+
774
+ orders
775
+ end
776
+
777
+ # Get active order count for a user in a specific market.
778
+ # @param user_id [String]
779
+ # @param symbol [String] if nil, counts across all markets
780
+ # @return [Integer] number of active orders
781
+ def active_order_count(user_id:, symbol: nil)
782
+ if symbol
783
+ @redis.zcard("user_orders:#{user_id}:#{symbol}")
784
+ else
785
+ total = 0
786
+ cursor = '0'
787
+ loop do
788
+ cursor, keys = @redis.scan(cursor, match: "user_orders:#{user_id}:*", count: 100)
789
+ total += keys.sum { |key| @redis.zcard(key) }
790
+ break if cursor == '0'
791
+ end
792
+ total
793
+ end
794
+ end
795
+
796
+ # Iterate over all orders for a user across all markets.
797
+ # Yields each order hash to the block.
798
+ # @param user_id [String]
799
+ # @yield [Hash] order hash with keys: order_id, user_id, symbol, side, type, status, price, quantity, remaining, etc.
800
+ # @return [Array<Hash>] all orders if block not given, otherwise nil
801
+ def all_orders(user_id:)
802
+ orders = []
803
+ cursor = '0'
804
+ loop do
805
+ cursor, keys = @redis.scan(cursor, match: "user_orders:#{user_id}:*", count: 100)
806
+
807
+ keys.each do |index_key|
808
+ symbol = index_key.split(':').last
809
+ order_ids = @redis.zrange(index_key, 0, -1)
810
+
811
+ order_ids.each do |oid|
812
+ o = order(oid)
813
+ next if o.empty?
814
+ if block_given?
815
+ yield o
816
+ else
817
+ orders << o
818
+ end
819
+ end
820
+ end
821
+
822
+ break if cursor == '0'
823
+ end
824
+
825
+ orders unless block_given?
826
+ end
827
+
828
+ # Iterate over all orders for a user in a specific market.
829
+ # Yields each order hash to the block.
830
+ # @param user_id [String]
831
+ # @param symbol [String] market symbol
832
+ # @yield [Hash] order hash with keys: order_id, user_id, symbol, side, type, status, price, quantity, remaining, etc.
833
+ # @return [Array<Hash>] all orders if block not given, otherwise nil
834
+ def all_orders_for_market(user_id:, symbol:)
835
+ orders = []
836
+ index_key = "user_orders:#{user_id}:#{symbol}"
837
+ order_ids = @redis.zrange(index_key, 0, -1)
838
+
839
+ order_ids.each do |oid|
840
+ o = order(oid)
841
+ next if o.empty?
842
+ if block_given?
843
+ yield o
844
+ else
845
+ orders << o
846
+ end
847
+ end
848
+
849
+ orders unless block_given?
850
+ end
851
+
852
+ # Fetch all active orders for a market (scanning the order book).
853
+ # WARNING: This can be expensive on large order books as it scans all price levels and queues.
854
+ # @param symbol [String]
855
+ # @return [Array<Hash>] list of order hashes
856
+ def market_active_orders(symbol:)
857
+ orders = []
858
+ [RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
859
+ # 1. Get all price levels from the book
860
+ book_key = "book:#{symbol}:#{side}"
861
+ prices = @redis.zrange(book_key, 0, -1)
862
+
863
+ prices.each do |price|
864
+ # 2. Get all order IDs at this price level
865
+ queue_key = "queue:#{symbol}:#{side}:#{price}"
866
+ order_ids = @redis.lrange(queue_key, 0, -1)
867
+
868
+ order_ids.each do |oid|
869
+ # 3. Fetch order details
870
+ o = order(oid)
871
+ orders << o unless o.empty?
872
+ end
873
+ end
874
+ end
875
+ orders
876
+ end
877
+
878
+ # Iterate over all orders for a market.
879
+ # Yields each order hash to the block.
880
+ # WARNING: This can be expensive on large order books as it scans all price levels and queues.
881
+ # @param symbol [String] market symbol
882
+ # @yield [Hash] order hash with keys: order_id, user_id, symbol, side, type, status, price, quantity, remaining, etc.
883
+ # @return [Array<Hash>] all orders if block not given, otherwise nil
884
+ def all_market_orders(symbol:)
885
+ orders = []
886
+ [RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
887
+ book_key = "book:#{symbol}:#{side}"
888
+ prices = @redis.zrange(book_key, 0, -1)
889
+
890
+ prices.each do |price|
891
+ queue_key = "queue:#{symbol}:#{side}:#{price}"
892
+ order_ids = @redis.lrange(queue_key, 0, -1)
893
+
894
+ order_ids.each do |oid|
895
+ o = order(oid)
896
+ next if o.empty?
897
+ if block_given?
898
+ yield o
899
+ else
900
+ orders << o
901
+ end
902
+ end
903
+ end
904
+ end
905
+
906
+ orders unless block_given?
907
+ end
908
+
909
+ # Get active order count for a market.
910
+ # @param symbol [String] market symbol
911
+ # @return [Hash] { total: Integer, buy: Integer, sell: Integer }
912
+ def market_order_count(symbol:)
913
+ total = 0
914
+ buy_count = 0
915
+ sell_count = 0
916
+
917
+ [RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
918
+ book_key = "book:#{symbol}:#{side}"
919
+ prices = @redis.zrange(book_key, 0, -1)
920
+
921
+ side_count = 0
922
+ prices.each do |price|
923
+ queue_key = "queue:#{symbol}:#{side}:#{price}"
924
+ side_count += @redis.llen(queue_key)
925
+ end
926
+
927
+ if side == RedisExchange::SIDE_BUY
928
+ buy_count = side_count
929
+ else
930
+ sell_count = side_count
931
+ end
932
+ total += side_count
933
+ end
934
+
935
+ { total: total, buy: buy_count, sell: sell_count }
936
+ end
937
+
938
+ # Monitor order book changes via Redis Keyspace Notifications.
939
+ # Yields events to the block.
940
+ # Note: This method blocks while listening for events.
941
+ #
942
+ # @param symbol [String] market symbol
943
+ # @param redis_config [Hash] optional Redis configuration for the subscriber connection
944
+ # @yield [type, side]
945
+ # @yieldparam type [String] event type (e.g. 'zadd', 'zrem')
946
+ # @yieldparam side [String] 'BUY' or 'SELL'
947
+ def monitor_order_book(symbol:, redis_config: nil, &block)
948
+ raise ArgumentError, 'block required' unless block_given?
949
+
950
+ # Ensure Keyspace notifications are enabled for Generic (keys) and Sorted Sets
951
+ # K: Keyspace events, E: Keyevent events, z: Sorted sets
952
+ # We need Kz or Ez. We'll use Kz to subscribe to specific keys.
953
+ begin
954
+ config = @redis.config('GET', 'notify-keyspace-events')
955
+ current_policy = if config.is_a?(Hash)
956
+ config['notify-keyspace-events']
957
+ elsif config.is_a?(Array)
958
+ Hash[*config]['notify-keyspace-events']
959
+ else
960
+ ''
961
+ end.to_s
962
+
963
+ unless current_policy.include?('K') && current_policy.include?('z')
964
+ new_policy = (current_policy.chars + ['K', 'z']).uniq.join
965
+ @redis.config('SET', 'notify-keyspace-events', new_policy)
966
+ end
967
+ rescue Redis::CommandError => e
968
+ # If CONFIG command is restricted (e.g. managed Redis), we warn but proceed
969
+ # assuming the user might have configured it externally.
970
+ warn "Warning: Failed to set notify-keyspace-events: #{e.message}"
971
+ end
972
+
973
+ # Get current DB
974
+ # Use CLIENT INFO to reliably get the current database index
975
+ db = begin
976
+ info = @redis.call('CLIENT', 'INFO')
977
+ info[/db=(\d+)/, 1] || 0
978
+ rescue StandardError
979
+ 0
980
+ end
981
+
982
+ # Channels
983
+ buy_channel = "__keyspace@#{db}__:book:#{symbol}:#{RedisExchange::SIDE_BUY}"
984
+ sell_channel = "__keyspace@#{db}__:book:#{symbol}:#{RedisExchange::SIDE_SELL}"
985
+
986
+ # Use a new connection for subscription to avoid blocking the main client
987
+ # If redis_config is not provided, try to extract from current client
988
+ sub_redis = if redis_config
989
+ Redis.new(redis_config)
990
+ else
991
+ # Clone connection options
992
+ opts = if @redis.respond_to?(:_client)
993
+ @redis._client.instance_variable_get(:@options)
994
+ else
995
+ # Fallback: try to access options via instance var if _client missing
996
+ @redis.instance_variable_get(:@options)
997
+ end
998
+
999
+ if opts
1000
+ Redis.new(opts)
1001
+ else
1002
+ # Last resort: try default or ENV
1003
+ Redis.new(url: ENV['REDIS_URL'])
1004
+ end
1005
+ end
1006
+
1007
+ begin
1008
+ sub_redis.subscribe(buy_channel, sell_channel) do |on|
1009
+ on.message do |channel, message|
1010
+ side = if channel.end_with?(RedisExchange::SIDE_BUY)
1011
+ RedisExchange::SIDE_BUY
1012
+ else
1013
+ RedisExchange::SIDE_SELL
1014
+ end
1015
+
1016
+ block.call(message, side)
1017
+ end
1018
+ end
1019
+ ensure
1020
+ sub_redis.close
1021
+ end
1022
+ end
1023
+
1024
+ # Build a TradeStreamConsumer for a market symbol.
1025
+ # Usage: consumer = exchange.trade_consumer(symbol: 'BTCUSDT', group: 'trades-consumers', consumer: 'worker-1')
1026
+ def trade_consumer(symbol:, group:, consumer:)
1027
+ require_relative 'redis_exchange/trade_stream_consumer'
1028
+ c = RedisExchange::TradeStreamConsumer.new(redis: @redis, symbol: symbol, group: group, consumer: consumer)
1029
+ c.ensure_group(start_id: '0')
1030
+ c
1031
+ end
1032
+
1033
+ # Build a DepthStreamConsumer for a market symbol.
1034
+ # Usage: consumer = exchange.depth_consumer(symbol: 'BTCUSDT', group: 'depth-consumers', consumer: 'worker-1')
1035
+ def depth_consumer(symbol:, group:, consumer:)
1036
+ require_relative 'redis_exchange/depth_stream_consumer'
1037
+ c = RedisExchange::DepthStreamConsumer.new(redis: @redis, symbol: symbol, group: group, consumer: consumer)
1038
+ c.ensure_group(start_id: '$')
1039
+ c
1040
+ end
1041
+
1042
+ def maintenance
1043
+ require_relative 'redis_exchange/maintenance'
1044
+ @maintenance ||= RedisExchange::Maintenance.new(@redis)
1045
+ end
1046
+
1047
+ # Build an OrderStreamConsumer for a market symbol.
1048
+ # Usage: consumer = exchange.order_consumer(symbol: 'BTCUSDT', group: 'orders-consumers', consumer: 'worker-1')
1049
+ def order_consumer(symbol:, group:, consumer:)
1050
+ require_relative 'redis_exchange/order_stream_consumer'
1051
+ c = RedisExchange::OrderStreamConsumer.new(redis: @redis, symbol: symbol, group: group, consumer: consumer)
1052
+ c.ensure_group(start_id: '0')
1053
+ c
1054
+ end
1055
+
1056
+ # Build a LedgerStreamConsumer for account ledger stream.
1057
+ # Usage: consumer = exchange.ledger_consumer(group: 'ledger-consumers', consumer: 'worker-1')
1058
+ def ledger_consumer(group:, consumer:, stream_key: 'account_ledger')
1059
+ require_relative 'redis_exchange/ledger_stream_consumer'
1060
+ c = RedisExchange::LedgerStreamConsumer.new(redis: @redis, group: group, consumer: consumer, stream_key: stream_key)
1061
+ c.ensure_group(start_id: '0')
1062
+ c
1063
+ end
1064
+
1065
+ # Remove book, volume, sequence, trades, and ticker keys for tests.
1066
+ def clear_books(symbol)
1067
+ keys = [
1068
+ "book:#{symbol}:#{RedisExchange::SIDE_BUY}",
1069
+ "book:#{symbol}:#{RedisExchange::SIDE_SELL}",
1070
+ "volume:#{symbol}:#{RedisExchange::SIDE_BUY}",
1071
+ "volume:#{symbol}:#{RedisExchange::SIDE_SELL}",
1072
+ "seq:#{symbol}",
1073
+ "trades:#{symbol}",
1074
+ "orders:#{symbol}",
1075
+ "depth:#{symbol}",
1076
+ "ticker:#{symbol}"
1077
+ ]
1078
+ @redis.del(*keys)
1079
+ end
1080
+
1081
+ # Snapshot the current book depth for both sides.
1082
+ # @param limit [Integer,nil] maximum number of price levels per side
1083
+ # @return [Hash{Symbol=>Array<Hash{Symbol=>Integer}>}]
1084
+ def order_book(symbol, limit: nil)
1085
+ limit = limit&.to_i
1086
+ {
1087
+ bids: fetch_book_side(symbol, RedisExchange::SIDE_BUY, descending: true, limit: limit),
1088
+ asks: fetch_book_side(symbol, RedisExchange::SIDE_SELL, descending: false, limit: limit)
1089
+ }
1090
+ end
1091
+
1092
+ # Get the number of price levels for a market.
1093
+ # @param symbol [String] market symbol
1094
+ # @return [Hash] { total: Integer, bids: Integer, asks: Integer }
1095
+ def price_level_count(symbol:)
1096
+ bids_count = @redis.zcard("book:#{symbol}:#{RedisExchange::SIDE_BUY}")
1097
+ asks_count = @redis.zcard("book:#{symbol}:#{RedisExchange::SIDE_SELL}")
1098
+ { total: bids_count + asks_count, bids: bids_count, asks: asks_count }
1099
+ end
1100
+
1101
+ # Merge depth levels by aggregating volumes within price ranges.
1102
+ # This method consolidates multiple price levels into aggregated buckets for better visualization.
1103
+ # @param symbol [String] market symbol
1104
+ # @param side [String] 'BUY' or 'SELL'
1105
+ # @param bucket_size [Integer] price bucket size in scaled units for aggregation
1106
+ # @param limit [Integer,nil] maximum number of aggregated buckets to return
1107
+ # @return [Array<Hash{Symbol=>Integer}>] array of aggregated depth levels
1108
+ # Each hash contains: :price, :volume, :original_levels (count of original levels merged)
1109
+ def merge_depth(symbol, side, bucket_size, limit: nil)
1110
+ raise ArgumentError, 'side must be BUY or SELL' unless RedisExchange::SIDES.include?(side.to_s.upcase)
1111
+ raise ArgumentError, 'bucket_size must be positive' unless bucket_size&.positive?
1112
+
1113
+ side_up = side.to_s.upcase
1114
+ bucket_size = bucket_size.to_i
1115
+ limit = limit&.to_i
1116
+
1117
+ # Fetch the full book side
1118
+ full_depth = fetch_book_side(symbol, side_up, descending: side_up == 'BUY', limit: nil)
1119
+ return [] if full_depth.empty?
1120
+
1121
+ # Aggregate price levels into buckets
1122
+ merged_buckets = {}
1123
+ full_depth.each do |level|
1124
+ price = level[:price]
1125
+ volume = level[:volume]
1126
+
1127
+ # Calculate bucket index based on price and bucket size
1128
+ bucket_index = (price / bucket_size).floor * bucket_size
1129
+
1130
+ # Initialize bucket if not exists
1131
+ unless merged_buckets[bucket_index]
1132
+ merged_buckets[bucket_index] = {
1133
+ price: bucket_index,
1134
+ volume: 0,
1135
+ original_levels: 0
1136
+ }
1137
+ end
1138
+
1139
+ # Aggregate volume and count levels
1140
+ merged_buckets[bucket_index][:volume] += volume
1141
+ merged_buckets[bucket_index][:original_levels] += 1
1142
+ end
1143
+
1144
+ # Convert to sorted array
1145
+ result = merged_buckets.values
1146
+ if side_up == 'BUY'
1147
+ # For BUY side, sort by price descending (highest prices first)
1148
+ result.sort_by! { |bucket| -bucket[:price] }
1149
+ else
1150
+ # For SELL side, sort by price ascending (lowest prices first)
1151
+ result.sort_by! { |bucket| bucket[:price] }
1152
+ end
1153
+
1154
+ # Apply limit if specified
1155
+ limit ? result.first(limit) : result
1156
+ end
1157
+
1158
+ # Merge depth for both sides with configurable bucket sizes.
1159
+ # @param symbol [String] market symbol
1160
+ # @param buy_bucket_size [Integer] bucket size for BUY side
1161
+ # @param sell_bucket_size [Integer] bucket size for SELL side
1162
+ # @param limit [Integer,nil] maximum number of buckets per side
1163
+ # @return [Hash{Symbol=>Array<Hash{Symbol=>Integer}>}] merged depth for both sides
1164
+ def merge_depth_both_sides(symbol, buy_bucket_size, sell_bucket_size, limit: nil)
1165
+ {
1166
+ bids: merge_depth(symbol, RedisExchange::SIDE_BUY, buy_bucket_size, limit: limit),
1167
+ asks: merge_depth(symbol, RedisExchange::SIDE_SELL, sell_bucket_size, limit: limit)
1168
+ }
1169
+ end
1170
+
1171
+ private
1172
+
1173
+ def check_market_status(market_config)
1174
+ st = market_config[:status].to_s.upcase
1175
+ return nil if st == RedisExchange::MARKET_STATUS_OPEN
1176
+
1177
+ case st
1178
+ when RedisExchange::MARKET_STATUS_HALTED then { error: 'MARKET_HALTED' }
1179
+ when RedisExchange::MARKET_STATUS_MAINTENANCE then { error: 'MARKET_MAINTENANCE' }
1180
+ when RedisExchange::MARKET_STATUS_CLOSED then { error: 'MARKET_CLOSED' }
1181
+ else { error: 'MARKET_CLOSED' }
1182
+ end
1183
+ end
1184
+
1185
+ # Market/asset/user-fee bundle for the order path, cached for
1186
+ # #config_cache_ttl seconds. Cache miss costs one HGETALL for the market plus
1187
+ # one pipelined round trip for both assets and the user's fee rates; a hit
1188
+ # costs nothing. Unknown markets are not cached so a freshly created market
1189
+ # is picked up immediately.
1190
+ def order_config(symbol, user_id)
1191
+ cache_key = "#{symbol}\u0000#{user_id}"
1192
+ now = monotonic_seconds
1193
+ cached = @config_cache[cache_key]
1194
+ # A zero TTL disables both storing and serving, so a stale entry left over
1195
+ # from an earlier setting can never be handed out.
1196
+ if @config_cache_ttl.positive? && cached && now < cached[:expires_at]
1197
+ return cached[:value]
1198
+ end
1199
+
1200
+ mc = market_config(symbol)
1201
+ return { market: {}, base_asset: {}, quote_asset: {}, user_fees: {} } if mc.empty?
1202
+
1203
+ base_raw, quote_raw, fees_raw = @redis.pipelined do |pipe|
1204
+ pipe.hgetall("asset:#{mc[:base_asset]}")
1205
+ pipe.hgetall("asset:#{mc[:quote_asset]}")
1206
+ pipe.hgetall("user_fees:#{user_id}")
1207
+ end
1208
+
1209
+ value = {
1210
+ market: mc,
1211
+ base_asset: parse_asset_config(base_raw),
1212
+ quote_asset: parse_asset_config(quote_raw),
1213
+ user_fees: parse_user_fees(fees_raw)
1214
+ }
1215
+ @config_cache[cache_key] = { value: value, expires_at: now + @config_cache_ttl } if @config_cache_ttl.positive?
1216
+ value
1217
+ end
1218
+
1219
+ def parse_asset_config(raw)
1220
+ return {} if raw.nil? || raw.empty?
1221
+
1222
+ {
1223
+ balance_scale: raw.fetch('balance_scale', '0').to_i,
1224
+ status: raw.fetch('status', RedisExchange::ASSET_STATUS_ACTIVE)
1225
+ }
1226
+ end
1227
+
1228
+ def parse_user_fees(raw)
1229
+ return {} if raw.nil? || raw.empty?
1230
+
1231
+ {
1232
+ maker_fee_bps: raw.fetch('maker_fee_bps', '0').to_i,
1233
+ taker_fee_bps: raw.fetch('taker_fee_bps', '0').to_i
1234
+ }
1235
+ end
1236
+
1237
+ # Same validation as #validate_asset_scales_for_market but against configs
1238
+ # that were already fetched, so the order path does not re-read them.
1239
+ def validate_asset_scale_configs(base_cfg:, quote_cfg:, price_scale:, quantity_scale:,
1240
+ base_asset:, quote_asset:)
1241
+ base_bs = base_cfg[:balance_scale].to_i
1242
+ quote_bs = quote_cfg[:balance_scale].to_i
1243
+ unless base_bs.positive? && quote_bs.positive?
1244
+ missing = !base_bs.positive? ? base_asset : quote_asset
1245
+ return { ok: false, error: 'ASSET_NOT_CONFIGURED', asset: missing }
1246
+ end
1247
+
1248
+ ps = price_scale.to_i
1249
+ qs = quantity_scale.to_i
1250
+ base_factor = base_bs / qs
1251
+ quote_factor = quote_bs / ps
1252
+ unless (base_factor * qs) == base_bs
1253
+ return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: base_asset,
1254
+ balance_scale: base_bs, market_scale: qs }
1255
+ end
1256
+ unless (quote_factor * ps) == quote_bs
1257
+ return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: quote_asset,
1258
+ balance_scale: quote_bs, market_scale: ps }
1259
+ end
1260
+
1261
+ { ok: true, base_factor: base_factor, quote_factor: quote_factor }
1262
+ end
1263
+
1264
+ def monotonic_seconds
1265
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1266
+ end
1267
+
1268
+ # Internal helper to execute the Lua script with typed arguments.
1269
+ def execute_order(symbol:, side:, order_type:, order_id:, user_id:, price_limit:, quantity:, timestamp_ms: nil)
1270
+ if maintenance.enabled?
1271
+ return { error: 'SYSTEM_MAINTENANCE' }
1272
+ end
1273
+
1274
+ # Duplicate id detection lives in match_order.lua (tombstone + order hash,
1275
+ # returning DUPLICATE_ORDER_ID). Checking it again here only added a round
1276
+ # trip to every order, so it is deliberately not repeated in Ruby.
1277
+
1278
+ side_up = side.to_s.upcase
1279
+ raise ArgumentError, 'side must be BUY or SELL' unless RedisExchange::SIDES.include?(side_up)
1280
+
1281
+ type_up = order_type.to_s.upcase
1282
+ raise ArgumentError, 'order_type must be LIMIT or MARKET' unless RedisExchange::ORDER_TYPES.include?(type_up)
1283
+
1284
+ # Precision pre-check (Ruby layer): both price_limit and quantity are scaled integers.
1285
+ # Prevent sending arguments that would overflow Lua's double precision (~9e15).
1286
+ pl = price_limit.to_i
1287
+ qty = quantity.to_i
1288
+ if pl.negative? || qty <= 0
1289
+ return { error: 'INVALID_PRICE_OR_QUANTITY' }
1290
+ end
1291
+ if pl > 0
1292
+ # If either operand or the product breaches the bound, abort.
1293
+ # allow product equal to bound; overflow only when strictly greater
1294
+ if pl >= RedisExchange::MAX_SAFE_INTEGER || qty >= RedisExchange::MAX_SAFE_INTEGER || (pl.to_i * qty.to_i) > RedisExchange::MAX_SAFE_INTEGER
1295
+ return { error: 'PRECISION_OVERFLOW', details: "price_limit * quantity: #{pl} * #{qty}" }
1296
+ end
1297
+ end
1298
+
1299
+ # Market status + asset scale + user fee checks, served from the config
1300
+ # cache: one pipelined round trip on a miss, zero on a hit.
1301
+ cfg = order_config(symbol, user_id)
1302
+ mc = cfg[:market]
1303
+ return { error: 'UNKNOWN_MARKET' } if mc.empty?
1304
+
1305
+ status_check = check_market_status(mc)
1306
+ return status_check if status_check
1307
+
1308
+ # Asset scale compatibility pre-check: ensure asset balance scales are integer multiples
1309
+ # of market scales before invoking the Lua matcher. This surfaces friendly errors early.
1310
+ scale_check = validate_asset_scale_configs(
1311
+ base_cfg: cfg[:base_asset],
1312
+ quote_cfg: cfg[:quote_asset],
1313
+ price_scale: mc[:price_scale],
1314
+ quantity_scale: mc[:quantity_scale],
1315
+ base_asset: mc[:base_asset],
1316
+ quote_asset: mc[:quote_asset]
1317
+ )
1318
+ unless scale_check[:ok]
1319
+ err = { error: scale_check[:error] }
1320
+ [:asset, :balance_scale, :market_scale].each do |k|
1321
+ v = scale_check[k]
1322
+ err[k] = v if v
1323
+ end
1324
+ return err
1325
+ end
1326
+
1327
+ ts = (timestamp_ms || current_millis).to_i
1328
+
1329
+ # Load user-specific taker fee rate if configured
1330
+ user_taker_fee_bps = cfg[:user_fees][:taker_fee_bps].to_s
1331
+
1332
+ keys = market_keys(symbol: symbol, user_id: user_id)
1333
+ script_payload = {
1334
+ keys: [
1335
+ keys[:market],
1336
+ keys[:sequence],
1337
+ keys[:book_buy],
1338
+ keys[:book_sell],
1339
+ keys[:volume_buy],
1340
+ keys[:volume_sell],
1341
+ keys[:balance],
1342
+ keys[:trades],
1343
+ keys[:orders]
1344
+ ],
1345
+ argv: [
1346
+ symbol,
1347
+ side_up,
1348
+ type_up,
1349
+ order_id,
1350
+ user_id,
1351
+ price_limit.to_s,
1352
+ quantity.to_s,
1353
+ ts.to_s,
1354
+ user_taker_fee_bps
1355
+ ]
1356
+ }
1357
+
1358
+ raw = execute_via_match_script(script_payload)
1359
+ JSON.parse(raw, symbolize_names: true)
1360
+ end
1361
+
1362
+ # Build all Redis keys touched by the Lua script for a market/user pair.
1363
+ def market_keys(symbol:, user_id:)
1364
+ {
1365
+ market: "market:#{symbol}",
1366
+ sequence: "seq:#{symbol}",
1367
+ book_buy: "book:#{symbol}:#{RedisExchange::SIDE_BUY}",
1368
+ book_sell: "book:#{symbol}:#{RedisExchange::SIDE_SELL}",
1369
+ volume_buy: "volume:#{symbol}:#{RedisExchange::SIDE_BUY}",
1370
+ volume_sell: "volume:#{symbol}:#{RedisExchange::SIDE_SELL}",
1371
+ balance: "balance:#{user_id}",
1372
+ trades: "trades:#{symbol}",
1373
+ orders: "orders:#{symbol}"
1374
+ }
1375
+ end
1376
+
1377
+ # Current epoch time in milliseconds.
1378
+ def current_millis
1379
+ (Time.now.to_f * 1000).floor
1380
+ end
1381
+
1382
+ # Simple Lua script wrapper using SCRIPT LOAD + EVALSHA.
1383
+ class LuaScript
1384
+ def initialize(source)
1385
+ @source = source
1386
+ @sha = nil
1387
+ end
1388
+
1389
+ def call(redis, keys:, argv:)
1390
+ @sha ||= redis.script(:load, @source)
1391
+ redis.evalsha(@sha, keys: keys, argv: argv)
1392
+ rescue ::Redis::CommandError => e
1393
+ if e.message =~ /NOSCRIPT/
1394
+ @sha = redis.script(:load, @source)
1395
+ retry
1396
+ else
1397
+ raise
1398
+ end
1399
+ end
1400
+ end
1401
+
1402
+ def register_script(lua_source)
1403
+ raise ArgumentError, 'missing Lua source' unless lua_source && !lua_source.empty?
1404
+ LuaScript.new(lua_source)
1405
+ end
1406
+
1407
+ def execute_via_match_script(script_payload)
1408
+ call_script(@match_order_script, script_payload)
1409
+ end
1410
+
1411
+ def read_script(path)
1412
+ return nil unless path && File.exist?(path)
1413
+
1414
+ File.read(path)
1415
+ end
1416
+
1417
+ def load_script(filename)
1418
+ path = File.expand_path("../redis_scripts/#{filename}", __dir__)
1419
+ register_script(read_script(path))
1420
+ end
1421
+
1422
+ def call_script(script, payload)
1423
+ script.call(@redis, **payload)
1424
+ end
1425
+
1426
+ def collect_order_keys
1427
+ collect_keys('order:*')
1428
+ end
1429
+
1430
+ def collect_queue_keys(symbol, side)
1431
+ collect_keys("queue:#{symbol}:#{side}:*")
1432
+ end
1433
+
1434
+ def collect_keys(pattern)
1435
+ if @redis.respond_to?(:scan)
1436
+ cursor = '0'
1437
+ keys = []
1438
+ loop do
1439
+ scan_result = @redis.scan(cursor, match: pattern, count: 200)
1440
+ cursor = scan_result[0]
1441
+ keys.concat(Array(scan_result[1]))
1442
+ break if cursor == '0'
1443
+ end
1444
+ keys
1445
+ elsif @redis.respond_to?(:keys)
1446
+ Array(@redis.keys(pattern))
1447
+ else
1448
+ []
1449
+ end
1450
+ end
1451
+
1452
+ def fetch_book_side(symbol, side, descending:, limit:)
1453
+ if supports_sorted_book?
1454
+ return fetch_sorted_book_side(symbol, side, descending: descending, limit: limit)
1455
+ end
1456
+
1457
+ queue_keys = collect_queue_keys(symbol, side)
1458
+ entries = queue_keys.map do |key|
1459
+ price = key.split(':').last
1460
+ {
1461
+ price: price.to_i,
1462
+ volume: @redis.hget("volume:#{symbol}:#{side}", price).to_i
1463
+ }
1464
+ end
1465
+ entries.select! { |row| row[:volume].positive? }
1466
+ entries.sort_by! { |row| descending ? -row[:price] : row[:price] }
1467
+ limit ? entries.first(limit) : entries
1468
+ end
1469
+
1470
+ def supports_sorted_book?
1471
+ @redis.respond_to?(:zrange) && @redis.respond_to?(:zrevrange)
1472
+ end
1473
+
1474
+ def fetch_sorted_book_side(symbol, side, descending:, limit:)
1475
+ book_key = "book:#{symbol}:#{side}"
1476
+ volume_key = "volume:#{symbol}:#{side}"
1477
+
1478
+ # For BUY side, scores are negative (e.g. Price 100 -> Score -100).
1479
+ # - descending=true (Price Descending 100->90) => Score Ascending (-100->-90) => zrange
1480
+ # - descending=false (Price Ascending 90->100) => Score Descending (-90->-100) => zrevrange
1481
+ # For SELL side, scores are positive.
1482
+ # - descending=true (Price Descending 100->90) => Score Descending (100->90) => zrevrange
1483
+ # - descending=false (Price Ascending 90->100) => Score Ascending (90->100) => zrange
1484
+
1485
+ range_method_flag = descending
1486
+ range_method_flag = !range_method_flag if side == RedisExchange::SIDE_BUY
1487
+ range_method = range_method_flag ? :zrevrange : :zrange
1488
+
1489
+ stop = limit ? limit - 1 : -1
1490
+ raw = @redis.public_send(range_method, book_key, 0, stop, with_scores: true)
1491
+ pairs = if raw.is_a?(Array) && raw.first.is_a?(Array)
1492
+ raw
1493
+ else
1494
+ Array(raw).each_slice(2).to_a
1495
+ end
1496
+ prices = pairs.map { |member, _score| member.to_s }
1497
+ # One HMGET instead of one HGET per level: a 20-level book used to cost 20
1498
+ # round trips per side, dominate the read path, and now costs one.
1499
+ volumes = prices.empty? ? [] : Array(@redis.hmget(volume_key, *prices))
1500
+ list = pairs.each_with_index.map do |(member, score), idx|
1501
+ {
1502
+ price: member.to_s.to_i,
1503
+ volume: volumes[idx].to_i,
1504
+ score: score.to_f
1505
+ }
1506
+ end
1507
+ list.select! { |row| row[:volume].positive? }
1508
+ limit ? list.first(limit) : list
1509
+ end
1510
+
1511
+
1512
+ # Fallback-related helpers removed; cancellations handled via Lua scripts.
1513
+
1514
+ private
1515
+
1516
+ # Scale a human-readable asset amount (e.g., 1.5 BTC) into an integer using balance_scale.
1517
+ def scale_asset_amount(asset, amount)
1518
+ cfg = asset_config(asset)
1519
+ scale = cfg[:balance_scale] || 1
1520
+ (amount.to_f * scale).floor
1521
+ end
1522
+
1523
+ # Scale a human-readable price into an integer using market price_scale.
1524
+ def scale_price(symbol, price)
1525
+ cfg = market_config(symbol)
1526
+ scale = cfg[:price_scale] || 1
1527
+ (price.to_f * scale).floor
1528
+ end
1529
+
1530
+ # Scale a human-readable quantity into an integer using market quantity_scale.
1531
+ def scale_quantity(symbol, quantity)
1532
+ cfg = market_config(symbol)
1533
+ scale = cfg[:quantity_scale] || 1
1534
+ (quantity.to_f * scale).floor
1535
+ end
1536
+
1537
+ # Unscale an integer amount into a human-readable float using balance_scale.
1538
+ def unscale_asset_amount(asset, integer_amount)
1539
+ cfg = asset_config(asset)
1540
+ scale = cfg[:balance_scale] || 1
1541
+ integer_amount.to_f / scale
1542
+ end
1543
+ end
1544
+ end