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,204 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
migrate_orders_batch.lua
|
|
3
|
+
Process a batch of price levels for precision update.
|
|
4
|
+
Reads orders from old queues, converts them, and appends to TMP queues/books.
|
|
5
|
+
|
|
6
|
+
KEYS:
|
|
7
|
+
1. market:{symbol}
|
|
8
|
+
2. book:{symbol}:{side}:TMP
|
|
9
|
+
3. volume:{symbol}:{side}:TMP
|
|
10
|
+
4. orders:{symbol} (for events)
|
|
11
|
+
|
|
12
|
+
ARGV:
|
|
13
|
+
1. symbol
|
|
14
|
+
2. side (BUY/SELL)
|
|
15
|
+
3. new_price_scale
|
|
16
|
+
4. new_quantity_scale
|
|
17
|
+
5. timestamp
|
|
18
|
+
6...N. price_members (strings) to process
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
JSON object { processed_orders: N, processed_levels: M }
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local cjson = cjson
|
|
25
|
+
|
|
26
|
+
local market_key = KEYS[1]
|
|
27
|
+
local book_tmp_key = KEYS[2]
|
|
28
|
+
local volume_tmp_key = KEYS[3]
|
|
29
|
+
local orders_key = KEYS[4]
|
|
30
|
+
|
|
31
|
+
local symbol = ARGV[1]
|
|
32
|
+
local side = ARGV[2]
|
|
33
|
+
local new_price_scale = tonumber(ARGV[3])
|
|
34
|
+
local new_quantity_scale = tonumber(ARGV[4])
|
|
35
|
+
local timestamp = tonumber(ARGV[5])
|
|
36
|
+
|
|
37
|
+
-- Helper: Convert HGETALL to map
|
|
38
|
+
local function to_map(array)
|
|
39
|
+
local map = {}
|
|
40
|
+
for i = 1, #array, 2 do
|
|
41
|
+
map[array[i]] = array[i + 1]
|
|
42
|
+
end
|
|
43
|
+
return map
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local function as_number(value)
|
|
47
|
+
return tonumber(value) or 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function append_ledger(user_id, asset, bucket, delta, reason, ts)
|
|
51
|
+
if delta == 0 then return end
|
|
52
|
+
local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
|
|
53
|
+
local ledger_biz_id = 'PRECISION:' .. symbol .. ':' .. ts .. ':' .. seq
|
|
54
|
+
redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
|
|
55
|
+
'user_id', user_id,
|
|
56
|
+
'asset', asset,
|
|
57
|
+
'bucket', bucket,
|
|
58
|
+
'amount', tostring(delta),
|
|
59
|
+
'reason', reason,
|
|
60
|
+
'ts', tostring(ts),
|
|
61
|
+
'sequence', tostring(seq),
|
|
62
|
+
'biz_id', ledger_biz_id
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
-- Load Market Config (for old scales)
|
|
67
|
+
local market = to_map(redis.call('HGETALL', market_key))
|
|
68
|
+
if next(market) == nil then
|
|
69
|
+
return cjson.encode({ error = 'UNKNOWN_MARKET' })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
local base_asset = market['base_asset']
|
|
73
|
+
local quote_asset = market['quote_asset']
|
|
74
|
+
local old_price_scale = tonumber(market['price_scale'])
|
|
75
|
+
local old_quantity_scale = tonumber(market['quantity_scale'])
|
|
76
|
+
local maker_fee_bps = tonumber(market['maker_fee_bps'] or '0')
|
|
77
|
+
|
|
78
|
+
-- Load Asset Factors
|
|
79
|
+
local base_asset_map = to_map(redis.call('HGETALL', 'asset:' .. base_asset))
|
|
80
|
+
local quote_asset_map = to_map(redis.call('HGETALL', 'asset:' .. quote_asset))
|
|
81
|
+
local base_balance_scale = tonumber(base_asset_map['balance_scale'] or '0')
|
|
82
|
+
local quote_balance_scale = tonumber(quote_asset_map['balance_scale'] or '0')
|
|
83
|
+
|
|
84
|
+
local function compute_factor(balance_scale, market_scale)
|
|
85
|
+
if market_scale <= 0 then return 0 end
|
|
86
|
+
local div = math.floor(balance_scale / market_scale)
|
|
87
|
+
if (div * market_scale) ~= balance_scale then return 0 end
|
|
88
|
+
return div
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
local new_base_factor = compute_factor(base_balance_scale, new_quantity_scale)
|
|
92
|
+
local new_quote_factor = compute_factor(quote_balance_scale, new_price_scale)
|
|
93
|
+
|
|
94
|
+
if new_base_factor == 0 or new_quote_factor == 0 then
|
|
95
|
+
return cjson.encode({ error = 'ASSET_SCALE_MISMATCH' })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
local processed_orders_count = 0
|
|
99
|
+
local processed_levels_count = 0
|
|
100
|
+
local is_buy = (side == 'BUY')
|
|
101
|
+
|
|
102
|
+
-- Iterate over provided price members
|
|
103
|
+
for i = 6, #ARGV do
|
|
104
|
+
local old_price_member = ARGV[i]
|
|
105
|
+
local old_price = as_number(old_price_member)
|
|
106
|
+
local old_queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. old_price_member
|
|
107
|
+
|
|
108
|
+
-- Calculate New Price
|
|
109
|
+
local new_price = math.floor(old_price * new_price_scale / old_price_scale)
|
|
110
|
+
local new_price_member = tostring(new_price)
|
|
111
|
+
local new_queue_key_tmp = 'queue:' .. symbol .. ':' .. side .. ':' .. new_price_member .. ':TMP'
|
|
112
|
+
|
|
113
|
+
local orders = redis.call('LRANGE', old_queue_key, 0, -1)
|
|
114
|
+
-- Note: We do NOT delete the old queue here.
|
|
115
|
+
-- We leave old data intact until finalize step to be safe,
|
|
116
|
+
-- OR we can delete it now to save space?
|
|
117
|
+
-- Safety first: If script fails midway, we want to be able to retry.
|
|
118
|
+
-- But if we don't delete, re-running this batch will duplicate data in TMP.
|
|
119
|
+
-- Assumption: The caller handles idempotency or we clear TMP before starting.
|
|
120
|
+
-- Actually, let's delete the old queue key as we process it.
|
|
121
|
+
-- If we crash, we are in a halfway state anyway (Maintenance Mode).
|
|
122
|
+
redis.call('DEL', old_queue_key)
|
|
123
|
+
|
|
124
|
+
for _, order_id in ipairs(orders) do
|
|
125
|
+
local order_key = 'order:' .. order_id
|
|
126
|
+
local order = to_map(redis.call('HGETALL', order_key))
|
|
127
|
+
|
|
128
|
+
if next(order) then
|
|
129
|
+
processed_orders_count = processed_orders_count + 1
|
|
130
|
+
local old_qty = as_number(order['quantity'])
|
|
131
|
+
local old_rem = as_number(order['remaining'])
|
|
132
|
+
local old_reserved_base = as_number(order['reserved_base'])
|
|
133
|
+
local old_reserved_quote = as_number(order['reserved_quote'])
|
|
134
|
+
local user_id = order['user_id']
|
|
135
|
+
|
|
136
|
+
local new_qty = math.floor(old_qty * new_quantity_scale / old_quantity_scale)
|
|
137
|
+
local new_rem = math.floor(old_rem * new_quantity_scale / old_quantity_scale)
|
|
138
|
+
|
|
139
|
+
local new_reserved_base = 0
|
|
140
|
+
local new_reserved_quote = 0
|
|
141
|
+
|
|
142
|
+
if side == 'SELL' then
|
|
143
|
+
new_reserved_base = new_rem * new_base_factor
|
|
144
|
+
else
|
|
145
|
+
local quote_val = math.floor((new_price * new_rem) / new_quantity_scale)
|
|
146
|
+
local fee = math.floor((quote_val * maker_fee_bps) / 10000)
|
|
147
|
+
local total_quote = quote_val + fee
|
|
148
|
+
new_reserved_quote = total_quote * new_quote_factor
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
local refund_base = old_reserved_base - new_reserved_base
|
|
152
|
+
local refund_quote = old_reserved_quote - new_reserved_quote
|
|
153
|
+
|
|
154
|
+
if refund_base > 0 then
|
|
155
|
+
local bal_key = 'balance:' .. user_id
|
|
156
|
+
redis.call('HINCRBY', bal_key, base_asset .. ':reserved', -refund_base)
|
|
157
|
+
redis.call('HINCRBY', bal_key, base_asset .. ':available', refund_base)
|
|
158
|
+
append_ledger(user_id, base_asset, 'available', refund_base, 'DUST_REFUND', timestamp)
|
|
159
|
+
append_ledger(user_id, base_asset, 'reserved', -refund_base, 'DUST_RELEASE', timestamp)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if refund_quote > 0 then
|
|
163
|
+
local bal_key = 'balance:' .. user_id
|
|
164
|
+
redis.call('HINCRBY', bal_key, quote_asset .. ':reserved', -refund_quote)
|
|
165
|
+
redis.call('HINCRBY', bal_key, quote_asset .. ':available', refund_quote)
|
|
166
|
+
append_ledger(user_id, quote_asset, 'available', refund_quote, 'DUST_REFUND', timestamp)
|
|
167
|
+
append_ledger(user_id, quote_asset, 'reserved', -refund_quote, 'DUST_RELEASE', timestamp)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if new_rem > 0 then
|
|
171
|
+
redis.call('HMSET', order_key,
|
|
172
|
+
'price', new_price_member,
|
|
173
|
+
'quantity', tostring(new_qty),
|
|
174
|
+
'remaining', tostring(new_rem),
|
|
175
|
+
'reserved_base', tostring(new_reserved_base),
|
|
176
|
+
'reserved_quote', tostring(new_reserved_quote)
|
|
177
|
+
)
|
|
178
|
+
redis.call('RPUSH', new_queue_key_tmp, order_id)
|
|
179
|
+
|
|
180
|
+
-- Accumulate Volume in TMP
|
|
181
|
+
redis.call('HINCRBY', volume_tmp_key, new_price_member, new_rem)
|
|
182
|
+
|
|
183
|
+
-- Add to TMP Book
|
|
184
|
+
local score = is_buy and -new_price or new_price
|
|
185
|
+
redis.call('ZADD', book_tmp_key, score, new_price_member)
|
|
186
|
+
else
|
|
187
|
+
redis.call('HMSET', order_key,
|
|
188
|
+
'price', new_price_member,
|
|
189
|
+
'quantity', tostring(new_qty),
|
|
190
|
+
'remaining', '0',
|
|
191
|
+
'reserved_base', '0',
|
|
192
|
+
'reserved_quote', '0',
|
|
193
|
+
'status', 'FILLED'
|
|
194
|
+
)
|
|
195
|
+
redis.call('ZREM', 'user_orders:' .. user_id .. ':' .. symbol, order_id)
|
|
196
|
+
redis.call('SET', 'order_tombstone:' .. order_id, 'FILLED', 'EX', 86400)
|
|
197
|
+
redis.call('DEL', order_key)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
processed_levels_count = processed_levels_count + 1
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return cjson.encode({ processed_orders = processed_orders_count, processed_levels = processed_levels_count })
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
update_market_precision.lua
|
|
3
|
+
Revises all orders for a market to match new precision settings.
|
|
4
|
+
Refunds any "dust" amounts to user balances.
|
|
5
|
+
|
|
6
|
+
KEYS:
|
|
7
|
+
1. market:{symbol}
|
|
8
|
+
2. book:{symbol}:BUY
|
|
9
|
+
3. book:{symbol}:SELL
|
|
10
|
+
4. volume:{symbol}:BUY
|
|
11
|
+
5. volume:{symbol}:SELL
|
|
12
|
+
6. orders:{symbol} (for events)
|
|
13
|
+
|
|
14
|
+
ARGV:
|
|
15
|
+
1. symbol
|
|
16
|
+
2. new_price_scale
|
|
17
|
+
3. new_quantity_scale
|
|
18
|
+
4. timestamp
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
JSON object { updated_orders: N, refunds: M }
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local cjson = cjson
|
|
25
|
+
|
|
26
|
+
local market_key = KEYS[1]
|
|
27
|
+
local book_buy_key = KEYS[2]
|
|
28
|
+
local book_sell_key = KEYS[3]
|
|
29
|
+
local volume_buy_key = KEYS[4]
|
|
30
|
+
local volume_sell_key = KEYS[5]
|
|
31
|
+
local orders_key = KEYS[6]
|
|
32
|
+
|
|
33
|
+
local symbol = ARGV[1]
|
|
34
|
+
local new_price_scale = tonumber(ARGV[2])
|
|
35
|
+
local new_quantity_scale = tonumber(ARGV[3])
|
|
36
|
+
local timestamp = tonumber(ARGV[4])
|
|
37
|
+
|
|
38
|
+
if not (symbol and new_price_scale and new_quantity_scale and timestamp) then
|
|
39
|
+
return cjson.encode({ error = 'INVALID_ARGUMENTS' })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if new_price_scale <= 0 or new_quantity_scale <= 0 then
|
|
43
|
+
return cjson.encode({ error = 'INVALID_SCALE' })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
-- Helper: Convert HGETALL to map
|
|
47
|
+
local function to_map(array)
|
|
48
|
+
local map = {}
|
|
49
|
+
for i = 1, #array, 2 do
|
|
50
|
+
map[array[i]] = array[i + 1]
|
|
51
|
+
end
|
|
52
|
+
return map
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
local function as_number(value)
|
|
56
|
+
return tonumber(value) or 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Helper: Append Ledger (Copied from match_order.lua - shortened)
|
|
60
|
+
local function append_ledger(user_id, asset, bucket, delta, reason, ts)
|
|
61
|
+
if delta == 0 then return end
|
|
62
|
+
local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
|
|
63
|
+
local ledger_biz_id = 'PRECISION:' .. symbol .. ':' .. ts .. ':' .. seq
|
|
64
|
+
redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
|
|
65
|
+
'user_id', user_id,
|
|
66
|
+
'asset', asset,
|
|
67
|
+
'bucket', bucket,
|
|
68
|
+
'amount', tostring(delta),
|
|
69
|
+
'reason', reason,
|
|
70
|
+
'ts', tostring(ts),
|
|
71
|
+
'sequence', tostring(seq),
|
|
72
|
+
'biz_id', ledger_biz_id
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
-- Load Market
|
|
77
|
+
local market = to_map(redis.call('HGETALL', market_key))
|
|
78
|
+
if next(market) == nil then
|
|
79
|
+
return cjson.encode({ error = 'UNKNOWN_MARKET' })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
local base_asset = market['base_asset']
|
|
83
|
+
local quote_asset = market['quote_asset']
|
|
84
|
+
local old_price_scale = tonumber(market['price_scale'])
|
|
85
|
+
local old_quantity_scale = tonumber(market['quantity_scale'])
|
|
86
|
+
local maker_fee_bps = tonumber(market['maker_fee_bps'] or '0')
|
|
87
|
+
|
|
88
|
+
-- Load Asset Configs for Factors
|
|
89
|
+
local base_asset_map = to_map(redis.call('HGETALL', 'asset:' .. base_asset))
|
|
90
|
+
local quote_asset_map = to_map(redis.call('HGETALL', 'asset:' .. quote_asset))
|
|
91
|
+
local base_balance_scale = tonumber(base_asset_map['balance_scale'] or '0')
|
|
92
|
+
local quote_balance_scale = tonumber(quote_asset_map['balance_scale'] or '0')
|
|
93
|
+
|
|
94
|
+
-- Validate Asset Scales
|
|
95
|
+
local function compute_factor(balance_scale, market_scale)
|
|
96
|
+
if market_scale <= 0 then return 0 end
|
|
97
|
+
local div = math.floor(balance_scale / market_scale)
|
|
98
|
+
if (div * market_scale) ~= balance_scale then return 0 end
|
|
99
|
+
return div
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
local new_base_factor = compute_factor(base_balance_scale, new_quantity_scale)
|
|
103
|
+
local new_quote_factor = compute_factor(quote_balance_scale, new_price_scale)
|
|
104
|
+
|
|
105
|
+
if new_base_factor == 0 or new_quote_factor == 0 then
|
|
106
|
+
return cjson.encode({ error = 'ASSET_SCALE_MISMATCH_WITH_NEW_CONFIG' })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
-- Update Market Config
|
|
110
|
+
redis.call('HSET', market_key, 'price_scale', new_price_scale, 'quantity_scale', new_quantity_scale)
|
|
111
|
+
|
|
112
|
+
local updated_count = 0
|
|
113
|
+
local sides = { 'BUY', 'SELL' }
|
|
114
|
+
local queues_to_rename = {} -- { {old_key, new_key, is_tmp} }
|
|
115
|
+
-- Actually we use TMP keys for new queues
|
|
116
|
+
|
|
117
|
+
local function process_side(side)
|
|
118
|
+
local is_buy = (side == 'BUY')
|
|
119
|
+
local book_key = is_buy and book_buy_key or book_sell_key
|
|
120
|
+
local volume_key = is_buy and volume_buy_key or volume_sell_key
|
|
121
|
+
|
|
122
|
+
-- Read all levels
|
|
123
|
+
local levels = redis.call('ZRANGE', book_key, 0, -1, 'WITHSCORES')
|
|
124
|
+
|
|
125
|
+
-- Clear old book and volume
|
|
126
|
+
redis.call('DEL', book_key)
|
|
127
|
+
redis.call('DEL', volume_key)
|
|
128
|
+
|
|
129
|
+
-- We will rebuild book/volume in memory then write
|
|
130
|
+
local new_book = {} -- price -> score
|
|
131
|
+
local new_volume = {} -- price -> volume
|
|
132
|
+
|
|
133
|
+
for i = 1, #levels, 2 do
|
|
134
|
+
local old_price_member = levels[i]
|
|
135
|
+
local old_price = as_number(old_price_member) -- This is the price string/member
|
|
136
|
+
-- Note: Score is also available but we use member as source of truth for price value
|
|
137
|
+
|
|
138
|
+
local old_queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. old_price_member
|
|
139
|
+
|
|
140
|
+
-- Calculate New Price
|
|
141
|
+
-- Logic: new_p = floor(old_p * new_scale / old_scale)
|
|
142
|
+
local new_price = math.floor(old_price * new_price_scale / old_price_scale)
|
|
143
|
+
local new_price_member = tostring(new_price)
|
|
144
|
+
|
|
145
|
+
-- Iterate Orders
|
|
146
|
+
local orders = redis.call('LRANGE', old_queue_key, 0, -1)
|
|
147
|
+
redis.call('DEL', old_queue_key) -- Delete old queue
|
|
148
|
+
|
|
149
|
+
local new_queue_key_tmp = 'queue:' .. symbol .. ':' .. side .. ':' .. new_price_member .. ':TMP'
|
|
150
|
+
|
|
151
|
+
for _, order_id in ipairs(orders) do
|
|
152
|
+
local order_key = 'order:' .. order_id
|
|
153
|
+
local order = to_map(redis.call('HGETALL', order_key))
|
|
154
|
+
|
|
155
|
+
if next(order) then
|
|
156
|
+
updated_count = updated_count + 1
|
|
157
|
+
local old_qty = as_number(order['quantity'])
|
|
158
|
+
local old_rem = as_number(order['remaining'])
|
|
159
|
+
local old_reserved_base = as_number(order['reserved_base'])
|
|
160
|
+
local old_reserved_quote = as_number(order['reserved_quote'])
|
|
161
|
+
local user_id = order['user_id']
|
|
162
|
+
|
|
163
|
+
-- Calculate New Qty/Rem
|
|
164
|
+
local new_qty = math.floor(old_qty * new_quantity_scale / old_quantity_scale)
|
|
165
|
+
local new_rem = math.floor(old_rem * new_quantity_scale / old_quantity_scale)
|
|
166
|
+
|
|
167
|
+
-- Calculate New Reserved
|
|
168
|
+
local new_reserved_base = 0
|
|
169
|
+
local new_reserved_quote = 0
|
|
170
|
+
|
|
171
|
+
if side == 'SELL' then
|
|
172
|
+
-- SELL: Reserve Base = rem * base_factor
|
|
173
|
+
new_reserved_base = new_rem * new_base_factor
|
|
174
|
+
else
|
|
175
|
+
-- BUY: Reserve Quote = floor(price * rem / qty_scale) + fee
|
|
176
|
+
-- fee = floor(quote * bps / 10000)
|
|
177
|
+
local quote_val = math.floor((new_price * new_rem) / new_quantity_scale)
|
|
178
|
+
local fee = math.floor((quote_val * maker_fee_bps) / 10000)
|
|
179
|
+
local total_quote = quote_val + fee
|
|
180
|
+
new_reserved_quote = total_quote * new_quote_factor
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
-- Refunds
|
|
184
|
+
local refund_base = old_reserved_base - new_reserved_base
|
|
185
|
+
local refund_quote = old_reserved_quote - new_reserved_quote
|
|
186
|
+
|
|
187
|
+
if refund_base > 0 then
|
|
188
|
+
local bal_key = 'balance:' .. user_id
|
|
189
|
+
redis.call('HINCRBY', bal_key, base_asset .. ':reserved', -refund_base)
|
|
190
|
+
redis.call('HINCRBY', bal_key, base_asset .. ':available', refund_base)
|
|
191
|
+
append_ledger(user_id, base_asset, 'available', refund_base, 'DUST_REFUND', timestamp)
|
|
192
|
+
append_ledger(user_id, base_asset, 'reserved', -refund_base, 'DUST_RELEASE', timestamp)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if refund_quote > 0 then
|
|
196
|
+
local bal_key = 'balance:' .. user_id
|
|
197
|
+
redis.call('HINCRBY', bal_key, quote_asset .. ':reserved', -refund_quote)
|
|
198
|
+
redis.call('HINCRBY', bal_key, quote_asset .. ':available', refund_quote)
|
|
199
|
+
append_ledger(user_id, quote_asset, 'available', refund_quote, 'DUST_REFUND', timestamp)
|
|
200
|
+
append_ledger(user_id, quote_asset, 'reserved', -refund_quote, 'DUST_RELEASE', timestamp)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
-- Update Order
|
|
204
|
+
if new_rem > 0 then
|
|
205
|
+
redis.call('HMSET', order_key,
|
|
206
|
+
'price', new_price_member,
|
|
207
|
+
'quantity', tostring(new_qty),
|
|
208
|
+
'remaining', tostring(new_rem),
|
|
209
|
+
'reserved_base', tostring(new_reserved_base),
|
|
210
|
+
'reserved_quote', tostring(new_reserved_quote)
|
|
211
|
+
)
|
|
212
|
+
-- Push to new queue (TMP)
|
|
213
|
+
redis.call('RPUSH', new_queue_key_tmp, order_id)
|
|
214
|
+
|
|
215
|
+
-- Accumulate Volume
|
|
216
|
+
new_volume[new_price_member] = (new_volume[new_price_member] or 0) + new_rem
|
|
217
|
+
|
|
218
|
+
-- Mark this TMP key for renaming later (deduplicate)
|
|
219
|
+
queues_to_rename[new_queue_key_tmp] = 'queue:' .. symbol .. ':' .. side .. ':' .. new_price_member
|
|
220
|
+
else
|
|
221
|
+
-- Filled (Dust eliminated remaining)
|
|
222
|
+
redis.call('HMSET', order_key,
|
|
223
|
+
'price', new_price_member,
|
|
224
|
+
'quantity', tostring(new_qty),
|
|
225
|
+
'remaining', '0',
|
|
226
|
+
'reserved_base', '0',
|
|
227
|
+
'reserved_quote', '0',
|
|
228
|
+
'status', 'FILLED'
|
|
229
|
+
)
|
|
230
|
+
-- Remove from User Index
|
|
231
|
+
local user_idx = 'user_orders:' .. user_id .. ':' .. symbol
|
|
232
|
+
redis.call('ZREM', user_idx, order_id)
|
|
233
|
+
-- Tombstone
|
|
234
|
+
redis.call('SET', 'order_tombstone:' .. order_id, 'FILLED', 'EX', 86400)
|
|
235
|
+
redis.call('DEL', order_key)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
-- Write Book and Volume
|
|
242
|
+
for price_str, vol in pairs(new_volume) do
|
|
243
|
+
if vol > 0 then
|
|
244
|
+
local price_val = tonumber(price_str)
|
|
245
|
+
local score = is_buy and -price_val or price_val
|
|
246
|
+
redis.call('ZADD', book_key, score, price_str)
|
|
247
|
+
redis.call('HSET', volume_key, price_str, vol)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
process_side('BUY')
|
|
253
|
+
process_side('SELL')
|
|
254
|
+
|
|
255
|
+
-- Rename TMP queues to Real queues
|
|
256
|
+
for tmp_key, real_key in pairs(queues_to_rename) do
|
|
257
|
+
redis.call('RENAME', tmp_key, real_key)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
return cjson.encode({ updated_orders = updated_count })
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Export balances and ledger sequences from a Redis RDB file to CSV.
|
|
3
|
+
#
|
|
4
|
+
# Outputs:
|
|
5
|
+
# - balances_{SNAPSHOT_ID}_{TIMESTAMP}.csv: User balances (available, reserved).
|
|
6
|
+
# - ledger_sequences_{SNAPSHOT_ID}_{TIMESTAMP}.csv: Ledger sequence numbers.
|
|
7
|
+
#
|
|
8
|
+
# Dependencies:
|
|
9
|
+
# - rdb-cli (included in bin/)
|
|
10
|
+
# - jq (brew install jq / apt install jq)
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# ./script/export_rdb_data.sh [options] [path/to/dump.rdb]
|
|
14
|
+
#
|
|
15
|
+
# Options:
|
|
16
|
+
# -h, --host <host> Redis host (default: 127.0.0.1)
|
|
17
|
+
# -p, --port <port> Redis port (default: 6379)
|
|
18
|
+
# -a, --password <pwd> Redis password
|
|
19
|
+
# -u, --url <url> Redis URL (e.g. redis://[:password@]host:port[/db])
|
|
20
|
+
#
|
|
21
|
+
|
|
22
|
+
set -e
|
|
23
|
+
|
|
24
|
+
REDIS_HOST="127.0.0.1"
|
|
25
|
+
REDIS_PORT="6379"
|
|
26
|
+
REDIS_PASS=""
|
|
27
|
+
REDIS_URL=""
|
|
28
|
+
USE_REMOTE=false
|
|
29
|
+
POSITIONAL_ARGS=()
|
|
30
|
+
|
|
31
|
+
while [[ $# -gt 0 ]]; do
|
|
32
|
+
case $1 in
|
|
33
|
+
-h|--host)
|
|
34
|
+
REDIS_HOST="$2"
|
|
35
|
+
USE_REMOTE=true
|
|
36
|
+
shift 2
|
|
37
|
+
;;
|
|
38
|
+
-p|--port)
|
|
39
|
+
REDIS_PORT="$2"
|
|
40
|
+
USE_REMOTE=true
|
|
41
|
+
shift 2
|
|
42
|
+
;;
|
|
43
|
+
-a|--password)
|
|
44
|
+
REDIS_PASS="$2"
|
|
45
|
+
USE_REMOTE=true
|
|
46
|
+
shift 2
|
|
47
|
+
;;
|
|
48
|
+
-u|--url)
|
|
49
|
+
REDIS_URL="$2"
|
|
50
|
+
USE_REMOTE=true
|
|
51
|
+
shift 2
|
|
52
|
+
;;
|
|
53
|
+
*)
|
|
54
|
+
POSITIONAL_ARGS+=("$1")
|
|
55
|
+
shift
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
set -- "${POSITIONAL_ARGS[@]}"
|
|
61
|
+
|
|
62
|
+
if [ ${#POSITIONAL_ARGS[@]} -gt 0 ]; then
|
|
63
|
+
RDB_FILE="${POSITIONAL_ARGS[0]}"
|
|
64
|
+
if [ ! -f "$RDB_FILE" ]; then
|
|
65
|
+
echo "Error: RDB file '$RDB_FILE' not found."
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
CLEANUP_RDB=false
|
|
69
|
+
elif [ "$USE_REMOTE" = true ]; then
|
|
70
|
+
if ! command -v redis-cli &> /dev/null; then
|
|
71
|
+
echo "Error: redis-cli not found. Cannot dump from remote Redis."
|
|
72
|
+
exit 1
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# Create tmp directory if it doesn't exist
|
|
76
|
+
mkdir -p tmp
|
|
77
|
+
|
|
78
|
+
RDB_FILE="tmp/dump_$(date +%s).rdb"
|
|
79
|
+
|
|
80
|
+
if [ -n "$REDIS_URL" ]; then
|
|
81
|
+
echo "Dumping data from Redis URL to ${RDB_FILE}..."
|
|
82
|
+
|
|
83
|
+
# Check if URL contains password but empty username (e.g. redis://:password@host...)
|
|
84
|
+
# This causes redis-cli to fail with "invalid uri scheme" or similar errors depending on version
|
|
85
|
+
# If detected, inject 'default' as username
|
|
86
|
+
if [[ "$REDIS_URL" =~ redis://:[^@]+@ ]]; then
|
|
87
|
+
echo " Detected empty username with password in REDIS_URL. Injecting 'default' username..."
|
|
88
|
+
REDIS_URL="${REDIS_URL/redis:\/\/:/redis:\/\/default:}"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
CMD="redis-cli -u $REDIS_URL --rdb $RDB_FILE"
|
|
92
|
+
else
|
|
93
|
+
echo "Dumping data from ${REDIS_HOST}:${REDIS_PORT} to ${RDB_FILE}..."
|
|
94
|
+
|
|
95
|
+
CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
|
96
|
+
if [ -n "$REDIS_PASS" ]; then
|
|
97
|
+
CMD="$CMD -a $REDIS_PASS"
|
|
98
|
+
fi
|
|
99
|
+
CMD="$CMD --rdb $RDB_FILE"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
$CMD
|
|
103
|
+
|
|
104
|
+
if [ ! -f "$RDB_FILE" ]; then
|
|
105
|
+
echo "Error: Failed to dump RDB file from remote."
|
|
106
|
+
exit 1
|
|
107
|
+
fi
|
|
108
|
+
CLEANUP_RDB=true
|
|
109
|
+
else
|
|
110
|
+
# Default fallback
|
|
111
|
+
RDB_FILE="dump.rdb"
|
|
112
|
+
if [ ! -f "$RDB_FILE" ]; then
|
|
113
|
+
echo "Error: RDB file '$RDB_FILE' not found."
|
|
114
|
+
echo "Usage: $0 [options] [path/to/dump.rdb]"
|
|
115
|
+
exit 1
|
|
116
|
+
fi
|
|
117
|
+
CLEANUP_RDB=false
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Check for rdb-cli (librdb)
|
|
121
|
+
if [ -n "$RDB_CLI_CMD" ]; then
|
|
122
|
+
RDB_CMD="$RDB_CLI_CMD"
|
|
123
|
+
elif [ -f "./bin/rdb-cli" ]; then
|
|
124
|
+
RDB_CMD="./bin/rdb-cli"
|
|
125
|
+
elif command -v rdb-cli &> /dev/null; then
|
|
126
|
+
RDB_CMD="rdb-cli"
|
|
127
|
+
else
|
|
128
|
+
echo "Error: 'rdb-cli' command not found. Please ensure bin/rdb-cli exists."
|
|
129
|
+
exit 1
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
if ! command -v jq &> /dev/null; then
|
|
133
|
+
echo "Error: 'jq' command not found. Please install jq."
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
echo "Processing $RDB_FILE..."
|
|
138
|
+
|
|
139
|
+
# Create tmp directory if it doesn't exist
|
|
140
|
+
mkdir -p tmp
|
|
141
|
+
|
|
142
|
+
echo "Exporting balances to tmp/balances.csv..."
|
|
143
|
+
echo "user_id,asset,available,reserved" > tmp/balances.csv
|
|
144
|
+
|
|
145
|
+
# rdb-cli json outputs: [{"key": value, "key2": value2}] (Array of objects, usually one per DB)
|
|
146
|
+
# We filter for keys starting with "balance:" using -k
|
|
147
|
+
$RDB_CMD "$RDB_FILE" -k "balance:*" json > tmp/balances_raw.json
|
|
148
|
+
# echo "Raw JSON:"
|
|
149
|
+
# cat tmp/balances_raw.json
|
|
150
|
+
|
|
151
|
+
cat tmp/balances_raw.json | jq -r '
|
|
152
|
+
.[] | to_entries[] |
|
|
153
|
+
.key as $k |
|
|
154
|
+
.value | to_entries |
|
|
155
|
+
map(
|
|
156
|
+
(.key | split(":")) as $p |
|
|
157
|
+
{asset: $p[0], bucket: $p[1], val: .value}
|
|
158
|
+
) |
|
|
159
|
+
group_by(.asset)[] |
|
|
160
|
+
{
|
|
161
|
+
user: ($k | split(":")[1]),
|
|
162
|
+
asset: .[0].asset,
|
|
163
|
+
avail: (map(select(.bucket=="available"))[0].val // "0"),
|
|
164
|
+
reserved: (map(select(.bucket=="reserved"))[0].val // "0")
|
|
165
|
+
} |
|
|
166
|
+
"\(.user),\(.asset),\(.avail),\(.reserved)"
|
|
167
|
+
' >> tmp/balances.csv
|
|
168
|
+
|
|
169
|
+
echo " -> Wrote $(wc -l < tmp/balances.csv) lines (including header)"
|
|
170
|
+
|
|
171
|
+
echo "Exporting ledger sequences to tmp/ledger_sequences.csv..."
|
|
172
|
+
echo "user_id,asset,sequence" > tmp/ledger_sequences.csv
|
|
173
|
+
|
|
174
|
+
# Filter for keys starting with "ledger_seq:"
|
|
175
|
+
$RDB_CMD "$RDB_FILE" -k "ledger_seq:*" json > tmp/ledger_seq_raw.json
|
|
176
|
+
|
|
177
|
+
cat tmp/ledger_seq_raw.json | jq -r '
|
|
178
|
+
.[] | to_entries[] |
|
|
179
|
+
(.key | split(":")) as $p |
|
|
180
|
+
"\( $p[1] ),\( $p[2] ),\( .value )"
|
|
181
|
+
' >> tmp/ledger_sequences.csv
|
|
182
|
+
|
|
183
|
+
echo " -> Wrote $(wc -l < tmp/ledger_sequences.csv) lines (including header)"
|
|
184
|
+
|
|
185
|
+
# 3. Export Snapshot ID (Last Entry ID from account_ledger)
|
|
186
|
+
echo "Exporting snapshot ID from account_ledger..."
|
|
187
|
+
|
|
188
|
+
# rdb-cli output for stream varies based on Redis stream structure
|
|
189
|
+
# Try to get the last entry from account_ledger stream
|
|
190
|
+
# Note: rdb-cli outputs invalid JSON ("account_ledger":) for empty streams, so we need to fix that
|
|
191
|
+
$RDB_CMD "$RDB_FILE" -k "account_ledger" json | sed 's/"account_ledger":$/&null/' > tmp/stream_raw.json
|
|
192
|
+
|
|
193
|
+
# Debug: show the raw JSON structure (commented out)
|
|
194
|
+
# cat stream_raw.json
|
|
195
|
+
|
|
196
|
+
# Actual JSON structure from rdb-cli for Redis streams:
|
|
197
|
+
# [{"account_ledger": {"entries": [{"id": "...", "items": {"ts": "...", ...}}, ...]}}]
|
|
198
|
+
# Note: Some databases may have empty account_ledger, so we need to filter them out
|
|
199
|
+
SNAPSHOT_DATA=$(cat tmp/stream_raw.json | jq -r '
|
|
200
|
+
try (
|
|
201
|
+
.[] |
|
|
202
|
+
select(has("account_ledger") and .account_ledger != null and (.account_ledger.entries // null) != null) |
|
|
203
|
+
.account_ledger.entries |
|
|
204
|
+
select(length > 0) |
|
|
205
|
+
last | "\(.id) \(.items.ts // 0)"
|
|
206
|
+
) catch "0-0 0"
|
|
207
|
+
')
|
|
208
|
+
|
|
209
|
+
# Take the last non-empty line (from the database with actual data)
|
|
210
|
+
SNAPSHOT_LINE=$(echo "$SNAPSHOT_DATA" | grep -v '^0-0 0$' | tail -n 1)
|
|
211
|
+
if [ -z "$SNAPSHOT_LINE" ]; then
|
|
212
|
+
SNAPSHOT_LINE="0-0 0"
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
SNAPSHOT_ID=$(echo "$SNAPSHOT_LINE" | awk '{print $1}')
|
|
216
|
+
SNAPSHOT_TS=$(echo "$SNAPSHOT_LINE" | awk '{print $2}')
|
|
217
|
+
|
|
218
|
+
if [ -z "$SNAPSHOT_ID" ] || [ "$SNAPSHOT_ID" == "null" ]; then
|
|
219
|
+
SNAPSHOT_ID="0-0"
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
mv tmp/balances.csv tmp/balances_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv
|
|
223
|
+
mv tmp/ledger_sequences.csv tmp/ledger_sequences_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv
|
|
224
|
+
echo "Snapshot ID: $SNAPSHOT_ID, Timestamp: $SNAPSHOT_TS"
|
|
225
|
+
echo "Renamed balances.csv to tmp/balances_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv"
|
|
226
|
+
echo "Renamed ledger_sequences.csv to tmp/ledger_sequences_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv"
|
|
227
|
+
|
|
228
|
+
# Cleanup
|
|
229
|
+
if [ "$CLEANUP_RDB" = true ]; then
|
|
230
|
+
echo "Cleaning up temporary RDB file $RDB_FILE..."
|
|
231
|
+
rm "$RDB_FILE"
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
rm -f tmp/balances_raw.json tmp/ledger_seq_raw.json tmp/stream_raw.json
|