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