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.
- checksums.yaml +7 -0
- data/README.md +542 -0
- data/docs/architecture.md +326 -0
- data/lib/redis_exchange/depth_stream_consumer.rb +39 -0
- data/lib/redis_exchange/ledger_stream_consumer.rb +14 -0
- data/lib/redis_exchange/maintenance.rb +641 -0
- data/lib/redis_exchange/order_stream_consumer.rb +17 -0
- data/lib/redis_exchange/stream_consumer.rb +163 -0
- data/lib/redis_exchange/trade_stream_consumer.rb +17 -0
- data/lib/redis_exchange/version.rb +5 -0
- data/lib/redis_exchange.rb +1544 -0
- data/redis_scripts/audit_user_balance.lua +150 -0
- data/redis_scripts/balance_op.lua +148 -0
- data/redis_scripts/cancel_order.lua +277 -0
- data/redis_scripts/finalize_precision_update.lua +88 -0
- data/redis_scripts/force_cancel_order.lua +222 -0
- data/redis_scripts/match_order.lua +1026 -0
- data/redis_scripts/migrate_orders_batch.lua +204 -0
- data/redis_scripts/update_market_precision.lua +260 -0
- data/script/export_rdb_data.sh +234 -0
- data/script/export_snapshot.rb +370 -0
- data/script/maintenance/adjust_precision.rb +176 -0
- data/script/rename_asset.rb +314 -0
- data/script/rename_market.rb +285 -0
- metadata +149 -0
|
@@ -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
|