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,314 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renames an asset code in Redis (e.g. BTC -> XBT).
|
|
5
|
+
#
|
|
6
|
+
# WARNING:
|
|
7
|
+
# - This mutates underlying Redis data. Run with --dry first (the default).
|
|
8
|
+
# - Historical stream entries (account_ledger, trades:{symbol}) CANNOT be renamed.
|
|
9
|
+
# Downstream consumers must handle old_asset -> new_asset mapping if needed.
|
|
10
|
+
# - Active orders in markets that reference the old asset must be cancelled first.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# ruby script/rename_asset.rb --old-asset BTC --new-asset XBT --dry
|
|
14
|
+
# ruby script/rename_asset.rb --old-asset BTC --new-asset XBT --execute
|
|
15
|
+
#
|
|
16
|
+
# Affected Redis keys/fields:
|
|
17
|
+
# asset:{old} -> asset:{new}
|
|
18
|
+
# market:{symbol}.base_asset -> updated if == old
|
|
19
|
+
# market:{symbol}.quote_asset -> updated if == old
|
|
20
|
+
# balance:{user_id}.{old}:available -> {new}:available
|
|
21
|
+
# balance:{user_id}.{old}:reserved -> {new}:reserved
|
|
22
|
+
# fees:{old} -> fees:{new}
|
|
23
|
+
# ledger_seq:{user_id}:{old} -> ledger_seq:{user_id}:{new}
|
|
24
|
+
|
|
25
|
+
require 'bundler/setup'
|
|
26
|
+
require 'optparse'
|
|
27
|
+
require 'redis'
|
|
28
|
+
require 'redis_exchange'
|
|
29
|
+
|
|
30
|
+
options = {
|
|
31
|
+
redis_url: ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0',
|
|
32
|
+
dry_run: true,
|
|
33
|
+
cancel_orders: false,
|
|
34
|
+
skip_ledger_seq: false,
|
|
35
|
+
force: false,
|
|
36
|
+
count: 1000,
|
|
37
|
+
users_file: nil
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
OptionParser.new do |opts|
|
|
41
|
+
opts.banner = 'Rename an asset code in Redis'
|
|
42
|
+
|
|
43
|
+
opts.on('--old-asset ASSET', 'Current asset code (e.g. BTC)') do |v|
|
|
44
|
+
options[:old_asset] = v.to_s.strip.upcase
|
|
45
|
+
end
|
|
46
|
+
opts.on('--new-asset ASSET', 'New asset code (e.g. XBT)') do |v|
|
|
47
|
+
options[:new_asset] = v.to_s.strip.upcase
|
|
48
|
+
end
|
|
49
|
+
opts.on('--redis-url URL', "Redis URL (default: ENV['REDIS_URL'] or local)") do |v|
|
|
50
|
+
options[:redis_url] = v
|
|
51
|
+
end
|
|
52
|
+
opts.on('--execute', 'Actually perform the rename (default is dry-run)') do
|
|
53
|
+
options[:dry_run] = false
|
|
54
|
+
end
|
|
55
|
+
opts.on('--cancel-orders', 'Automatically cancel active orders in affected markets') do
|
|
56
|
+
options[:cancel_orders] = true
|
|
57
|
+
end
|
|
58
|
+
opts.on('--skip-ledger-seq', 'Do not rename ledger_seq:{user_id}:{old} keys') do
|
|
59
|
+
options[:skip_ledger_seq] = true
|
|
60
|
+
end
|
|
61
|
+
opts.on('--force', 'Allow overwriting an existing new asset config') do
|
|
62
|
+
options[:force] = true
|
|
63
|
+
end
|
|
64
|
+
opts.on('--count=N', Integer, 'SCAN count hint for key iteration (default: 1000)') do |v|
|
|
65
|
+
options[:count] = v.positive? ? v : 1000
|
|
66
|
+
end
|
|
67
|
+
opts.on('--users-file PATH', 'File containing user IDs to process (one per line); skips balance:* scan') do |v|
|
|
68
|
+
options[:users_file] = v
|
|
69
|
+
end
|
|
70
|
+
opts.on('-h', '--help', 'Show this help message') do
|
|
71
|
+
puts opts
|
|
72
|
+
exit
|
|
73
|
+
end
|
|
74
|
+
end.parse!
|
|
75
|
+
|
|
76
|
+
abort '[rename_asset] --old-asset is required' if options[:old_asset].nil? || options[:old_asset].empty?
|
|
77
|
+
abort '[rename_asset] --new-asset is required' if options[:new_asset].nil? || options[:new_asset].empty?
|
|
78
|
+
abort '[rename_asset] old and new asset codes must be different' if options[:old_asset] == options[:new_asset]
|
|
79
|
+
|
|
80
|
+
old_asset = options[:old_asset]
|
|
81
|
+
new_asset = options[:new_asset]
|
|
82
|
+
dry_run = options[:dry_run]
|
|
83
|
+
|
|
84
|
+
# Load explicit user list if provided.
|
|
85
|
+
explicit_users = nil
|
|
86
|
+
if options[:users_file]
|
|
87
|
+
abort "[rename_asset] Users file not found: #{options[:users_file]}" unless File.exist?(options[:users_file])
|
|
88
|
+
explicit_users = File.readlines(options[:users_file])
|
|
89
|
+
.map(&:strip)
|
|
90
|
+
.reject(&:empty?)
|
|
91
|
+
.uniq
|
|
92
|
+
puts "[rename_asset] Loaded #{explicit_users.size} user(s) from #{options[:users_file]}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
redis = Redis.new(url: options[:redis_url])
|
|
96
|
+
begin
|
|
97
|
+
redis.ping
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
abort "[rename_asset] Failed to connect to Redis at #{options[:redis_url]}: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
client = RedisExchange::Client.new(redis: redis)
|
|
103
|
+
|
|
104
|
+
puts "[rename_asset] Mode: #{dry_run ? 'DRY RUN' : 'EXECUTE'}"
|
|
105
|
+
puts "[rename_asset] Renaming #{old_asset} -> #{new_asset}"
|
|
106
|
+
|
|
107
|
+
# 1. Validate old asset exists
|
|
108
|
+
old_config = client.asset_config(old_asset)
|
|
109
|
+
abort "[rename_asset] Asset config not found: asset:#{old_asset}" if old_config.empty?
|
|
110
|
+
|
|
111
|
+
# 2. Validate new asset does not exist (unless --force)
|
|
112
|
+
unless options[:force]
|
|
113
|
+
new_config = client.asset_config(new_asset)
|
|
114
|
+
abort "[rename_asset] Asset config already exists: asset:#{new_asset} (use --force to overwrite)" unless new_config.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 3. Find markets referencing the old asset
|
|
118
|
+
affected_markets = []
|
|
119
|
+
market_scanned = 0
|
|
120
|
+
redis.scan_each(match: 'market:*', count: options[:count]) do |key|
|
|
121
|
+
market_scanned += 1
|
|
122
|
+
symbol = key.sub('market:', '')
|
|
123
|
+
cfg = client.market_config(symbol)
|
|
124
|
+
if cfg[:base_asset] == old_asset || cfg[:quote_asset] == old_asset
|
|
125
|
+
affected_markets << symbol
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
puts "[rename_asset] Scanned #{market_scanned} market key(s), found #{affected_markets.size} affected"
|
|
129
|
+
|
|
130
|
+
puts "[rename_asset] Affected markets: #{affected_markets.empty? ? 'none' : affected_markets.join(', ')}"
|
|
131
|
+
|
|
132
|
+
# 4. Check for active orders in affected markets
|
|
133
|
+
active_orders = []
|
|
134
|
+
affected_markets.each do |symbol|
|
|
135
|
+
client.market_active_orders(symbol: symbol).each do |order|
|
|
136
|
+
active_orders << { order_id: order[:order_id], symbol: symbol, user_id: order[:user_id] }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
unless active_orders.empty?
|
|
141
|
+
puts "[rename_asset] Found #{active_orders.size} active order(s) in affected markets:"
|
|
142
|
+
active_orders.first(10).each { |o| puts " - #{o[:order_id]} (#{o[:symbol]}, user=#{o[:user_id]})" }
|
|
143
|
+
puts ' ...' if active_orders.size > 10
|
|
144
|
+
|
|
145
|
+
if options[:cancel_orders]
|
|
146
|
+
puts '[rename_asset] Cancelling active orders...'
|
|
147
|
+
unless dry_run
|
|
148
|
+
active_orders.each do |o|
|
|
149
|
+
res = client.maintenance.force_cancel_order(order_id: o[:order_id], mode: :best_effort_unlock)
|
|
150
|
+
if res[:error]
|
|
151
|
+
abort "[rename_asset] Failed to cancel order #{o[:order_id]}: #{res[:error]}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
puts "[rename_asset] Cancelled #{active_orders.size} order(s)"
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
abort '[rename_asset] Please cancel all active orders first, or re-run with --cancel-orders'
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# 5. Build migration plan
|
|
162
|
+
plan = {
|
|
163
|
+
asset_config: { from: "asset:#{old_asset}", to: "asset:#{new_asset}" },
|
|
164
|
+
markets: affected_markets,
|
|
165
|
+
balance_users: [],
|
|
166
|
+
fee_key: { from: "fees:#{old_asset}", to: "fees:#{new_asset}" },
|
|
167
|
+
ledger_seq_keys: []
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if explicit_users
|
|
171
|
+
puts '[rename_asset] Using explicit user list for balance lookup...'
|
|
172
|
+
explicit_users.each_with_index do |user_id, idx|
|
|
173
|
+
key = "balance:#{user_id}"
|
|
174
|
+
if redis.hexists(key, "#{old_asset}:available") || redis.hexists(key, "#{old_asset}:reserved")
|
|
175
|
+
plan[:balance_users] << user_id
|
|
176
|
+
end
|
|
177
|
+
if ((idx + 1) % 10_000).zero?
|
|
178
|
+
puts "[rename_asset] Checked #{idx + 1}/#{explicit_users.size} user(s), matched #{plan[:balance_users].size}..."
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
puts "[rename_asset] Checked #{explicit_users.size} user(s), matched #{plan[:balance_users].size}"
|
|
182
|
+
else
|
|
183
|
+
balance_scanned = 0
|
|
184
|
+
redis.scan_each(match: 'balance:*', count: options[:count]) do |key|
|
|
185
|
+
balance_scanned += 1
|
|
186
|
+
if redis.hexists(key, "#{old_asset}:available") || redis.hexists(key, "#{old_asset}:reserved")
|
|
187
|
+
user_id = key.sub('balance:', '')
|
|
188
|
+
plan[:balance_users] << user_id
|
|
189
|
+
end
|
|
190
|
+
if (balance_scanned % 10_000).zero?
|
|
191
|
+
puts "[rename_asset] Scanned #{balance_scanned} balance key(s), matched #{plan[:balance_users].size} user(s)..."
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
puts "[rename_asset] Scanned #{balance_scanned} balance key(s), matched #{plan[:balance_users].size} user(s)"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if redis.exists?("fees:#{old_asset}")
|
|
198
|
+
plan[:fee_key][:exists] = true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
unless options[:skip_ledger_seq]
|
|
202
|
+
if explicit_users
|
|
203
|
+
puts '[rename_asset] Using explicit user list for ledger_seq lookup...'
|
|
204
|
+
explicit_users.each do |user_id|
|
|
205
|
+
key = "ledger_seq:#{user_id}:#{old_asset}"
|
|
206
|
+
plan[:ledger_seq_keys] << key if redis.exists?(key)
|
|
207
|
+
end
|
|
208
|
+
puts "[rename_asset] Checked #{explicit_users.size} user(s), matched #{plan[:ledger_seq_keys].size} ledger_seq key(s)"
|
|
209
|
+
else
|
|
210
|
+
ledger_scanned = 0
|
|
211
|
+
redis.scan_each(match: "ledger_seq:*:#{old_asset}", count: options[:count]) do |key|
|
|
212
|
+
ledger_scanned += 1
|
|
213
|
+
plan[:ledger_seq_keys] << key
|
|
214
|
+
end
|
|
215
|
+
puts "[rename_asset] Scanned #{ledger_scanned} ledger_seq key(s), matched #{plan[:ledger_seq_keys].size}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
puts '[rename_asset] Migration plan:'
|
|
220
|
+
puts " - Asset config: asset:#{old_asset} -> asset:#{new_asset}"
|
|
221
|
+
puts " - Markets to update: #{affected_markets.size}"
|
|
222
|
+
puts " - User balances to update: #{plan[:balance_users].size}"
|
|
223
|
+
puts " - Fees key to rename: #{plan[:fee_key][:exists] ? 'yes' : 'no'}"
|
|
224
|
+
puts " - Ledger seq keys to rename: #{plan[:ledger_seq_keys].size}"
|
|
225
|
+
|
|
226
|
+
if dry_run
|
|
227
|
+
puts '[rename_asset] Dry run complete. Re-run with --execute to apply changes.'
|
|
228
|
+
exit 0
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# 6. Enter maintenance mode
|
|
232
|
+
puts '[rename_asset] Enabling system maintenance mode...'
|
|
233
|
+
client.maintenance.enable
|
|
234
|
+
|
|
235
|
+
begin
|
|
236
|
+
# 6.1 Migrate asset config
|
|
237
|
+
puts '[rename_asset] Migrating asset config...'
|
|
238
|
+
redis.hset("asset:#{new_asset}", old_config.transform_keys(&:to_s).transform_values(&:to_s))
|
|
239
|
+
redis.del("asset:#{old_asset}")
|
|
240
|
+
|
|
241
|
+
# 6.2 Update affected markets
|
|
242
|
+
unless affected_markets.empty?
|
|
243
|
+
puts '[rename_asset] Updating market configs...'
|
|
244
|
+
affected_markets.each do |symbol|
|
|
245
|
+
cfg = client.market_config(symbol)
|
|
246
|
+
updates = {}
|
|
247
|
+
updates['base_asset'] = new_asset if cfg[:base_asset] == old_asset
|
|
248
|
+
updates['quote_asset'] = new_asset if cfg[:quote_asset] == old_asset
|
|
249
|
+
redis.hset("market:#{symbol}", updates) unless updates.empty?
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# 6.3 Migrate user balances
|
|
254
|
+
unless plan[:balance_users].empty?
|
|
255
|
+
puts '[rename_asset] Migrating user balances...'
|
|
256
|
+
plan[:balance_users].each_with_index do |user_id, idx|
|
|
257
|
+
key = "balance:#{user_id}"
|
|
258
|
+
avail = redis.hget(key, "#{old_asset}:available").to_i
|
|
259
|
+
resv = redis.hget(key, "#{old_asset}:reserved").to_i
|
|
260
|
+
|
|
261
|
+
redis.pipelined do |p|
|
|
262
|
+
p.hset(key, "#{new_asset}:available", avail, "#{new_asset}:reserved", resv)
|
|
263
|
+
p.hdel(key, "#{old_asset}:available", "#{old_asset}:reserved")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if ((idx + 1) % 1000).zero?
|
|
267
|
+
puts "[rename_asset] Migrated #{idx + 1}/#{plan[:balance_users].size} user balances..."
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
puts "[rename_asset] Migrated #{plan[:balance_users].size} user balance(s)"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# 6.4 Rename fees key
|
|
274
|
+
if plan[:fee_key][:exists]
|
|
275
|
+
puts '[rename_asset] Renaming fees key...'
|
|
276
|
+
redis.rename("fees:#{old_asset}", "fees:#{new_asset}")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# 6.5 Rename ledger_seq keys
|
|
280
|
+
unless options[:skip_ledger_seq] || plan[:ledger_seq_keys].empty?
|
|
281
|
+
puts '[rename_asset] Renaming ledger_seq keys...'
|
|
282
|
+
plan[:ledger_seq_keys].each_with_index do |old_key, idx|
|
|
283
|
+
new_key = old_key.sub(/:#{old_asset}$/, ":#{new_asset}")
|
|
284
|
+
redis.rename(old_key, new_key)
|
|
285
|
+
if ((idx + 1) % 1000).zero?
|
|
286
|
+
puts "[rename_asset] Renamed #{idx + 1}/#{plan[:ledger_seq_keys].size} ledger_seq keys..."
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
puts "[rename_asset] Renamed #{plan[:ledger_seq_keys].size} ledger_seq key(s)"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
puts '[rename_asset] Rename complete.'
|
|
293
|
+
|
|
294
|
+
# 7. Run balance audit for affected users
|
|
295
|
+
unless plan[:balance_users].empty?
|
|
296
|
+
puts '[rename_asset] Running balance audit...'
|
|
297
|
+
plan[:balance_users].each_with_index do |user_id, idx|
|
|
298
|
+
reports = client.maintenance.audit_user_balances(user_id: user_id, fix: true)
|
|
299
|
+
reports.each do |r|
|
|
300
|
+
puts " [audit] user=#{r[:user_id]} deficits=#{r[:deficits].size} fixes=#{r[:fixes].size}"
|
|
301
|
+
end
|
|
302
|
+
if ((idx + 1) % 100).zero?
|
|
303
|
+
puts "[rename_asset] Audited #{idx + 1}/#{plan[:balance_users].size} user(s)..."
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
puts "[rename_asset] Audited #{plan[:balance_users].size} user(s)"
|
|
307
|
+
end
|
|
308
|
+
ensure
|
|
309
|
+
puts '[rename_asset] Disabling system maintenance mode...'
|
|
310
|
+
client.maintenance.disable
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
puts "[rename_asset] #{old_asset} has been renamed to #{new_asset}."
|
|
314
|
+
puts '[rename_asset] NOTE: Historical account_ledger and trades:{symbol} stream entries still reference the old asset code.'
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renames a market symbol in Redis (e.g. BTCUSDT -> XBTUSDT).
|
|
5
|
+
#
|
|
6
|
+
# WARNING:
|
|
7
|
+
# - This mutates underlying Redis data. Run with --dry first (the default).
|
|
8
|
+
# - All active orders for the old symbol are migrated to the new symbol.
|
|
9
|
+
# - Historical trades/orders streams are renamed by default, preserving history.
|
|
10
|
+
# Use --reset-streams if you want fresh empty streams for the new symbol.
|
|
11
|
+
# - This is a maintenance operation: the old market is placed under MAINTENANCE
|
|
12
|
+
# mode during the rename.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# ruby script/rename_market.rb --old-symbol BTCUSDT --new-symbol XBTUSDT --dry
|
|
16
|
+
# ruby script/rename_market.rb --old-symbol BTCUSDT --new-symbol XBTUSDT --execute
|
|
17
|
+
#
|
|
18
|
+
# Affected Redis keys (examples for BTCUSDT -> XBTUSDT):
|
|
19
|
+
# market:BTCUSDT -> market:XBTUSDT
|
|
20
|
+
# book:BTCUSDT:BUY / SELL -> book:XBTUSDT:BUY / SELL
|
|
21
|
+
# volume:BTCUSDT:BUY / SELL -> volume:XBTUSDT:BUY / SELL
|
|
22
|
+
# queue:BTCUSDT:BUY/SELL:{price} -> queue:XBTUSDT:BUY/SELL:{price}
|
|
23
|
+
# seq:BTCUSDT -> seq:XBTUSDT
|
|
24
|
+
# ticker:BTCUSDT -> ticker:XBTUSDT
|
|
25
|
+
# trades:BTCUSDT -> trades:XBTUSDT (or reset)
|
|
26
|
+
# orders:BTCUSDT -> orders:XBTUSDT (or reset)
|
|
27
|
+
# user_orders:{user_id}:BTCUSDT -> user_orders:{user_id}:XBTUSDT
|
|
28
|
+
# order:{order_id}.symbol -> "XBTUSDT"
|
|
29
|
+
|
|
30
|
+
require 'bundler/setup'
|
|
31
|
+
require 'optparse'
|
|
32
|
+
require 'redis'
|
|
33
|
+
require 'redis_exchange'
|
|
34
|
+
|
|
35
|
+
options = {
|
|
36
|
+
redis_url: ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0',
|
|
37
|
+
dry_run: true,
|
|
38
|
+
cancel_orders: false,
|
|
39
|
+
reset_streams: false,
|
|
40
|
+
force: false,
|
|
41
|
+
count: 1000
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
OptionParser.new do |opts|
|
|
45
|
+
opts.banner = 'Rename a market symbol in Redis'
|
|
46
|
+
|
|
47
|
+
opts.on('--old-symbol SYMBOL', 'Current market symbol (e.g. BTCUSDT)') do |v|
|
|
48
|
+
options[:old_symbol] = v.to_s.strip.upcase
|
|
49
|
+
end
|
|
50
|
+
opts.on('--new-symbol SYMBOL', 'New market symbol (e.g. XBTUSDT)') do |v|
|
|
51
|
+
options[:new_symbol] = v.to_s.strip.upcase
|
|
52
|
+
end
|
|
53
|
+
opts.on('--redis-url URL', "Redis URL (default: ENV['REDIS_URL'] or local)") do |v|
|
|
54
|
+
options[:redis_url] = v
|
|
55
|
+
end
|
|
56
|
+
opts.on('--execute', 'Actually perform the rename (default is dry-run)') do
|
|
57
|
+
options[:dry_run] = false
|
|
58
|
+
end
|
|
59
|
+
opts.on('--cancel-orders', 'Automatically cancel active orders before rename') do
|
|
60
|
+
options[:cancel_orders] = true
|
|
61
|
+
end
|
|
62
|
+
opts.on('--reset-streams', 'Create fresh empty trades/orders streams for the new symbol') do
|
|
63
|
+
options[:reset_streams] = true
|
|
64
|
+
end
|
|
65
|
+
opts.on('--force', 'Allow overwriting an existing new market config') do
|
|
66
|
+
options[:force] = true
|
|
67
|
+
end
|
|
68
|
+
opts.on('--count=N', Integer, 'SCAN count hint for key iteration (default: 1000)') do |v|
|
|
69
|
+
options[:count] = v.positive? ? v : 1000
|
|
70
|
+
end
|
|
71
|
+
opts.on('-h', '--help', 'Show this help message') do
|
|
72
|
+
puts opts
|
|
73
|
+
exit
|
|
74
|
+
end
|
|
75
|
+
end.parse!
|
|
76
|
+
|
|
77
|
+
abort '[rename_market] --old-symbol is required' if options[:old_symbol].nil? || options[:old_symbol].empty?
|
|
78
|
+
abort '[rename_market] --new-symbol is required' if options[:new_symbol].nil? || options[:new_symbol].empty?
|
|
79
|
+
abort '[rename_market] old and new symbols must be different' if options[:old_symbol] == options[:new_symbol]
|
|
80
|
+
|
|
81
|
+
old_symbol = options[:old_symbol]
|
|
82
|
+
new_symbol = options[:new_symbol]
|
|
83
|
+
dry_run = options[:dry_run]
|
|
84
|
+
|
|
85
|
+
redis = Redis.new(url: options[:redis_url])
|
|
86
|
+
begin
|
|
87
|
+
redis.ping
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
abort "[rename_market] Failed to connect to Redis at #{options[:redis_url]}: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
client = RedisExchange::Client.new(redis: redis)
|
|
93
|
+
|
|
94
|
+
puts "[rename_market] Mode: #{dry_run ? 'DRY RUN' : 'EXECUTE'}"
|
|
95
|
+
puts "[rename_market] Renaming market #{old_symbol} -> #{new_symbol}"
|
|
96
|
+
|
|
97
|
+
# 1. Validate old market exists
|
|
98
|
+
old_market_key = "market:#{old_symbol}"
|
|
99
|
+
old_config = client.market_config(old_symbol)
|
|
100
|
+
abort "[rename_market] Market not found: #{old_market_key}" if old_config.empty?
|
|
101
|
+
|
|
102
|
+
# 2. Validate new market does not exist (unless --force)
|
|
103
|
+
new_market_key = "market:#{new_symbol}"
|
|
104
|
+
unless options[:force]
|
|
105
|
+
abort "[rename_market] Market already exists: #{new_market_key}" if redis.exists?(new_market_key)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# 3. Check for active orders
|
|
109
|
+
active_orders = client.market_active_orders(symbol: old_symbol)
|
|
110
|
+
|
|
111
|
+
unless active_orders.empty?
|
|
112
|
+
puts "[rename_market] Found #{active_orders.size} active order(s) for #{old_symbol}:"
|
|
113
|
+
active_orders.first(10).each { |o| puts " - #{o[:order_id]} (user=#{o[:user_id]})" }
|
|
114
|
+
puts ' ...' if active_orders.size > 10
|
|
115
|
+
|
|
116
|
+
if options[:cancel_orders]
|
|
117
|
+
puts '[rename_market] Cancelling active orders before rename...'
|
|
118
|
+
unless dry_run
|
|
119
|
+
active_orders.each do |o|
|
|
120
|
+
res = client.maintenance.force_cancel_order(order_id: o[:order_id], mode: :best_effort_unlock)
|
|
121
|
+
abort "[rename_market] Failed to cancel order #{o[:order_id]}: #{res[:error]}" if res[:error]
|
|
122
|
+
end
|
|
123
|
+
puts "[rename_market] Cancelled #{active_orders.size} order(s)"
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
puts '[rename_market] Active orders will be migrated to the new symbol.'
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 4. Gather user order indexes
|
|
131
|
+
user_order_indexes = []
|
|
132
|
+
index_scanned = 0
|
|
133
|
+
redis.scan_each(match: "user_orders:*:#{old_symbol}", count: options[:count]) do |key|
|
|
134
|
+
user_order_indexes << key
|
|
135
|
+
index_scanned += 1
|
|
136
|
+
if (index_scanned % 10_000).zero?
|
|
137
|
+
puts "[rename_market] Scanned #{index_scanned} user_order index key(s), matched #{user_order_indexes.size}..."
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
puts "[rename_market] Scanned #{index_scanned} user_order index key(s), matched #{user_order_indexes.size}"
|
|
141
|
+
|
|
142
|
+
puts "[rename_market] Migration plan:"
|
|
143
|
+
puts " - Market config: #{old_market_key} -> #{new_market_key}"
|
|
144
|
+
puts " - Order book keys: book/volume/queue/seq/ticker"
|
|
145
|
+
puts " - User order indexes: #{user_order_indexes.size}"
|
|
146
|
+
puts " - Active orders: #{active_orders.size} (#{options[:cancel_orders] ? 'will be cancelled' : 'will be migrated'})"
|
|
147
|
+
puts " - Trades/orders stream: #{options[:reset_streams] ? 'reset to empty' : 'rename preserving history'}"
|
|
148
|
+
|
|
149
|
+
if dry_run
|
|
150
|
+
puts '[rename_market] Dry run complete. Re-run with --execute to apply changes.'
|
|
151
|
+
exit 0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# 5. Enter maintenance mode
|
|
155
|
+
puts "[rename_market] Setting #{old_symbol} to MAINTENANCE..."
|
|
156
|
+
client.maintenance.enable_market(old_symbol)
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
# 6. Rename simple keys
|
|
160
|
+
simple_key_pairs = [
|
|
161
|
+
["book:#{old_symbol}:BUY", "book:#{new_symbol}:BUY"],
|
|
162
|
+
["book:#{old_symbol}:SELL", "book:#{new_symbol}:SELL"],
|
|
163
|
+
["volume:#{old_symbol}:BUY", "volume:#{new_symbol}:BUY"],
|
|
164
|
+
["volume:#{old_symbol}:SELL", "volume:#{new_symbol}:SELL"],
|
|
165
|
+
["seq:#{old_symbol}", "seq:#{new_symbol}"],
|
|
166
|
+
["ticker:#{old_symbol}", "ticker:#{new_symbol}"]
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
simple_key_pairs.each do |old_key, new_key|
|
|
170
|
+
if redis.exists?(old_key)
|
|
171
|
+
puts "[rename_market] Renaming #{old_key} -> #{new_key}"
|
|
172
|
+
redis.rename(old_key, new_key)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# 7. Rename queue keys for each price level
|
|
177
|
+
[RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
|
|
178
|
+
book_key = "book:#{new_symbol}:#{side}"
|
|
179
|
+
prices = redis.zrange(book_key, 0, -1)
|
|
180
|
+
prices.each do |price|
|
|
181
|
+
old_queue = "queue:#{old_symbol}:#{side}:#{price}"
|
|
182
|
+
new_queue = "queue:#{new_symbol}:#{side}:#{price}"
|
|
183
|
+
if redis.exists?(old_queue)
|
|
184
|
+
puts "[rename_market] Renaming #{old_queue} -> #{new_queue}"
|
|
185
|
+
redis.rename(old_queue, new_queue)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Clean up any orphan old queues (just in case)
|
|
190
|
+
orphan_scanned = 0
|
|
191
|
+
redis.scan_each(match: "queue:#{old_symbol}:#{side}:*", count: options[:count]) do |old_queue|
|
|
192
|
+
orphan_scanned += 1
|
|
193
|
+
new_queue = old_queue.sub("queue:#{old_symbol}:#{side}:", "queue:#{new_symbol}:#{side}:")
|
|
194
|
+
puts "[rename_market] Renaming orphan #{old_queue} -> #{new_queue}"
|
|
195
|
+
redis.rename(old_queue, new_queue)
|
|
196
|
+
end
|
|
197
|
+
puts "[rename_market] Renamed #{orphan_scanned} orphan queue key(s) on #{side}" if orphan_scanned.positive?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# 8. Update symbol field in all order hashes referenced by user order indexes
|
|
201
|
+
migrated_orders = 0
|
|
202
|
+
user_order_indexes.each_with_index do |old_index, idx|
|
|
203
|
+
order_ids = redis.zrange(old_index, 0, -1)
|
|
204
|
+
order_ids.each do |order_id|
|
|
205
|
+
order_key = "order:#{order_id}"
|
|
206
|
+
if redis.hexists(order_key, 'symbol')
|
|
207
|
+
redis.hset(order_key, 'symbol', new_symbol)
|
|
208
|
+
migrated_orders += 1
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Rename the index key itself. Format: user_orders:{user_id}:{symbol}
|
|
213
|
+
user_id = old_index.split(':')[1]
|
|
214
|
+
new_index = "user_orders:#{user_id}:#{new_symbol}"
|
|
215
|
+
redis.rename(old_index, new_index)
|
|
216
|
+
|
|
217
|
+
if ((idx + 1) % 1000).zero?
|
|
218
|
+
puts "[rename_market] Migrated #{idx + 1}/#{user_order_indexes.size} user order indexes..."
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
puts "[rename_market] Updated #{migrated_orders} order hash(es) across #{user_order_indexes.size} user index(es)"
|
|
222
|
+
|
|
223
|
+
# 9. Rename or reset streams
|
|
224
|
+
trades_old = "trades:#{old_symbol}"
|
|
225
|
+
trades_new = "trades:#{new_symbol}"
|
|
226
|
+
orders_old = "orders:#{old_symbol}"
|
|
227
|
+
orders_new = "orders:#{new_symbol}"
|
|
228
|
+
|
|
229
|
+
if options[:reset_streams]
|
|
230
|
+
puts "[rename_market] Resetting streams for new symbol (old streams preserved)"
|
|
231
|
+
redis.del(trades_new)
|
|
232
|
+
redis.del(orders_new)
|
|
233
|
+
# Lua will recreate the streams on the next XADD with MAXLEN from market config.
|
|
234
|
+
else
|
|
235
|
+
if redis.exists?(trades_old)
|
|
236
|
+
puts "[rename_market] Renaming #{trades_old} -> #{trades_new}"
|
|
237
|
+
redis.rename(trades_old, trades_new)
|
|
238
|
+
end
|
|
239
|
+
if redis.exists?(orders_old)
|
|
240
|
+
puts "[rename_market] Renaming #{orders_old} -> #{orders_new}"
|
|
241
|
+
redis.rename(orders_old, orders_new)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# 10. Create new market config and delete old one
|
|
246
|
+
puts "[rename_market] Creating #{new_market_key}..."
|
|
247
|
+
mapping = {
|
|
248
|
+
'base_asset' => old_config[:base_asset],
|
|
249
|
+
'quote_asset' => old_config[:quote_asset],
|
|
250
|
+
'price_scale' => old_config[:price_scale],
|
|
251
|
+
'quantity_scale' => old_config[:quantity_scale],
|
|
252
|
+
'maker_fee_bps' => old_config[:maker_fee_bps],
|
|
253
|
+
'taker_fee_bps' => old_config[:taker_fee_bps],
|
|
254
|
+
'min_qty' => old_config[:min_qty],
|
|
255
|
+
'min_notional' => old_config[:min_notional],
|
|
256
|
+
'status' => RedisExchange::MARKET_STATUS_OPEN
|
|
257
|
+
}
|
|
258
|
+
redis.hset(new_market_key, mapping)
|
|
259
|
+
redis.del(old_market_key)
|
|
260
|
+
|
|
261
|
+
puts "[rename_market] Rename complete."
|
|
262
|
+
|
|
263
|
+
# 11. Audit affected users
|
|
264
|
+
affected_users = user_order_indexes.map { |k| k.split(':')[1] }.uniq
|
|
265
|
+
unless affected_users.empty?
|
|
266
|
+
puts '[rename_market] Running balance audit for affected users...'
|
|
267
|
+
affected_users.each_with_index do |user_id, idx|
|
|
268
|
+
reports = client.maintenance.audit_user_balances(user_id: user_id, fix: true)
|
|
269
|
+
reports.each do |r|
|
|
270
|
+
puts " [audit] user=#{r[:user_id]} deficits=#{r[:deficits].size} fixes=#{r[:fixes].size}"
|
|
271
|
+
end
|
|
272
|
+
if ((idx + 1) % 100).zero?
|
|
273
|
+
puts "[rename_market] Audited #{idx + 1}/#{affected_users.size} user(s)..."
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
puts "[rename_market] Audited #{affected_users.size} user(s)"
|
|
277
|
+
end
|
|
278
|
+
ensure
|
|
279
|
+
# Always try to restore new market to OPEN and old market no longer exists
|
|
280
|
+
puts "[rename_market] Restoring market status..."
|
|
281
|
+
client.maintenance.disable_market(new_symbol) if redis.exists?(new_market_key)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
puts "[rename_market] #{old_symbol} has been renamed to #{new_symbol}."
|
|
285
|
+
puts '[rename_market] NOTE: Historical ledger entries (account_ledger) still reference the old market symbol if any exist.'
|