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,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.'