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,222 @@
1
+ -- force_cancel_order.lua
2
+ -- Performs force cancellation of an order, bypassing status checks.
3
+ -- Modes:
4
+ -- 1. DELETE_ONLY: Remove order and cleanup queues/books/indices. Do NOT modify balances.
5
+ -- 2. BEST_EFFORT_UNLOCK: Remove order, cleanup, and refund whatever reserved balance is available (up to the order's lock).
6
+ --
7
+ -- KEYS:
8
+ -- 1. order hash (order:{order_id})
9
+ -- ARGV:
10
+ -- 1. requested_order_id
11
+ -- 2. mode ("DELETE_ONLY" or "BEST_EFFORT_UNLOCK")
12
+ --
13
+ -- Returns JSON with status.
14
+
15
+ local cjson = cjson
16
+ local order_key = KEYS[1]
17
+ local requested_id = ARGV[1]
18
+ local mode = ARGV[2]
19
+
20
+ local now = redis.call('TIME')
21
+ local ts_ms = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
22
+
23
+ -- Helpers
24
+ local function to_map(array)
25
+ local map = {}
26
+ for i = 1, #array, 2 do
27
+ map[array[i]] = array[i + 1]
28
+ end
29
+ return map
30
+ end
31
+
32
+ local function as_number(value)
33
+ return tonumber(value) or 0
34
+ end
35
+
36
+ local function append_ledger(user_id, asset, bucket, delta, new_available, new_reserved, reason, ts, biz_id)
37
+ if delta == 0 then return end
38
+ local now_ts = ts
39
+ if not now_ts or now_ts <= 0 then
40
+ local now = redis.call('TIME')
41
+ now_ts = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
42
+ end
43
+ local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
44
+ local ledger_biz_id = biz_id
45
+ if not ledger_biz_id then
46
+ ledger_biz_id = 'AUTO:' .. user_id .. ':' .. asset .. ':' .. bucket .. ':' .. tostring(seq)
47
+ end
48
+ redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
49
+ 'user_id', user_id,
50
+ 'asset', asset,
51
+ 'bucket', bucket,
52
+ 'amount', tostring(delta),
53
+ 'new_available', tostring(new_available),
54
+ 'new_reserved', tostring(new_reserved),
55
+ 'reason', reason or 'FORCE_CANCEL',
56
+ 'ts', tostring(now_ts),
57
+ 'sequence', tostring(seq),
58
+ 'biz_id', ledger_biz_id
59
+ )
60
+ end
61
+
62
+ local function publish_depth_update(symbol, side, price, delta, final_volume, ts)
63
+ redis.call('XADD', 'depth:' .. symbol, 'MAXLEN', '~', 1000000, '*',
64
+ 'e', 'u',
65
+ 's', side,
66
+ 'p', tostring(price),
67
+ 'd', tostring(delta),
68
+ 'v', tostring(final_volume),
69
+ 'ts', tostring(ts)
70
+ )
71
+ end
72
+
73
+ local function refresh_ticker(symbol)
74
+ local ticker_key = 'ticker:' .. symbol
75
+ local best_bid_member = redis.call('ZRANGE', 'book:' .. symbol .. ':BUY', -1, -1)
76
+ local best_bid_price = best_bid_member[1] or '0'
77
+ local best_bid_volume = '0'
78
+ if best_bid_member[1] then
79
+ best_bid_volume = redis.call('HGET', 'volume:' .. symbol .. ':BUY', best_bid_price) or '0'
80
+ end
81
+
82
+ local best_ask_member = redis.call('ZRANGE', 'book:' .. symbol .. ':SELL', 0, 0)
83
+ local best_ask_price = best_ask_member[1] or '0'
84
+ local best_ask_volume = '0'
85
+ if best_ask_member[1] then
86
+ best_ask_volume = redis.call('HGET', 'volume:' .. symbol .. ':SELL', best_ask_price) or '0'
87
+ end
88
+
89
+ redis.call('HSET', ticker_key,
90
+ 'best_bid_price', best_bid_price,
91
+ 'best_bid_volume', tostring(best_bid_volume),
92
+ 'best_ask_price', best_ask_price,
93
+ 'best_ask_volume', tostring(best_ask_volume)
94
+ )
95
+ end
96
+
97
+ local order = to_map(redis.call('HGETALL', order_key))
98
+ if next(order) == nil then
99
+ return cjson.encode({ status = 'NOT_FOUND', order_id = requested_id })
100
+ end
101
+
102
+ local order_id = order['order_id'] or requested_id
103
+ local symbol = order['symbol']
104
+ local sequence = 0
105
+ if symbol then
106
+ local seq_key = 'seq:' .. symbol
107
+ sequence = redis.call('INCR', seq_key)
108
+ end
109
+
110
+ local side = order['side']
111
+ local price = order['price']
112
+ local remaining = as_number(order['remaining'])
113
+ local user_id = order['user_id']
114
+ local status = order['status']
115
+
116
+ -- Clean up Book / Queue / Volume
117
+ if symbol and side and price and remaining > 0 then
118
+ local price_member = tostring(price)
119
+ local queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. price_member
120
+ redis.call('LREM', queue_key, 0, order_id)
121
+
122
+ local volume_key = 'volume:' .. symbol .. ':' .. side
123
+ local remaining_volume = redis.call('HINCRBY', volume_key, price_member, -remaining)
124
+ publish_depth_update(symbol, side, price_member, -remaining, remaining_volume, ts_ms)
125
+ if remaining_volume <= 0 then
126
+ redis.call('HDEL', volume_key, price_member)
127
+ redis.call('DEL', queue_key)
128
+ local book_key = 'book:' .. symbol .. ':' .. side
129
+ redis.call('ZREM', book_key, price_member)
130
+ end
131
+
132
+ refresh_ticker(symbol)
133
+ end
134
+
135
+ -- Clean up User Index
136
+ if user_id and symbol and order_id then
137
+ local index_key = 'user_orders:' .. user_id .. ':' .. symbol
138
+ redis.call('ZREM', index_key, order_id)
139
+ end
140
+
141
+ -- Handle Balances (Only for BEST_EFFORT_UNLOCK)
142
+ if mode == 'BEST_EFFORT_UNLOCK' and remaining > 0 and user_id then
143
+ local market = to_map(redis.call('HGETALL', 'market:' .. symbol))
144
+ if next(market) then
145
+ local balance_key = 'balance:' .. user_id
146
+ local asset_to_unlock = nil
147
+ local amount_to_unlock = 0
148
+
149
+ if side == 'SELL' then
150
+ asset_to_unlock = market['base_asset']
151
+ amount_to_unlock = as_number(order['reserved_base'])
152
+ if amount_to_unlock <= 0 then amount_to_unlock = remaining end
153
+ else
154
+ asset_to_unlock = market['quote_asset']
155
+ amount_to_unlock = as_number(order['reserved_quote'])
156
+ if amount_to_unlock <= 0 then
157
+ local quantity_scale = as_number(market['quantity_scale'])
158
+ if quantity_scale <= 0 then quantity_scale = 1 end
159
+ amount_to_unlock = math.floor((as_number(price) * remaining) / quantity_scale)
160
+ end
161
+ end
162
+
163
+ if asset_to_unlock and amount_to_unlock > 0 then
164
+ local current_reserved = as_number(redis.call('HGET', balance_key, asset_to_unlock .. ':reserved'))
165
+ local final_unlock = amount_to_unlock
166
+ if current_reserved < amount_to_unlock then
167
+ final_unlock = current_reserved
168
+ end
169
+
170
+ if final_unlock > 0 then
171
+ local new_reserved = redis.call('HINCRBY', balance_key, asset_to_unlock .. ':reserved', -final_unlock)
172
+ local current_available = as_number(redis.call('HGET', balance_key, asset_to_unlock .. ':available'))
173
+ append_ledger(user_id, asset_to_unlock, 'reserved', -final_unlock, current_available, new_reserved,
174
+ 'FORCE_CANCEL_RELEASE', nil,
175
+ 'order_cancel:' .. sequence .. ':' .. order_id)
176
+
177
+ local new_available = redis.call('HINCRBY', balance_key, asset_to_unlock .. ':available', final_unlock)
178
+ append_ledger(user_id, asset_to_unlock, 'available', final_unlock, new_available, new_reserved,
179
+ 'FORCE_CANCEL_REFUND', nil,
180
+ 'order_cancel:' .. sequence .. ':' .. order_id)
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ -- Publish Event
187
+ if symbol then
188
+ -- sequence is already incremented above
189
+ local orders_key = 'orders:' .. symbol
190
+ local market_config = to_map(redis.call('HGETALL', 'market:' .. symbol))
191
+ local orders_maxlen = tonumber(market_config['orders_maxlen']) or 1000000
192
+
193
+ redis.call('XADD', orders_key, 'MAXLEN', '~', orders_maxlen, '*',
194
+ 'order_id', order_id,
195
+ 'user_id', user_id,
196
+ 'symbol', symbol,
197
+ 'side', side,
198
+ 'type', order['type'] or '',
199
+ 'status', 'CANCELLED', -- Force cancel still maps to CANCELLED state for downstream consumers? Or FORCE_CANCELLED? Let's use CANCELLED for compatibility.
200
+ 'price', tostring(price),
201
+ 'quantity', tostring(order['quantity'] or '0'),
202
+ 'remaining', '0',
203
+ 'ts', tostring(ts_ms),
204
+ 'sequence', tostring(sequence),
205
+ 'reason', 'FORCE_CANCEL'
206
+ )
207
+ end
208
+
209
+ -- Tombstone
210
+ local tombstone_ttl = 86400 -- default
211
+ if symbol then
212
+ local m = to_map(redis.call('HGETALL', 'market:' .. symbol))
213
+ if m['order_tombstone_ttl_sec'] then
214
+ tombstone_ttl = tonumber(m['order_tombstone_ttl_sec'])
215
+ end
216
+ end
217
+ redis.call('SET', 'order_tombstone:' .. order_id, 'CANCELLED', 'EX', tombstone_ttl)
218
+
219
+ -- Delete Order
220
+ redis.call('DEL', order_key)
221
+
222
+ return cjson.encode({ status = 'OK', mode = mode })