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,150 @@
1
+ -- audit_user_balance.lua
2
+ -- Calculates required reserved balances from active orders and reconciles with actual user balance.
3
+ --
4
+ -- KEYS:
5
+ -- 1. balance hash (balance:{user_id})
6
+ -- ARGV:
7
+ -- 1. user_id
8
+ -- 2. fix_mode ('1' for true, '0' for false)
9
+ -- 3. timestamp (for ledger)
10
+ -- 4..N. symbols (list of all market symbols to scan)
11
+ --
12
+ -- Returns JSON object:
13
+ -- {
14
+ -- "deficits": [ { "asset": "BTC", "amount": 100 } ],
15
+ -- "fixes": [ { "asset": "USDT", "action": "LOCK", "amount": 50 } ]
16
+ -- }
17
+
18
+ local cjson = cjson
19
+ local balance_key = KEYS[1]
20
+ local user_id = ARGV[1]
21
+ local fix_mode = (ARGV[2] == '1')
22
+ local ts = ARGV[3]
23
+ local symbols = {}
24
+ for i = 4, #ARGV do
25
+ table.insert(symbols, ARGV[i])
26
+ end
27
+
28
+ -- Helpers
29
+ local function as_number(value)
30
+ return tonumber(value) or 0
31
+ end
32
+
33
+ local function append_ledger(uid, asset, action, amount, new_available, new_reserved, reason, timestamp)
34
+ if amount == 0 then return end
35
+ local now_ts = timestamp
36
+ if not now_ts or tonumber(now_ts) <= 0 then
37
+ local now = redis.call('TIME')
38
+ now_ts = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
39
+ end
40
+ local seq = redis.call('INCR', 'ledger_seq:' .. uid .. ':' .. asset)
41
+ local ledger_biz_id = 'AUDIT:' .. uid .. ':' .. asset .. ':' .. action .. ':' .. tostring(seq)
42
+ redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
43
+ 'user_id', uid,
44
+ 'asset', asset,
45
+ 'action', action,
46
+ 'amount', tostring(amount),
47
+ 'new_available', tostring(new_available),
48
+ 'new_reserved', tostring(new_reserved),
49
+ 'reason', reason,
50
+ 'ts', tostring(now_ts),
51
+ 'sequence', tostring(seq),
52
+ 'biz_id', ledger_biz_id
53
+ )
54
+ end
55
+
56
+ -- 1. Calculate Required Reserved
57
+ local required = {} -- asset -> amount
58
+
59
+ for _, symbol in ipairs(symbols) do
60
+ local index_key = 'user_orders:' .. user_id .. ':' .. symbol
61
+ -- Use ZRANGE to get all order IDs
62
+ local order_ids = redis.call('ZRANGE', index_key, 0, -1)
63
+
64
+ if #order_ids > 0 then
65
+ -- Optimize: We need base/quote asset for this symbol.
66
+ -- We can fetch it once per symbol.
67
+ local market_key = 'market:' .. symbol
68
+ local m = redis.call('HMGET', market_key, 'base_asset', 'quote_asset')
69
+ local base_asset = m[1]
70
+ local quote_asset = m[2]
71
+
72
+ if base_asset and quote_asset then
73
+ for _, oid in ipairs(order_ids) do
74
+ local order_key = 'order:' .. oid
75
+ local reserved = redis.call('HMGET', order_key, 'reserved_base', 'reserved_quote')
76
+ local r_base = as_number(reserved[1])
77
+ local r_quote = as_number(reserved[2])
78
+
79
+ if r_base > 0 then
80
+ required[base_asset] = (required[base_asset] or 0) + r_base
81
+ end
82
+ if r_quote > 0 then
83
+ required[quote_asset] = (required[quote_asset] or 0) + r_quote
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ -- 2. Compare with Actual Balance & Fix
91
+ local deficits = {}
92
+ local fixes = {}
93
+
94
+ -- We iterate over all assets that have requirements OR exist in balance?
95
+ -- Ideally we should also check if we have reserved balance for assets with NO orders.
96
+ -- But iterating all fields in balance hash is expensive if it's huge?
97
+ -- Usually user balance hash has limited fields. Let's iterate all reserved fields in balance too.
98
+
99
+ local all_assets = {}
100
+ for a, _ in pairs(required) do all_assets[a] = true end
101
+
102
+ -- Also scan balance hash for any 'reserved' fields
103
+ local balance_fields = redis.call('HGETALL', balance_key)
104
+ for i = 1, #balance_fields, 2 do
105
+ local field = balance_fields[i]
106
+ if string.find(field, ':reserved$') then
107
+ local asset = string.sub(field, 1, -10) -- remove ':reserved'
108
+ all_assets[asset] = true
109
+ end
110
+ end
111
+
112
+ for asset, _ in pairs(all_assets) do
113
+ local req_amount = required[asset] or 0
114
+ local actual_reserved = as_number(redis.call('HGET', balance_key, asset .. ':reserved'))
115
+ local diff = req_amount - actual_reserved
116
+
117
+ if diff > 0 then
118
+ -- Deficit: Need to lock more
119
+ local actual_available = as_number(redis.call('HGET', balance_key, asset .. ':available'))
120
+ local to_lock = diff
121
+ if actual_available < to_lock then
122
+ to_lock = actual_available
123
+ end
124
+
125
+ if fix_mode and to_lock > 0 then
126
+ local new_available = redis.call('HINCRBY', balance_key, asset .. ':available', -to_lock)
127
+ local new_reserved = redis.call('HINCRBY', balance_key, asset .. ':reserved', to_lock)
128
+ append_ledger(user_id, asset, 'LOCK', to_lock, new_available, new_reserved, 'AUDIT_FIX_LOCK', ts)
129
+
130
+ table.insert(fixes, { asset = asset, action = 'LOCK', amount = to_lock })
131
+ end
132
+
133
+ local remaining_deficit = diff - to_lock
134
+ if remaining_deficit > 0 then
135
+ table.insert(deficits, { asset = asset, amount = remaining_deficit })
136
+ end
137
+ elseif diff < 0 then
138
+ -- Surplus: Locked too much. Release to available.
139
+ local to_release = -diff
140
+ if fix_mode then
141
+ local new_reserved = redis.call('HINCRBY', balance_key, asset .. ':reserved', -to_release)
142
+ local new_available = redis.call('HINCRBY', balance_key, asset .. ':available', to_release)
143
+ append_ledger(user_id, asset, 'UNLOCK', to_release, new_available, new_reserved, 'AUDIT_FIX_RELEASE', ts)
144
+
145
+ table.insert(fixes, { asset = asset, action = 'RELEASE', amount = to_release })
146
+ end
147
+ end
148
+ end
149
+
150
+ return cjson.encode({ deficits = deficits, fixes = fixes })
@@ -0,0 +1,148 @@
1
+ -- balance_op.lua
2
+ -- Atomically adjusts user balance based on specific actions and records ledger entries.
3
+ --
4
+ -- KEYS:
5
+ -- 1. balance hash (balance:{user_id})
6
+ -- ARGV:
7
+ -- 1. user_id
8
+ -- 2. asset
9
+ -- 3. amount (positive integer/string)
10
+ -- 4. action (LOCK, UNLOCK, ADD, SUB, SUB_LOCKED, ADD_LOCKED)
11
+ -- 5. reason
12
+ -- 6. timestamp (optional)
13
+ -- 7. biz_id (optional, for idempotency)
14
+ --
15
+ -- Returns JSON with new balances or error.
16
+
17
+ local cjson = cjson
18
+
19
+ if redis.call('GET', 'system:maintenance_mode') == '1' then
20
+ return cjson.encode({ error = 'SYSTEM_MAINTENANCE' })
21
+ end
22
+
23
+ local function as_number(value)
24
+ return tonumber(value) or 0
25
+ end
26
+
27
+ local balance_key = KEYS[1]
28
+ local user_id = ARGV[1]
29
+ local asset = ARGV[2]
30
+ local amount = as_number(ARGV[3])
31
+ local action = ARGV[4]
32
+ local reason = ARGV[5] or 'MANUAL'
33
+ local ts_arg = ARGV[6]
34
+ local biz_id = ARGV[7]
35
+
36
+ if not (balance_key and user_id and asset and action) then
37
+ return cjson.encode({ error = 'INVALID_ARGUMENTS' })
38
+ end
39
+
40
+ if amount <= 0 then
41
+ return cjson.encode({ error = 'INVALID_AMOUNT' })
42
+ end
43
+
44
+ -- Idempotency check
45
+ local resp_key = nil
46
+ if biz_id and biz_id ~= '' then
47
+ resp_key = 'op_biz:' .. user_id .. ':' .. asset .. ':' .. action .. ':' .. biz_id
48
+ local cached = redis.call('GET', resp_key)
49
+ if cached then
50
+ return cached
51
+ end
52
+ end
53
+
54
+ local avail_field = asset .. ':available'
55
+ local reserved_field = asset .. ':reserved'
56
+
57
+ local avail_bal = as_number(redis.call('HGET', balance_key, avail_field))
58
+ local reserved_bal = as_number(redis.call('HGET', balance_key, reserved_field))
59
+
60
+ local new_avail = avail_bal
61
+ local new_reserved = reserved_bal
62
+
63
+ if action == 'LOCK' then
64
+ if avail_bal < amount then
65
+ return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = asset, bucket = 'available' })
66
+ end
67
+ new_avail = avail_bal - amount
68
+ new_reserved = reserved_bal + amount
69
+ redis.call('HINCRBY', balance_key, avail_field, -amount)
70
+ redis.call('HINCRBY', balance_key, reserved_field, amount)
71
+ elseif action == 'UNLOCK' then
72
+ if reserved_bal < amount then
73
+ return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = asset, bucket = 'reserved' })
74
+ end
75
+ new_reserved = reserved_bal - amount
76
+ new_avail = avail_bal + amount
77
+ redis.call('HINCRBY', balance_key, reserved_field, -amount)
78
+ redis.call('HINCRBY', balance_key, avail_field, amount)
79
+ elseif action == 'ADD' then
80
+ new_avail = avail_bal + amount
81
+ redis.call('HINCRBY', balance_key, avail_field, amount)
82
+ elseif action == 'SUB' then
83
+ if avail_bal < amount then
84
+ return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = asset, bucket = 'available' })
85
+ end
86
+ new_avail = avail_bal - amount
87
+ redis.call('HINCRBY', balance_key, avail_field, -amount)
88
+ elseif action == 'SUB_LOCKED' then
89
+ if reserved_bal < amount then
90
+ return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = asset, bucket = 'reserved' })
91
+ end
92
+ new_reserved = reserved_bal - amount
93
+ redis.call('HINCRBY', balance_key, reserved_field, -amount)
94
+ elseif action == 'ADD_LOCKED' then
95
+ new_reserved = reserved_bal + amount
96
+ redis.call('HINCRBY', balance_key, reserved_field, amount)
97
+ else
98
+ return cjson.encode({ error = 'INVALID_ACTION' })
99
+ end
100
+
101
+ -- Ledger Recording
102
+ local ts
103
+ if ts_arg and ts_arg ~= '' then
104
+ ts = tonumber(ts_arg) or 0
105
+ else
106
+ local now = redis.call('TIME')
107
+ ts = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
108
+ end
109
+
110
+ local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
111
+ local ledger_biz_id = biz_id
112
+ if not ledger_biz_id or ledger_biz_id == '' then
113
+ ledger_biz_id = 'AUTO:' .. user_id .. ':' .. asset .. ':' .. action .. ':' .. tostring(seq)
114
+ end
115
+
116
+ redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 10000000, '*',
117
+ 'user_id', user_id,
118
+ 'asset', asset,
119
+ 'action', action,
120
+ 'amount', tostring(amount),
121
+ 'new_available', tostring(new_avail),
122
+ 'new_reserved', tostring(new_reserved),
123
+ 'reason', reason,
124
+ 'ts', tostring(ts),
125
+ 'sequence', tostring(seq),
126
+ 'biz_id', ledger_biz_id
127
+ )
128
+
129
+ local result = {
130
+ ok = true,
131
+ user_id = user_id,
132
+ asset = asset,
133
+ action = action,
134
+ amount = amount,
135
+ available = new_avail,
136
+ reserved = new_reserved,
137
+ ts = ts,
138
+ sequence = seq,
139
+ biz_id = ledger_biz_id
140
+ }
141
+
142
+ local result_json = cjson.encode(result)
143
+
144
+ if resp_key then
145
+ redis.call('SET', resp_key, result_json, 'EX', 86400)
146
+ end
147
+
148
+ return result_json
@@ -0,0 +1,277 @@
1
+ -- cancel_order.lua performs atomic cancellation of a resting limit order.
2
+ --
3
+ -- KEYS:
4
+ -- 1. order hash (order:{order_id})
5
+ -- 2. market sequence counter (seq:{symbol})
6
+ -- ARGV:
7
+ -- 1. requested order_id (for error responses)
8
+ --
9
+ -- Returns JSON with `status`, `order_id`, and `symbol` when available.
10
+
11
+ -- Redis Lua environment provides global `cjson`; no `require` is allowed.
12
+ local cjson = cjson
13
+
14
+ -- Check for system maintenance mode
15
+ if redis.call('GET', 'system:maintenance_mode') == '1' then
16
+ return cjson.encode({ error = 'SYSTEM_MAINTENANCE' })
17
+ end
18
+
19
+ -- order_key is KEYS[1]
20
+ local order_key = KEYS[1]
21
+ local seq_key = KEYS[2]
22
+ local requested_id = ARGV[1]
23
+
24
+ -- To check market maintenance, we need the symbol.
25
+ -- Since we don't pass market key directly, we must look it up from the order.
26
+ -- This works fine on single-instance Redis.
27
+
28
+ local symbol = redis.call('HGET', order_key, 'symbol')
29
+ if symbol then
30
+ if redis.call('HGET', 'market:' .. symbol, 'status') == 'MAINTENANCE' then
31
+ return cjson.encode({ error = 'MARKET_MAINTENANCE' })
32
+ end
33
+ end
34
+
35
+ local function to_map(array)
36
+ local map = {}
37
+ for i = 1, #array, 2 do
38
+ map[array[i]] = array[i + 1]
39
+ end
40
+ return map
41
+ end
42
+
43
+ local function as_number(value)
44
+ if not value then
45
+ return 0
46
+ end
47
+ local num = tonumber(value)
48
+ if not num then
49
+ return 0
50
+ end
51
+ return num
52
+ end
53
+
54
+ -- Append an account ledger entry for a balance change.
55
+ -- Uses per-user-asset sequence `ledger_seq:{user_id}:{asset}` and global stream `account_ledger`.
56
+ local function append_ledger(user_id, asset, action, amount, new_available, new_reserved, reason, ts, biz_id)
57
+ if amount == 0 then return end
58
+ local now_ts = ts
59
+ if not now_ts or now_ts <= 0 then
60
+ local now = redis.call('TIME')
61
+ now_ts = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
62
+ end
63
+ local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
64
+ local ledger_biz_id = biz_id
65
+ if not ledger_biz_id then
66
+ ledger_biz_id = 'AUTO:' .. user_id .. ':' .. asset .. ':' .. action .. ':' .. tostring(seq)
67
+ end
68
+ redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
69
+ 'user_id', user_id,
70
+ 'asset', asset,
71
+ 'action', action,
72
+ 'amount', tostring(amount),
73
+ 'new_available', tostring(new_available),
74
+ 'new_reserved', tostring(new_reserved),
75
+ 'reason', reason or 'CANCEL',
76
+ 'ts', tostring(now_ts),
77
+ 'sequence', tostring(seq),
78
+ 'biz_id', ledger_biz_id
79
+ )
80
+ end
81
+
82
+ local function op_unlock(user_id, asset, amount, reason, ts, biz_id)
83
+ if amount <= 0 then return end
84
+ local new_reserved = redis.call('HINCRBY', 'balance:' .. user_id, asset .. ':reserved', -amount)
85
+ local new_available = redis.call('HINCRBY', 'balance:' .. user_id, asset .. ':available', amount)
86
+ append_ledger(user_id, asset, 'UNLOCK', amount, new_available, new_reserved, reason, ts, biz_id)
87
+ end
88
+
89
+ local function publish_depth_update(symbol, side, price, delta, final_volume, ts)
90
+ redis.call('XADD', 'depth:' .. symbol, 'MAXLEN', '~', 1000000, '*',
91
+ 'e', 'u',
92
+ 's', side,
93
+ 'p', tostring(price),
94
+ 'd', tostring(delta),
95
+ 'v', tostring(final_volume),
96
+ 'ts', tostring(ts)
97
+ )
98
+ end
99
+
100
+ local function refresh_ticker(symbol)
101
+ local ticker_key = 'ticker:' .. symbol
102
+ local best_bid_member = redis.call('ZRANGE', 'book:' .. symbol .. ':BUY', -1, -1)
103
+ local best_bid_price = best_bid_member[1] or '0'
104
+ local best_bid_volume = '0'
105
+ if best_bid_member[1] then
106
+ best_bid_volume = redis.call('HGET', 'volume:' .. symbol .. ':BUY', best_bid_price) or '0'
107
+ end
108
+
109
+ local best_ask_member = redis.call('ZRANGE', 'book:' .. symbol .. ':SELL', 0, 0)
110
+ local best_ask_price = best_ask_member[1] or '0'
111
+ local best_ask_volume = '0'
112
+ if best_ask_member[1] then
113
+ best_ask_volume = redis.call('HGET', 'volume:' .. symbol .. ':SELL', best_ask_price) or '0'
114
+ end
115
+
116
+ redis.call('HSET', ticker_key,
117
+ 'best_bid_price', best_bid_price,
118
+ 'best_bid_volume', tostring(best_bid_volume),
119
+ 'best_ask_price', best_ask_price,
120
+ 'best_ask_volume', tostring(best_ask_volume)
121
+ )
122
+ end
123
+
124
+ local order = to_map(redis.call('HGETALL', order_key))
125
+ if next(order) == nil then
126
+ return cjson.encode({ status = 'NOT_FOUND', order_id = requested_id })
127
+ end
128
+
129
+ local order_id = order['order_id'] or requested_id
130
+ local symbol = order['symbol']
131
+ local side = order['side']
132
+ local price = order['price']
133
+ local remaining = as_number(order['remaining'])
134
+ local status = order['status'] or 'UNKNOWN'
135
+ local user_id = order['user_id']
136
+
137
+ local now = redis.call('TIME')
138
+ local ts_ms = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
139
+ local sequence = redis.call('INCR', seq_key)
140
+
141
+ if remaining <= 0 then
142
+ return cjson.encode({ status = status, order_id = order_id, symbol = symbol or '' })
143
+ end
144
+
145
+ if not (symbol and side and price and user_id) then
146
+ return cjson.encode({ status = 'INVALID_ORDER', order_id = order_id })
147
+ end
148
+
149
+ local market = to_map(redis.call('HGETALL', 'market:' .. symbol))
150
+ if next(market) == nil then
151
+ return cjson.encode({ status = 'UNKNOWN_MARKET', order_id = order_id })
152
+ end
153
+
154
+ local tombstone_ttl_sec = tonumber(market['order_tombstone_ttl_sec']) or 86400
155
+ local function mark_tombstone(oid, status)
156
+ if not oid or oid == '' then return end
157
+ redis.call('SET', 'order_tombstone:' .. oid, status or 'DONE', 'EX', tombstone_ttl_sec)
158
+ end
159
+
160
+ local base_asset = market['base_asset']
161
+ local quote_asset = market['quote_asset']
162
+ local quantity_scale = as_number(market['quantity_scale'])
163
+ if quantity_scale <= 0 then
164
+ quantity_scale = 1
165
+ end
166
+
167
+ local balance_key = 'balance:' .. user_id
168
+
169
+ -- Check if order was never executed (no trades)
170
+ local quantity = as_number(order['quantity'])
171
+ local trade_count = as_number(order['trade_count'] or '0')
172
+ local is_untouched = (trade_count == 0 and remaining == quantity)
173
+
174
+ -- Build biz_id with untouched flag if applicable
175
+ local function build_biz_id(base_biz_id)
176
+ if is_untouched then
177
+ return base_biz_id .. ':UNT'
178
+ else
179
+ return base_biz_id
180
+ end
181
+ end
182
+
183
+ if side == 'SELL' then
184
+ local reserved_base = as_number(order['reserved_base'])
185
+ local release_amount = reserved_base
186
+ if release_amount <= 0 then
187
+ release_amount = remaining
188
+ end
189
+ op_unlock(user_id, base_asset, release_amount, 'CANCEL_REFUND', nil, build_biz_id('order_cancel:' .. sequence .. ':' .. order_id))
190
+
191
+ -- If we released the full reserved amount for the order, we should zero it out in the hash too,
192
+ -- although the order is being deleted shortly. But critically, if this was a partial cancel
193
+ -- (not supported here, but good practice), we'd update.
194
+ -- For full cancel, the order hash is deleted anyway.
195
+ else
196
+ local reserved_quote = as_number(order['reserved_quote'])
197
+ local release_amount = reserved_quote
198
+ if release_amount <= 0 then
199
+ -- Fallback for legacy orders without reserved_quote field
200
+ release_amount = math.floor((as_number(price) * remaining) / quantity_scale)
201
+
202
+ -- Add fee reserve fallback logic matching match_order.lua?
203
+ -- match_order.lua: reserve_total = reserve_quote + reserve_fee
204
+ -- If we don't have the exact reserved amount stored, we might under-release if fees were reserved!
205
+ -- However, match_order.lua NOW stores `reserved_quote` correctly.
206
+ -- So this fallback is only for very old data.
207
+
208
+ -- But wait, if we have stored reserved_quote, we trust it.
209
+ end
210
+ if release_amount > 0 then
211
+ op_unlock(user_id, quote_asset, release_amount, 'CANCEL_REFUND', ts_ms,
212
+ build_biz_id('order_cancel:' .. sequence .. ':' .. order_id))
213
+ end
214
+ end
215
+
216
+ local price_member = tostring(price)
217
+ local queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. price_member
218
+ redis.call('LREM', queue_key, 0, order_id)
219
+
220
+ local volume_key = 'volume:' .. symbol .. ':' .. side
221
+ local remaining_volume = redis.call('HINCRBY', volume_key, price_member, -remaining)
222
+ publish_depth_update(symbol, side, price_member, -remaining, remaining_volume, ts_ms)
223
+
224
+ if remaining_volume <= 0 then
225
+ redis.call('HDEL', volume_key, price_member)
226
+ redis.call('DEL', queue_key)
227
+ local book_key = 'book:' .. symbol .. ':' .. side
228
+ redis.call('ZREM', book_key, price_member)
229
+ end
230
+
231
+ redis.call('HMSET', order_key,
232
+ 'status', 'CANCELLED',
233
+ 'remaining', '0'
234
+ )
235
+
236
+ -- Remove from user's active orders index
237
+ if user_id and symbol and order_id then
238
+ local index_key = 'user_orders:' .. user_id .. ':' .. symbol
239
+ redis.call('ZREM', index_key, order_id)
240
+ end
241
+
242
+ -- Publish order cancellation event to orders stream and delete final order hash
243
+ do
244
+ local now = redis.call('TIME')
245
+ local ts_ms = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
246
+ local orders_key = 'orders:' .. symbol
247
+ local order_type = order['type'] or ''
248
+ local quantity = order['quantity'] or '0'
249
+
250
+ local orders_maxlen = tonumber(market['orders_maxlen']) or 1000000
251
+ redis.call('XADD', orders_key, 'MAXLEN', '~', orders_maxlen, '*',
252
+ 'order_id', order_id,
253
+ 'user_id', user_id,
254
+ 'symbol', symbol,
255
+ 'side', side,
256
+ 'type', order_type,
257
+ 'status', 'CANCELLED',
258
+ 'price', tostring(price),
259
+ 'quantity', tostring(quantity),
260
+ 'remaining', '0',
261
+ 'ts', tostring(ts_ms),
262
+ 'sequence', tostring(sequence)
263
+ )
264
+
265
+ -- Delete terminal order hash to keep storage lean
266
+ mark_tombstone(order_id, 'CANCELLED')
267
+ redis.call('DEL', order_key)
268
+ end
269
+
270
+ refresh_ticker(symbol)
271
+
272
+ return cjson.encode({
273
+ status = 'CANCELLED',
274
+ order_id = order_id,
275
+ symbol = symbol,
276
+ remaining_quantity = 0
277
+ })
@@ -0,0 +1,88 @@
1
+ --[[
2
+ finalize_precision_update.lua
3
+ Switches the market to the new precision configuration and enables the new order books.
4
+
5
+ KEYS:
6
+ 1. market:{symbol}
7
+ 2. book:{symbol}:BUY
8
+ 3. book:{symbol}:SELL
9
+ 4. volume:{symbol}:BUY
10
+ 5. volume:{symbol}:SELL
11
+ 6. book:{symbol}:BUY:TMP
12
+ 7. book:{symbol}:SELL:TMP
13
+ 8. volume:{symbol}:BUY:TMP
14
+ 9. volume:{symbol}:SELL:TMP
15
+
16
+ ARGV:
17
+ 1. symbol
18
+ 2. new_price_scale
19
+ 3. new_quantity_scale
20
+
21
+ Returns:
22
+ JSON object { status: 'OK' }
23
+ ]]
24
+
25
+ local cjson = cjson
26
+
27
+ local market_key = KEYS[1]
28
+ local book_buy_key = KEYS[2]
29
+ local book_sell_key = KEYS[3]
30
+ local volume_buy_key = KEYS[4]
31
+ local volume_sell_key = KEYS[5]
32
+ local book_buy_tmp = KEYS[6]
33
+ local book_sell_tmp = KEYS[7]
34
+ local volume_buy_tmp = KEYS[8]
35
+ local volume_sell_tmp = KEYS[9]
36
+
37
+ local symbol = ARGV[1]
38
+ local new_price_scale = ARGV[2]
39
+ local new_quantity_scale = ARGV[3]
40
+
41
+ -- Update Market Config
42
+ redis.call('HSET', market_key, 'price_scale', new_price_scale, 'quantity_scale', new_quantity_scale)
43
+
44
+ -- Replace Books
45
+ redis.call('DEL', book_buy_key)
46
+ if redis.call('EXISTS', book_buy_tmp) == 1 then
47
+ redis.call('RENAME', book_buy_tmp, book_buy_key)
48
+ end
49
+
50
+ redis.call('DEL', book_sell_key)
51
+ if redis.call('EXISTS', book_sell_tmp) == 1 then
52
+ redis.call('RENAME', book_sell_tmp, book_sell_key)
53
+ end
54
+
55
+ -- Replace Volumes
56
+ redis.call('DEL', volume_buy_key)
57
+ if redis.call('EXISTS', volume_buy_tmp) == 1 then
58
+ redis.call('RENAME', volume_buy_tmp, volume_buy_key)
59
+ end
60
+
61
+ redis.call('DEL', volume_sell_key)
62
+ if redis.call('EXISTS', volume_sell_tmp) == 1 then
63
+ redis.call('RENAME', volume_sell_tmp, volume_sell_key)
64
+ end
65
+
66
+ -- Rename all TMP queues to real queues
67
+ -- We need to scan for them because we don't know exactly which prices exist.
68
+ -- But SCAN in Lua is discouraged for large keyspaces.
69
+ -- However, we can infer the keys from the NEW books we just put in place.
70
+ -- Iterate over the new books to find active prices and rename their queues.
71
+
72
+ local function rename_queues(book_key, side)
73
+ local prices = redis.call('ZRANGE', book_key, 0, -1)
74
+ for _, price_member in ipairs(prices) do
75
+ local tmp_queue = 'queue:' .. symbol .. ':' .. side .. ':' .. price_member .. ':TMP'
76
+ local real_queue = 'queue:' .. symbol .. ':' .. side .. ':' .. price_member
77
+
78
+ redis.call('DEL', real_queue) -- Should be empty as we deleted old ones in batch, but safety first
79
+ if redis.call('EXISTS', tmp_queue) == 1 then
80
+ redis.call('RENAME', tmp_queue, real_queue)
81
+ end
82
+ end
83
+ end
84
+
85
+ rename_queues(book_buy_key, 'BUY')
86
+ rename_queues(book_sell_key, 'SELL')
87
+
88
+ return cjson.encode({ status = 'OK' })