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,1026 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
match_order.lua implements the exchange's atomic limit/market order matching routine.
|
|
3
|
+
|
|
4
|
+
Expected KEYS (1-9):
|
|
5
|
+
1. market metadata hash (market:{symbol})
|
|
6
|
+
2. sequence counter (seq:{symbol})
|
|
7
|
+
3. buy book sorted set (book:{symbol}:BUY)
|
|
8
|
+
4. sell book sorted set (book:{symbol}:SELL)
|
|
9
|
+
5. buy side volume hash (volume:{symbol}:BUY)
|
|
10
|
+
6. sell side volume hash (volume:{symbol}:SELL)
|
|
11
|
+
7. taker balance hash (balance:{user_id})
|
|
12
|
+
8. trade stream (trades:{symbol})
|
|
13
|
+
9. order event stream (orders:{symbol})
|
|
14
|
+
|
|
15
|
+
Expected ARGV (1-9):
|
|
16
|
+
1. symbol
|
|
17
|
+
2. side (BUY|SELL)
|
|
18
|
+
3. order type (LIMIT|MARKET)
|
|
19
|
+
4. order id
|
|
20
|
+
5. user id
|
|
21
|
+
6. price limit (scaled integer, 0 when unrestricted)
|
|
22
|
+
7. quantity (scaled integer)
|
|
23
|
+
8. timestamp in milliseconds
|
|
24
|
+
9. user taker fee in basis points (optional, nil uses market default)
|
|
25
|
+
|
|
26
|
+
Returns a JSON blob describing execution outcome or an error code.
|
|
27
|
+
The ticker hash (`ticker:{symbol}`) is also refreshed with last trade and top-of-book data.
|
|
28
|
+
]]
|
|
29
|
+
|
|
30
|
+
-- Redis Lua environment provides global `cjson`; no `require` is allowed.
|
|
31
|
+
local cjson = cjson
|
|
32
|
+
|
|
33
|
+
-- Check for system maintenance mode
|
|
34
|
+
if redis.call('GET', 'system:maintenance_mode') == '1' then
|
|
35
|
+
return cjson.encode({ error = 'SYSTEM_MAINTENANCE' })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- Check for market maintenance mode (via status field)
|
|
39
|
+
local market_status = redis.call('HGET', KEYS[1], 'status')
|
|
40
|
+
if market_status == 'MAINTENANCE' then
|
|
41
|
+
return cjson.encode({ error = 'MARKET_MAINTENANCE' })
|
|
42
|
+
end
|
|
43
|
+
if market_status ~= 'OPEN' then
|
|
44
|
+
return cjson.encode({ error = 'MARKET_HALTED' })
|
|
45
|
+
end
|
|
46
|
+
local function to_map(array)
|
|
47
|
+
local map = {}
|
|
48
|
+
for i = 1, #array, 2 do
|
|
49
|
+
map[array[i]] = array[i + 1]
|
|
50
|
+
end
|
|
51
|
+
return map
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
-- Coerce Redis string replies into numbers with 0 fallback.
|
|
55
|
+
local function as_number(value)
|
|
56
|
+
if not value then
|
|
57
|
+
return 0
|
|
58
|
+
end
|
|
59
|
+
return tonumber(value) or 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Format number as integer string to avoid scientific notation (e.g. "1e+15")
|
|
63
|
+
-- which causes "ERR value is not an integer" in HINCRBY.
|
|
64
|
+
-- Also handles -0 case which Redis rejects.
|
|
65
|
+
local function format_int(n)
|
|
66
|
+
-- Handle -0 case: string.format("%.0f", -0) returns "-0" which Redis rejects
|
|
67
|
+
if n == 0 then
|
|
68
|
+
return "0"
|
|
69
|
+
end
|
|
70
|
+
return string.format("%.0f", n)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
-- Perform HINCRBY only when the delta is non-zero to avoid redundant writes.
|
|
74
|
+
local function hincrby(key, field, delta)
|
|
75
|
+
if delta ~= 0 then
|
|
76
|
+
return redis.call('HINCRBY', key, field, format_int(delta))
|
|
77
|
+
end
|
|
78
|
+
return tonumber(redis.call('HGET', key, field)) or 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
-- Append an account ledger entry for a balance change.
|
|
82
|
+
-- Uses per-user-asset sequence `ledger_seq:{user_id}:{asset}` and global stream `account_ledger`.
|
|
83
|
+
local function append_ledger(user_id, asset, action, amount, new_available, new_reserved, reason, ts, biz_id)
|
|
84
|
+
if amount == 0 then return end
|
|
85
|
+
local now_ts = ts
|
|
86
|
+
if not now_ts or now_ts <= 0 then
|
|
87
|
+
local now = redis.call('TIME')
|
|
88
|
+
now_ts = (tonumber(now[1]) or 0) * 1000 + math.floor((tonumber(now[2]) or 0) / 1000)
|
|
89
|
+
end
|
|
90
|
+
local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
|
|
91
|
+
local ledger_biz_id = biz_id
|
|
92
|
+
if not ledger_biz_id then
|
|
93
|
+
ledger_biz_id = 'AUTO:' .. user_id .. ':' .. asset .. ':' .. action .. ':' .. tostring(seq)
|
|
94
|
+
end
|
|
95
|
+
redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 10000000, '*',
|
|
96
|
+
'user_id', user_id,
|
|
97
|
+
'asset', asset,
|
|
98
|
+
'action', action,
|
|
99
|
+
'amount', format_int(amount),
|
|
100
|
+
'new_available', format_int(new_available),
|
|
101
|
+
'new_reserved', format_int(new_reserved),
|
|
102
|
+
'reason', reason or 'MATCH',
|
|
103
|
+
'ts', tostring(now_ts),
|
|
104
|
+
'sequence', tostring(seq),
|
|
105
|
+
'biz_id', ledger_biz_id
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
-- Get user-specific fee rates for a given user
|
|
110
|
+
local function get_user_fees(user_id)
|
|
111
|
+
local fees = to_map(redis.call('HGETALL', 'user_fees:' .. user_id))
|
|
112
|
+
if next(fees) == nil then
|
|
113
|
+
return nil, nil
|
|
114
|
+
end
|
|
115
|
+
local m_bps = tonumber(fees['maker_fee_bps'])
|
|
116
|
+
local t_bps = tonumber(fees['taker_fee_bps'])
|
|
117
|
+
return m_bps, t_bps
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
-- Balance Primitives
|
|
121
|
+
local function op_lock(user_id, asset, amount, reason, ts, biz_id)
|
|
122
|
+
if amount <= 0 then return end
|
|
123
|
+
local new_avail = hincrby('balance:' .. user_id, asset .. ':available', -amount)
|
|
124
|
+
local new_res = hincrby('balance:' .. user_id, asset .. ':reserved', amount)
|
|
125
|
+
append_ledger(user_id, asset, 'LOCK', amount, new_avail, new_res, reason, ts, biz_id)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
local function op_unlock(user_id, asset, amount, reason, ts, biz_id)
|
|
129
|
+
if amount <= 0 then return end
|
|
130
|
+
local new_res = hincrby('balance:' .. user_id, asset .. ':reserved', -amount)
|
|
131
|
+
local new_avail = hincrby('balance:' .. user_id, asset .. ':available', amount)
|
|
132
|
+
append_ledger(user_id, asset, 'UNLOCK', amount, new_avail, new_res, reason, ts, biz_id)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
local function op_add(user_id, asset, amount, reason, ts, biz_id)
|
|
136
|
+
if amount <= 0 then return end
|
|
137
|
+
local new_avail = hincrby('balance:' .. user_id, asset .. ':available', amount)
|
|
138
|
+
local new_res = tonumber(redis.call('HGET', 'balance:' .. user_id, asset .. ':reserved')) or 0
|
|
139
|
+
append_ledger(user_id, asset, 'ADD', amount, new_avail, new_res, reason, ts, biz_id)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local function op_sub(user_id, asset, amount, reason, ts, biz_id)
|
|
143
|
+
if amount <= 0 then return end
|
|
144
|
+
local new_avail = hincrby('balance:' .. user_id, asset .. ':available', -amount)
|
|
145
|
+
local new_res = tonumber(redis.call('HGET', 'balance:' .. user_id, asset .. ':reserved')) or 0
|
|
146
|
+
append_ledger(user_id, asset, 'SUB', amount, new_avail, new_res, reason, ts, biz_id)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
local function op_sub_locked(user_id, asset, amount, reason, ts, biz_id)
|
|
150
|
+
if amount <= 0 then return end
|
|
151
|
+
local new_res = hincrby('balance:' .. user_id, asset .. ':reserved', -amount)
|
|
152
|
+
local new_avail = tonumber(redis.call('HGET', 'balance:' .. user_id, asset .. ':available')) or 0
|
|
153
|
+
append_ledger(user_id, asset, 'SUB_LOCKED', amount, new_avail, new_res, reason, ts, biz_id)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
local function publish_depth_update(symbol, side, price, delta, final_volume, ts, seq)
|
|
157
|
+
redis.call('XADD', 'depth:' .. symbol, 'MAXLEN', '~', 100000, '*',
|
|
158
|
+
'e', 'u',
|
|
159
|
+
's', side,
|
|
160
|
+
'p', format_int(price),
|
|
161
|
+
'd', format_int(delta),
|
|
162
|
+
'v', format_int(final_volume),
|
|
163
|
+
'ts', tostring(ts),
|
|
164
|
+
'seq', tostring(seq)
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
-- Store a trade entry so that the response can echo executions.
|
|
169
|
+
local function append_trade(trades, payload)
|
|
170
|
+
trades[#trades + 1] = payload
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
-- Remove empty price levels from the sorted set, volume hash, and queue.
|
|
174
|
+
local function pop_empty_price_level(book_key, volume_key, queue_key, price_member)
|
|
175
|
+
local queue_len = redis.call('LLEN', queue_key)
|
|
176
|
+
local level_volume = redis.call('HGET', volume_key, price_member)
|
|
177
|
+
|
|
178
|
+
if queue_len == 0 or not level_volume or tonumber(level_volume) <= 0 then
|
|
179
|
+
redis.call('ZREM', book_key, price_member)
|
|
180
|
+
redis.call('HDEL', volume_key, price_member)
|
|
181
|
+
redis.call('DEL', queue_key)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
-- Redis KEYS provided by the caller. Order matters for latency and routing.
|
|
186
|
+
local market_key = KEYS[1]
|
|
187
|
+
local seq_key = KEYS[2]
|
|
188
|
+
local book_buy_key = KEYS[3]
|
|
189
|
+
local book_sell_key = KEYS[4]
|
|
190
|
+
local volume_buy_key = KEYS[5]
|
|
191
|
+
local volume_sell_key = KEYS[6]
|
|
192
|
+
local taker_balance_key = KEYS[7]
|
|
193
|
+
local trades_key = KEYS[8]
|
|
194
|
+
local orders_key = KEYS[9]
|
|
195
|
+
|
|
196
|
+
-- ARGV parameters describing the taker order being processed.
|
|
197
|
+
local symbol = ARGV[1]
|
|
198
|
+
local side = ARGV[2]
|
|
199
|
+
local order_type = string.upper(ARGV[3] or 'LIMIT')
|
|
200
|
+
local order_id = ARGV[4]
|
|
201
|
+
local user_id = ARGV[5]
|
|
202
|
+
local price_limit = tonumber(ARGV[6]) or 0
|
|
203
|
+
local quantity = tonumber(ARGV[7])
|
|
204
|
+
local timestamp = tonumber(ARGV[8])
|
|
205
|
+
local has_price_limit = price_limit > 0
|
|
206
|
+
local is_market = order_type == 'MARKET'
|
|
207
|
+
local is_buy = side == 'BUY'
|
|
208
|
+
|
|
209
|
+
if not (symbol and side and order_type and order_id and user_id and quantity and timestamp) then
|
|
210
|
+
return cjson.encode({ error = 'INVALID_ARGUMENTS' })
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if order_type ~= 'LIMIT' and order_type ~= 'MARKET' then
|
|
214
|
+
return cjson.encode({ error = 'INVALID_ORDER_TYPE' })
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if side ~= 'BUY' and side ~= 'SELL' then
|
|
218
|
+
return cjson.encode({ error = 'INVALID_SIDE' })
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if quantity <= 0 then
|
|
222
|
+
return cjson.encode({ error = 'INVALID_PRICE_OR_QUANTITY' })
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if not has_price_limit and order_type == 'LIMIT' then
|
|
226
|
+
return cjson.encode({ error = 'INVALID_PRICE_OR_QUANTITY' })
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
local order_key = 'order:' .. order_id
|
|
230
|
+
|
|
231
|
+
local tombstone_key = 'order_tombstone:' .. order_id
|
|
232
|
+
if redis.call('EXISTS', tombstone_key) == 1 then
|
|
233
|
+
return cjson.encode({ error = 'DUPLICATE_ORDER_ID' })
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
local existing_status = redis.call('HGET', order_key, 'status')
|
|
237
|
+
local existing_remaining = redis.call('HGET', order_key, 'remaining')
|
|
238
|
+
if existing_status == 'OPEN' or existing_status == 'PARTIALLY_FILLED' then
|
|
239
|
+
return cjson.encode({ error = 'DUPLICATE_ORDER_ID' })
|
|
240
|
+
end
|
|
241
|
+
if existing_remaining and tonumber(existing_remaining) > 0 then
|
|
242
|
+
return cjson.encode({ error = 'DUPLICATE_ORDER_ID' })
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
local market = to_map(redis.call('HGETALL', market_key))
|
|
246
|
+
if next(market) == nil then
|
|
247
|
+
return cjson.encode({ error = 'UNKNOWN_MARKET' })
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
local tombstone_ttl_sec = tonumber(market['order_tombstone_ttl_sec']) or 86400
|
|
251
|
+
local function mark_tombstone(oid, status)
|
|
252
|
+
if not oid or oid == '' then return end
|
|
253
|
+
redis.call('SET', 'order_tombstone:' .. oid, status or 'DONE', 'EX', tombstone_ttl_sec)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
-- Redundant check removed as we checked status at start of script
|
|
257
|
+
-- but we need to ensure market dict has it if used elsewhere.
|
|
258
|
+
-- market['status'] is available.
|
|
259
|
+
|
|
260
|
+
-- Optional stream length cap from market config; default to 1,000,000 entries
|
|
261
|
+
local trades_maxlen = tonumber(market['trades_maxlen']) or 1000000
|
|
262
|
+
local orders_maxlen = tonumber(market['orders_maxlen']) or 1000000
|
|
263
|
+
|
|
264
|
+
local base_asset = market['base_asset']
|
|
265
|
+
local quote_asset = market['quote_asset']
|
|
266
|
+
local quantity_scale = tonumber(market['quantity_scale'] or '1')
|
|
267
|
+
local min_qty = tonumber(market['min_qty'] or '0')
|
|
268
|
+
local min_notional = tonumber(market['min_notional'] or '0')
|
|
269
|
+
local maker_fee_bps = tonumber(market['maker_fee_bps'] or '0')
|
|
270
|
+
local taker_fee_bps = tonumber(market['taker_fee_bps'] or '0')
|
|
271
|
+
local price_scale = tonumber(market['price_scale'] or '1')
|
|
272
|
+
|
|
273
|
+
-- Taker user-specific fee rate takes precedence over market default
|
|
274
|
+
local user_taker_fee_bps = tonumber(ARGV[9])
|
|
275
|
+
if user_taker_fee_bps and user_taker_fee_bps >= 0 and user_taker_fee_bps <= 10000 then
|
|
276
|
+
taker_fee_bps = user_taker_fee_bps
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
-- Read asset balance scales and validate compatibility with market scales.
|
|
280
|
+
local base_asset_map = to_map(redis.call('HGETALL', 'asset:' .. base_asset))
|
|
281
|
+
local quote_asset_map = to_map(redis.call('HGETALL', 'asset:' .. quote_asset))
|
|
282
|
+
if next(base_asset_map) == nil or next(quote_asset_map) == nil then
|
|
283
|
+
return cjson.encode({ error = 'ASSET_NOT_CONFIGURED' })
|
|
284
|
+
end
|
|
285
|
+
local base_balance_scale = tonumber(base_asset_map['balance_scale'] or '0')
|
|
286
|
+
local quote_balance_scale = tonumber(quote_asset_map['balance_scale'] or '0')
|
|
287
|
+
if base_balance_scale <= 0 or quote_balance_scale <= 0 then
|
|
288
|
+
return cjson.encode({ error = 'ASSET_NOT_CONFIGURED' })
|
|
289
|
+
end
|
|
290
|
+
local function compute_factor(balance_scale, market_scale)
|
|
291
|
+
if market_scale <= 0 then return 0 end
|
|
292
|
+
local div = math.floor(balance_scale / market_scale)
|
|
293
|
+
if div <= 0 then return 0 end
|
|
294
|
+
if (div * market_scale) ~= balance_scale then
|
|
295
|
+
return 0
|
|
296
|
+
end
|
|
297
|
+
return div
|
|
298
|
+
end
|
|
299
|
+
local base_factor = compute_factor(base_balance_scale, quantity_scale)
|
|
300
|
+
local quote_factor = compute_factor(quote_balance_scale, price_scale)
|
|
301
|
+
if base_factor == 0 or quote_factor == 0 then
|
|
302
|
+
return cjson.encode({ error = 'ASSET_SCALE_MISMATCH' })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
-- Precision guard: Lua uses 64-bit double; keep intermediates below ~9e15
|
|
306
|
+
local PRECISION_BOUND = 9000000000000000 -- ~9e15
|
|
307
|
+
local function would_overflow_mul(a, b)
|
|
308
|
+
-- both a,b are integers represented in double; compare against bound
|
|
309
|
+
-- allow product equal to bound, overflow only when strictly greater
|
|
310
|
+
return (a > 0 and b > 0) and (a >= PRECISION_BOUND or b >= PRECISION_BOUND or (a * b) > PRECISION_BOUND)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
local function overflow_error(context, a, b)
|
|
314
|
+
return cjson.encode({
|
|
315
|
+
error = 'PRECISION_OVERFLOW',
|
|
316
|
+
details = context .. ': ' .. format_int(a) .. ' * ' .. format_int(b)
|
|
317
|
+
})
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
if quantity < min_qty then
|
|
321
|
+
return cjson.encode({ error = 'MIN_QTY_NOT_SATISFIED' })
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
if has_price_limit and would_overflow_mul(price_limit, quantity) then
|
|
325
|
+
return overflow_error('price_limit * quantity', price_limit, quantity)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
if order_type == 'LIMIT' then
|
|
329
|
+
local notional = math.floor((price_limit * quantity) / quantity_scale)
|
|
330
|
+
if notional < min_notional then
|
|
331
|
+
return cjson.encode({ error = 'MIN_NOTIONAL_NOT_SATISFIED' })
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
local base_available_field = base_asset .. ':available'
|
|
336
|
+
local base_reserved_field = base_asset .. ':reserved'
|
|
337
|
+
local quote_available_field = quote_asset .. ':available'
|
|
338
|
+
local quote_reserved_field = quote_asset .. ':reserved'
|
|
339
|
+
|
|
340
|
+
local taker_available_base = as_number(redis.call('HGET', taker_balance_key, base_available_field))
|
|
341
|
+
local taker_reserved_base = as_number(redis.call('HGET', taker_balance_key, base_reserved_field))
|
|
342
|
+
local taker_available_quote = as_number(redis.call('HGET', taker_balance_key, quote_available_field))
|
|
343
|
+
local taker_reserved_quote = as_number(redis.call('HGET', taker_balance_key, quote_reserved_field))
|
|
344
|
+
|
|
345
|
+
local taker_available_base_after = taker_available_base
|
|
346
|
+
local taker_available_quote_after = taker_available_quote
|
|
347
|
+
|
|
348
|
+
local reserved_base_for_order = 0
|
|
349
|
+
local reserved_quote_for_order = 0
|
|
350
|
+
|
|
351
|
+
local max_quote_needed = math.floor((price_limit * quantity) / quantity_scale)
|
|
352
|
+
local taker_fee_estimate = math.floor((max_quote_needed * taker_fee_bps) / 10000)
|
|
353
|
+
local maker_fee_estimate = math.floor((max_quote_needed * maker_fee_bps) / 10000)
|
|
354
|
+
|
|
355
|
+
if is_buy then
|
|
356
|
+
if is_market and not has_price_limit then
|
|
357
|
+
-- For market BUY without explicit price, we only check non-zero quote balance presence.
|
|
358
|
+
if taker_available_quote <= 0 then
|
|
359
|
+
return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = quote_asset })
|
|
360
|
+
end
|
|
361
|
+
else
|
|
362
|
+
local required_fee
|
|
363
|
+
if is_market then
|
|
364
|
+
required_fee = taker_fee_estimate
|
|
365
|
+
else
|
|
366
|
+
-- Limit BUY: Maker fee will be deducted from received Base asset, so no Quote fee reservation needed.
|
|
367
|
+
-- Taker fee for immediate matches?
|
|
368
|
+
-- If it matches immediately as Taker, it pays fee in Base (deducted from received).
|
|
369
|
+
-- So in neither case (Maker or Taker) does a BUY order pay fee in Quote.
|
|
370
|
+
required_fee = 0
|
|
371
|
+
end
|
|
372
|
+
local required_quote_market = max_quote_needed + required_fee
|
|
373
|
+
if would_overflow_mul(required_quote_market, quote_factor) then
|
|
374
|
+
return overflow_error('required_quote * quote_factor', required_quote_market, quote_factor)
|
|
375
|
+
end
|
|
376
|
+
local required_quote_balance = required_quote_market * quote_factor
|
|
377
|
+
if taker_available_quote < required_quote_balance then
|
|
378
|
+
return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = quote_asset })
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
else
|
|
382
|
+
if would_overflow_mul(quantity, base_factor) then
|
|
383
|
+
return overflow_error('quantity * base_factor', quantity, base_factor)
|
|
384
|
+
end
|
|
385
|
+
local required_base_balance = quantity * base_factor
|
|
386
|
+
if taker_available_base < required_base_balance then
|
|
387
|
+
return cjson.encode({ error = 'INSUFFICIENT_FUNDS', asset = base_asset })
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
local trades = {}
|
|
392
|
+
local remaining = quantity
|
|
393
|
+
local executed_quantity = 0
|
|
394
|
+
local executed_quote = 0
|
|
395
|
+
local taker_quote_spent = 0
|
|
396
|
+
local taker_quote_received = 0
|
|
397
|
+
local taker_base_received = 0
|
|
398
|
+
local taker_base_sold = 0
|
|
399
|
+
local maker_fees_collected = 0
|
|
400
|
+
local taker_fees_collected = 0
|
|
401
|
+
|
|
402
|
+
-- Accumulate collected fees in fees:{asset} for downstream accounting.
|
|
403
|
+
local function add_fee(asset, amount)
|
|
404
|
+
if amount <= 0 then
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
redis.call('HINCRBY', 'fees:' .. asset, 'total', format_int(amount))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
local function compute_fee_from_balance(amount_balance, fee_bps)
|
|
411
|
+
if amount_balance <= 0 or not fee_bps or fee_bps <= 0 then
|
|
412
|
+
return 0
|
|
413
|
+
end
|
|
414
|
+
local q = math.floor(amount_balance / 10000)
|
|
415
|
+
local r = amount_balance - (q * 10000)
|
|
416
|
+
if would_overflow_mul(q, fee_bps) then
|
|
417
|
+
return nil, overflow_error('fee_q * bps', q, fee_bps)
|
|
418
|
+
end
|
|
419
|
+
return (q * fee_bps) + math.floor((r * fee_bps) / 10000)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
-- Load an order hash into a Lua table for easier access.
|
|
423
|
+
local function load_order_map(order_key)
|
|
424
|
+
return to_map(redis.call('HGETALL', order_key))
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
-- Retrieve the top-of-book price entry for the given side.
|
|
428
|
+
local function best_price_key_for_side(book_key, is_buy_side)
|
|
429
|
+
-- We store SELL book with positive scores (price), BUY book with negative scores (-price).
|
|
430
|
+
-- Therefore, the best price level for both sides resides at ZRANGE index 0:
|
|
431
|
+
-- - SELL book: lowest ask (smallest positive score)
|
|
432
|
+
-- - BUY book: highest bid (most negative score)
|
|
433
|
+
return redis.call('ZRANGE', book_key, 0, 0, 'WITHSCORES')
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
local opposite_book_key = is_buy and book_sell_key or book_buy_key
|
|
437
|
+
local own_book_key = is_buy and book_buy_key or book_sell_key
|
|
438
|
+
local opposite_volume_key = is_buy and volume_sell_key or volume_buy_key
|
|
439
|
+
local own_volume_key = is_buy and volume_buy_key or volume_sell_key
|
|
440
|
+
-- Pre-check: for market orders, ensure sufficient opposing liquidity exists to fully satisfy requested quantity.
|
|
441
|
+
-- This avoids partial executions when the user expects all-or-nothing for market orders.
|
|
442
|
+
local insufficient_liquidity_precheck = false
|
|
443
|
+
if is_market then
|
|
444
|
+
local total_liquidity = 0
|
|
445
|
+
local levels = redis.call('ZRANGE', opposite_book_key, 0, -1, 'WITHSCORES')
|
|
446
|
+
for i = 1, #levels, 2 do
|
|
447
|
+
local price_member = levels[i]
|
|
448
|
+
local level_price = tonumber(levels[i + 1])
|
|
449
|
+
if not is_buy then
|
|
450
|
+
level_price = -level_price
|
|
451
|
+
end
|
|
452
|
+
if has_price_limit then
|
|
453
|
+
if is_buy and level_price > price_limit then
|
|
454
|
+
break
|
|
455
|
+
end
|
|
456
|
+
if (not is_buy) and level_price < price_limit then
|
|
457
|
+
break
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
local level_volume = as_number(redis.call('HGET', opposite_volume_key, price_member))
|
|
461
|
+
total_liquidity = total_liquidity + level_volume
|
|
462
|
+
if total_liquidity >= quantity then
|
|
463
|
+
break
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
if total_liquidity < quantity then
|
|
467
|
+
insufficient_liquidity_precheck = true
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
local function update_ticker_state(trade_list)
|
|
471
|
+
local ticker_key = 'ticker:' .. symbol
|
|
472
|
+
|
|
473
|
+
if #trade_list > 0 then
|
|
474
|
+
local last_trade = trade_list[#trade_list]
|
|
475
|
+
redis.call('HSET', ticker_key,
|
|
476
|
+
'last_price', format_int(last_trade.price),
|
|
477
|
+
'last_quantity', format_int(last_trade.quantity),
|
|
478
|
+
'last_quote', format_int(last_trade.quote),
|
|
479
|
+
'last_timestamp', tostring(last_trade.timestamp)
|
|
480
|
+
)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
local best_bid = redis.call('ZRANGE', book_buy_key, -1, -1)
|
|
484
|
+
if #best_bid > 0 then
|
|
485
|
+
local bid_price = best_bid[1]
|
|
486
|
+
local bid_volume = redis.call('HGET', volume_buy_key, bid_price) or '0'
|
|
487
|
+
redis.call('HSET', ticker_key,
|
|
488
|
+
'best_bid_price', bid_price,
|
|
489
|
+
'best_bid_volume', format_int(bid_volume)
|
|
490
|
+
)
|
|
491
|
+
else
|
|
492
|
+
redis.call('HSET', ticker_key,
|
|
493
|
+
'best_bid_price', '0',
|
|
494
|
+
'best_bid_volume', '0'
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
local best_ask = redis.call('ZRANGE', book_sell_key, 0, 0)
|
|
499
|
+
if #best_ask > 0 then
|
|
500
|
+
local ask_price = best_ask[1]
|
|
501
|
+
local ask_volume = redis.call('HGET', volume_sell_key, ask_price) or '0'
|
|
502
|
+
redis.call('HSET', ticker_key,
|
|
503
|
+
'best_ask_price', ask_price,
|
|
504
|
+
'best_ask_volume', format_int(ask_volume)
|
|
505
|
+
)
|
|
506
|
+
else
|
|
507
|
+
redis.call('HSET', ticker_key,
|
|
508
|
+
'best_ask_price', '0',
|
|
509
|
+
'best_ask_volume', '0'
|
|
510
|
+
)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
local opposite_side = is_buy and 'SELL' or 'BUY'
|
|
515
|
+
local stop_matching = false
|
|
516
|
+
|
|
517
|
+
while (not insufficient_liquidity_precheck) and remaining > 0 do
|
|
518
|
+
if stop_matching then
|
|
519
|
+
break
|
|
520
|
+
end
|
|
521
|
+
local market_seq = redis.call('INCR', seq_key)
|
|
522
|
+
local level = best_price_key_for_side(opposite_book_key, is_buy)
|
|
523
|
+
if #level == 0 then
|
|
524
|
+
break
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
local price_member = level[1]
|
|
528
|
+
local level_price = tonumber(level[2])
|
|
529
|
+
if opposite_side == 'BUY' then
|
|
530
|
+
level_price = -level_price
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
if has_price_limit then
|
|
534
|
+
if is_buy and level_price > price_limit then
|
|
535
|
+
break
|
|
536
|
+
end
|
|
537
|
+
if not is_buy and level_price < price_limit then
|
|
538
|
+
break
|
|
539
|
+
end
|
|
540
|
+
elseif not is_market then
|
|
541
|
+
-- Should not happen, defensive guard for future order types.
|
|
542
|
+
break
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
local queue_key = 'queue:' .. symbol .. ':' .. opposite_side .. ':' .. price_member
|
|
546
|
+
local maker_order_id = redis.call('LINDEX', queue_key, 0)
|
|
547
|
+
|
|
548
|
+
repeat
|
|
549
|
+
if not maker_order_id then
|
|
550
|
+
pop_empty_price_level(opposite_book_key, opposite_volume_key, queue_key, price_member)
|
|
551
|
+
break
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
local maker_order_key = 'order:' .. maker_order_id
|
|
555
|
+
local maker_order = load_order_map(maker_order_key)
|
|
556
|
+
if next(maker_order) == nil then
|
|
557
|
+
redis.call('LPOP', queue_key)
|
|
558
|
+
pop_empty_price_level(opposite_book_key, opposite_volume_key, queue_key, price_member)
|
|
559
|
+
break
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
local maker_remaining = tonumber(maker_order['remaining'] or '0')
|
|
563
|
+
if maker_remaining <= 0 then
|
|
564
|
+
redis.call('LPOP', queue_key)
|
|
565
|
+
pop_empty_price_level(opposite_book_key, opposite_volume_key, queue_key, price_member)
|
|
566
|
+
break
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
local trade_qty = remaining
|
|
570
|
+
if maker_remaining < trade_qty then
|
|
571
|
+
trade_qty = maker_remaining
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
local trade_price = tonumber(maker_order['price'] or level_price)
|
|
575
|
+
if would_overflow_mul(trade_price, trade_qty) then
|
|
576
|
+
return overflow_error('trade_price * trade_qty', trade_price, trade_qty)
|
|
577
|
+
end
|
|
578
|
+
local trade_quote = math.floor((trade_price * trade_qty) / quantity_scale)
|
|
579
|
+
local maker_side = maker_order['side']
|
|
580
|
+
local maker_user_id = maker_order['user_id']
|
|
581
|
+
local maker_balance_key = 'balance:' .. maker_user_id
|
|
582
|
+
|
|
583
|
+
if would_overflow_mul(trade_quote, quote_factor) then
|
|
584
|
+
return overflow_error('trade_quote * quote_factor', trade_quote, quote_factor)
|
|
585
|
+
end
|
|
586
|
+
local trade_quote_balance = trade_quote * quote_factor
|
|
587
|
+
local stream_taker_fee_asset
|
|
588
|
+
local stream_taker_fee_balance = 0
|
|
589
|
+
local stream_maker_fee_asset
|
|
590
|
+
local stream_maker_fee_balance = 0
|
|
591
|
+
|
|
592
|
+
-- Standard trade_seq for trades
|
|
593
|
+
local trade_seq = redis.call('INCR', seq_key)
|
|
594
|
+
|
|
595
|
+
-- Taker fee uses taker's configured rate
|
|
596
|
+
local taker_fee_quote = math.floor((trade_quote * taker_fee_bps) / 10000)
|
|
597
|
+
local taker_fee_base = math.floor((trade_qty * taker_fee_bps) / 10000)
|
|
598
|
+
|
|
599
|
+
-- Maker fee uses maker's configured rate (fetch from user_fees if set)
|
|
600
|
+
local maker_user_maker_fee_bps, _ = get_user_fees(maker_user_id)
|
|
601
|
+
local actual_maker_fee_bps = maker_user_maker_fee_bps or maker_fee_bps
|
|
602
|
+
local maker_fee_quote = math.floor((trade_quote * actual_maker_fee_bps) / 10000)
|
|
603
|
+
local maker_fee_base = math.floor((trade_qty * actual_maker_fee_bps) / 10000)
|
|
604
|
+
|
|
605
|
+
if is_buy then
|
|
606
|
+
-- Taker is BUY (receives Base, pays Quote)
|
|
607
|
+
-- Maker is SELL (receives Quote, pays Base)
|
|
608
|
+
|
|
609
|
+
-- Taker (BUY):
|
|
610
|
+
-- - Pays: trade_quote (from Available Quote)
|
|
611
|
+
-- - Receives: trade_qty - taker_fee_base (to Available Base)
|
|
612
|
+
-- - Fee: taker_fee_base (in Base Asset)
|
|
613
|
+
|
|
614
|
+
-- Maker (SELL):
|
|
615
|
+
-- - Pays: trade_qty (from Reserved Base)
|
|
616
|
+
-- - Receives: trade_quote - maker_fee_quote (to Available Quote)
|
|
617
|
+
-- - Fee: maker_fee_quote (in Quote Asset)
|
|
618
|
+
|
|
619
|
+
local debit_market = trade_quote
|
|
620
|
+
if would_overflow_mul(debit_market, quote_factor) then
|
|
621
|
+
return overflow_error('debit_market * quote_factor', debit_market, quote_factor)
|
|
622
|
+
end
|
|
623
|
+
local debit_balance = debit_market * quote_factor
|
|
624
|
+
if taker_available_quote_after < debit_balance then
|
|
625
|
+
stop_matching = true
|
|
626
|
+
break
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
if would_overflow_mul(trade_qty, base_factor) then
|
|
630
|
+
return overflow_error('trade_qty * base_factor', trade_qty, base_factor)
|
|
631
|
+
end
|
|
632
|
+
local trade_qty_balance = trade_qty * base_factor
|
|
633
|
+
|
|
634
|
+
-- Maker checks (SELL)
|
|
635
|
+
local maker_balance_key = 'balance:' .. maker_user_id
|
|
636
|
+
if maker_side == 'SELL' then
|
|
637
|
+
local trade_qty_balance_maker = trade_qty_balance
|
|
638
|
+
local taker_fee_base_balance, fee_err = compute_fee_from_balance(trade_qty_balance, taker_fee_bps)
|
|
639
|
+
if fee_err then
|
|
640
|
+
return fee_err
|
|
641
|
+
end
|
|
642
|
+
local maker_fee_quote_balance, fee_err2 = compute_fee_from_balance(trade_quote_balance, actual_maker_fee_bps)
|
|
643
|
+
if fee_err2 then
|
|
644
|
+
return fee_err2
|
|
645
|
+
end
|
|
646
|
+
local credit_balance_maker = trade_quote_balance - maker_fee_quote_balance
|
|
647
|
+
stream_taker_fee_asset = base_asset
|
|
648
|
+
stream_taker_fee_balance = taker_fee_base_balance
|
|
649
|
+
stream_maker_fee_asset = quote_asset
|
|
650
|
+
stream_maker_fee_balance = maker_fee_quote_balance
|
|
651
|
+
|
|
652
|
+
-- Fee Accumulation
|
|
653
|
+
taker_fees_collected = taker_fees_collected + taker_fee_base
|
|
654
|
+
maker_fees_collected = maker_fees_collected + maker_fee_quote
|
|
655
|
+
|
|
656
|
+
-- Add fees to system accounts
|
|
657
|
+
add_fee(base_asset, taker_fee_base_balance)
|
|
658
|
+
|
|
659
|
+
-- Maker paid fee in Quote Asset
|
|
660
|
+
add_fee(quote_asset, maker_fee_quote_balance)
|
|
661
|
+
|
|
662
|
+
-- EXECUTE Taker (BUY)
|
|
663
|
+
taker_available_quote_after = taker_available_quote_after - debit_balance
|
|
664
|
+
op_sub(user_id, quote_asset, debit_balance, 'TAKER_SPEND', timestamp,
|
|
665
|
+
'TAKER_SPEND:' .. market_seq .. '_' .. trade_seq .. ':' .. order_id)
|
|
666
|
+
|
|
667
|
+
local taker_receive_balance = trade_qty_balance - taker_fee_base_balance
|
|
668
|
+
op_add(user_id, base_asset, taker_receive_balance, 'TAKER_RECEIVE', timestamp,
|
|
669
|
+
'TAKER_RECEIVE:' .. market_seq .. '_' .. trade_seq .. ':' .. order_id)
|
|
670
|
+
|
|
671
|
+
taker_quote_spent = taker_quote_spent + debit_market
|
|
672
|
+
taker_base_received = taker_base_received + (trade_qty - taker_fee_base)
|
|
673
|
+
|
|
674
|
+
-- EXECUTE Maker (SELL)
|
|
675
|
+
-- Maker locked full trade_qty, release it all
|
|
676
|
+
op_sub_locked(maker_user_id, base_asset, trade_qty_balance_maker, 'MAKER_RELEASE', timestamp,
|
|
677
|
+
'MAKER_RELEASE:' .. market_seq .. '_' .. trade_seq .. ':' .. maker_order_id)
|
|
678
|
+
if redis.call('HEXISTS', maker_order_key, 'reserved_base') == 1 then
|
|
679
|
+
redis.call('HINCRBY', maker_order_key, 'reserved_base', format_int(-trade_qty_balance_maker))
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
-- Maker receives net quote (trade_quote - fee)
|
|
683
|
+
op_add(maker_user_id, quote_asset, credit_balance_maker, 'MAKER_RECEIVE', timestamp,
|
|
684
|
+
'MAKER_RECEIVE:' .. market_seq .. '_' .. trade_seq .. ':' .. maker_order_id)
|
|
685
|
+
else
|
|
686
|
+
-- Logic error check
|
|
687
|
+
end
|
|
688
|
+
else
|
|
689
|
+
-- Taker is SELL (pays Base, receives Quote)
|
|
690
|
+
-- Maker is BUY (pays Quote, receives Base)
|
|
691
|
+
|
|
692
|
+
-- Taker (SELL):
|
|
693
|
+
-- - Pays: trade_qty (from Available Base)
|
|
694
|
+
-- - Receives: trade_quote - taker_fee_quote (to Available Quote)
|
|
695
|
+
-- - Fee: taker_fee_quote (in Quote Asset)
|
|
696
|
+
|
|
697
|
+
-- Maker (BUY):
|
|
698
|
+
-- - Pays: trade_quote (from Reserved Quote)
|
|
699
|
+
-- - Receives: trade_qty - maker_fee_base (to Available Base)
|
|
700
|
+
-- - Fee: maker_fee_base (in Base Asset)
|
|
701
|
+
|
|
702
|
+
if would_overflow_mul(trade_qty, base_factor) then
|
|
703
|
+
return overflow_error('trade_qty * base_factor', trade_qty, base_factor)
|
|
704
|
+
end
|
|
705
|
+
local trade_qty_balance = trade_qty * base_factor
|
|
706
|
+
if taker_available_base_after < trade_qty_balance then
|
|
707
|
+
stop_matching = true
|
|
708
|
+
break
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
local credit_market = trade_quote - taker_fee_quote
|
|
712
|
+
if would_overflow_mul(credit_market, quote_factor) then
|
|
713
|
+
return overflow_error('credit_market * quote_factor', credit_market, quote_factor)
|
|
714
|
+
end
|
|
715
|
+
local credit_balance = credit_market * quote_factor
|
|
716
|
+
|
|
717
|
+
-- Maker checks (Maker is BUY)
|
|
718
|
+
local debit_market_maker = trade_quote
|
|
719
|
+
if would_overflow_mul(debit_market_maker, quote_factor) then
|
|
720
|
+
return overflow_error('debit_market_maker * quote_factor', debit_market_maker, quote_factor)
|
|
721
|
+
end
|
|
722
|
+
local debit_balance_maker = debit_market_maker * quote_factor
|
|
723
|
+
|
|
724
|
+
local trade_qty_balance_maker = trade_qty_balance
|
|
725
|
+
local taker_fee_quote_balance, fee_err = compute_fee_from_balance(trade_quote_balance, taker_fee_bps)
|
|
726
|
+
if fee_err then
|
|
727
|
+
return fee_err
|
|
728
|
+
end
|
|
729
|
+
local maker_fee_base_balance, fee_err2 = compute_fee_from_balance(trade_qty_balance_maker, actual_maker_fee_bps)
|
|
730
|
+
if fee_err2 then
|
|
731
|
+
return fee_err2
|
|
732
|
+
end
|
|
733
|
+
stream_taker_fee_asset = quote_asset
|
|
734
|
+
stream_taker_fee_balance = taker_fee_quote_balance
|
|
735
|
+
stream_maker_fee_asset = base_asset
|
|
736
|
+
stream_maker_fee_balance = maker_fee_base_balance
|
|
737
|
+
|
|
738
|
+
-- Fee Accumulation
|
|
739
|
+
taker_fees_collected = taker_fees_collected + taker_fee_quote
|
|
740
|
+
maker_fees_collected = maker_fees_collected + maker_fee_base
|
|
741
|
+
|
|
742
|
+
-- Add fees to system accounts
|
|
743
|
+
add_fee(quote_asset, taker_fee_quote_balance)
|
|
744
|
+
|
|
745
|
+
-- Maker paid fee in Base Asset
|
|
746
|
+
add_fee(base_asset, maker_fee_base_balance)
|
|
747
|
+
|
|
748
|
+
-- EXECUTE Taker (SELL)
|
|
749
|
+
taker_available_base_after = taker_available_base_after - trade_qty_balance
|
|
750
|
+
op_sub(user_id, base_asset, trade_qty_balance, 'TAKER_SPEND', timestamp,
|
|
751
|
+
'TAKER_SPEND:' .. market_seq .. '_' .. trade_seq .. ':' .. order_id)
|
|
752
|
+
|
|
753
|
+
local credit_balance_precise = trade_quote_balance - taker_fee_quote_balance
|
|
754
|
+
op_add(user_id, quote_asset, credit_balance_precise, 'TAKER_RECEIVE', timestamp,
|
|
755
|
+
'TAKER_RECEIVE:' .. market_seq .. '_' .. trade_seq .. ':' .. order_id)
|
|
756
|
+
|
|
757
|
+
taker_quote_received = taker_quote_received + credit_market
|
|
758
|
+
taker_base_sold = taker_base_sold + trade_qty
|
|
759
|
+
|
|
760
|
+
-- EXECUTE Maker (BUY)
|
|
761
|
+
op_sub_locked(maker_user_id, quote_asset, debit_balance_maker, 'MAKER_RELEASE', timestamp,
|
|
762
|
+
'MAKER_RELEASE:' .. market_seq .. '_' .. trade_seq .. ':' .. maker_order_id)
|
|
763
|
+
if redis.call('HEXISTS', maker_order_key, 'reserved_quote') == 1 then
|
|
764
|
+
redis.call('HINCRBY', maker_order_key, 'reserved_quote', format_int(-debit_balance_maker))
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
local maker_receive_balance = trade_qty_balance_maker - maker_fee_base_balance
|
|
768
|
+
|
|
769
|
+
op_add(maker_user_id, base_asset, maker_receive_balance, 'MAKER_RECEIVE', timestamp,
|
|
770
|
+
'MAKER_RECEIVE:' .. market_seq .. '_' .. trade_seq .. ':' .. maker_order_id)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
maker_remaining = maker_remaining - trade_qty
|
|
774
|
+
remaining = remaining - trade_qty
|
|
775
|
+
executed_quantity = executed_quantity + trade_qty
|
|
776
|
+
executed_quote = executed_quote + trade_quote
|
|
777
|
+
|
|
778
|
+
redis.call('HSET', maker_order_key, 'remaining', maker_remaining)
|
|
779
|
+
local maker_executed_quote = redis.call('HINCRBY', maker_order_key, 'executed_quote', format_int(trade_quote))
|
|
780
|
+
local maker_trade_count = redis.call('HINCRBY', maker_order_key, 'trade_count', 1)
|
|
781
|
+
|
|
782
|
+
-- Update volume first before any cleanup
|
|
783
|
+
local new_maker_vol = redis.call('HINCRBY', opposite_volume_key, price_member, format_int(-trade_qty))
|
|
784
|
+
publish_depth_update(symbol, (is_buy and 'SELL' or 'BUY'), price_member, -trade_qty, new_maker_vol, timestamp,
|
|
785
|
+
market_seq)
|
|
786
|
+
|
|
787
|
+
if maker_remaining == 0 then
|
|
788
|
+
-- Cleanup any residual reserved amounts (dust from rounding)
|
|
789
|
+
local residual_base = as_number(redis.call('HGET', maker_order_key, 'reserved_base'))
|
|
790
|
+
if residual_base > 0 then
|
|
791
|
+
op_unlock(maker_user_id, base_asset, residual_base, 'DUST_RELEASE', timestamp,
|
|
792
|
+
'DUST_RELEASE:' .. market_seq .. '_' .. trade_seq .. ':' .. maker_order_id)
|
|
793
|
+
end
|
|
794
|
+
local residual_quote = as_number(redis.call('HGET', maker_order_key, 'reserved_quote'))
|
|
795
|
+
if residual_quote > 0 then
|
|
796
|
+
op_unlock(maker_user_id, quote_asset, residual_quote, 'DUST_RELEASE', timestamp,
|
|
797
|
+
'DUST_RELEASE:' .. market_seq .. '_' .. trade_seq .. ':' .. maker_order_id)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
redis.call('HSET', maker_order_key, 'status', 'FILLED')
|
|
801
|
+
redis.call('LPOP', queue_key)
|
|
802
|
+
pop_empty_price_level(opposite_book_key, opposite_volume_key, queue_key, price_member)
|
|
803
|
+
-- Remove filled maker order from user's active index
|
|
804
|
+
local maker_index_key = 'user_orders:' .. maker_user_id .. ':' .. symbol
|
|
805
|
+
redis.call('ZREM', maker_index_key, maker_order_id)
|
|
806
|
+
-- Publish maker FILLED event and delete terminal order hash
|
|
807
|
+
redis.call('XADD', orders_key, 'MAXLEN', '~', orders_maxlen, '*',
|
|
808
|
+
'order_id', maker_order_id,
|
|
809
|
+
'user_id', maker_user_id,
|
|
810
|
+
'symbol', symbol,
|
|
811
|
+
'side', (is_buy and 'SELL' or 'BUY'),
|
|
812
|
+
'type', 'LIMIT',
|
|
813
|
+
'status', 'FILLED',
|
|
814
|
+
'price', price_member,
|
|
815
|
+
'quantity', redis.call('HGET', maker_order_key, 'quantity') or '0',
|
|
816
|
+
'remaining', '0',
|
|
817
|
+
'executed_quote', format_int(maker_executed_quote),
|
|
818
|
+
'trade_count', format_int(maker_trade_count),
|
|
819
|
+
'ts', tostring(timestamp),
|
|
820
|
+
'sequence', tostring(redis.call('HGET', maker_order_key, 'sequence') or '')
|
|
821
|
+
)
|
|
822
|
+
mark_tombstone(maker_order_id, 'FILLED')
|
|
823
|
+
redis.call('DEL', maker_order_key)
|
|
824
|
+
else
|
|
825
|
+
redis.call('HSET', maker_order_key, 'status', 'PARTIALLY_FILLED')
|
|
826
|
+
-- Publish maker PARTIALLY_FILLED event
|
|
827
|
+
redis.call('XADD', orders_key, 'MAXLEN', '~', orders_maxlen, '*',
|
|
828
|
+
'order_id', maker_order_id,
|
|
829
|
+
'user_id', maker_user_id,
|
|
830
|
+
'symbol', symbol,
|
|
831
|
+
'side', (is_buy and 'SELL' or 'BUY'),
|
|
832
|
+
'type', 'LIMIT',
|
|
833
|
+
'status', 'PARTIALLY_FILLED',
|
|
834
|
+
'price', price_member,
|
|
835
|
+
'quantity', redis.call('HGET', maker_order_key, 'quantity') or '0',
|
|
836
|
+
'remaining', format_int(maker_remaining),
|
|
837
|
+
'executed_quote', format_int(maker_executed_quote),
|
|
838
|
+
'trade_count', format_int(maker_trade_count),
|
|
839
|
+
'ts', tostring(timestamp),
|
|
840
|
+
'sequence', tostring(redis.call('HGET', maker_order_key, 'sequence') or '')
|
|
841
|
+
)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
local trade_payload = {
|
|
845
|
+
taker_order_id = order_id,
|
|
846
|
+
maker_order_id = maker_order_id,
|
|
847
|
+
-- normalized identifiers for convenience
|
|
848
|
+
buy_order_id = is_buy and order_id or maker_order_id,
|
|
849
|
+
sell_order_id = is_buy and maker_order_id or order_id,
|
|
850
|
+
taker_side = is_buy and 'BUY' or 'SELL',
|
|
851
|
+
price = trade_price,
|
|
852
|
+
quantity = trade_qty,
|
|
853
|
+
quote = trade_quote,
|
|
854
|
+
taker_fee = is_buy and taker_fee_base or taker_fee_quote,
|
|
855
|
+
maker_fee = is_buy and maker_fee_quote or maker_fee_base,
|
|
856
|
+
timestamp = timestamp,
|
|
857
|
+
trade_seq = trade_seq
|
|
858
|
+
}
|
|
859
|
+
append_trade(trades, trade_payload)
|
|
860
|
+
-- Stream entry with near-length trimming to avoid unbounded growth
|
|
861
|
+
-- Use MAXLEN ~ for amortized trimming; length threshold sourced from market config.
|
|
862
|
+
redis.call('XADD', trades_key, 'MAXLEN', '~', trades_maxlen, '*',
|
|
863
|
+
'taker_order_id', order_id,
|
|
864
|
+
'taker_user_id', user_id,
|
|
865
|
+
'maker_order_id', maker_order_id,
|
|
866
|
+
'maker_user_id', maker_user_id,
|
|
867
|
+
'buy_order_id', is_buy and order_id or maker_order_id,
|
|
868
|
+
'buy_user_id', is_buy and user_id or maker_user_id,
|
|
869
|
+
'sell_order_id', is_buy and maker_order_id or order_id,
|
|
870
|
+
'sell_user_id', is_buy and maker_user_id or user_id,
|
|
871
|
+
'taker_side', is_buy and 'BUY' or 'SELL',
|
|
872
|
+
'price', format_int(trade_price),
|
|
873
|
+
'quantity', format_int(trade_qty),
|
|
874
|
+
'quote', format_int(trade_quote),
|
|
875
|
+
'taker_fee', format_int(stream_taker_fee_balance),
|
|
876
|
+
'maker_fee', format_int(stream_maker_fee_balance),
|
|
877
|
+
'taker_fee_asset', stream_taker_fee_asset or '',
|
|
878
|
+
'maker_fee_asset', stream_maker_fee_asset or '',
|
|
879
|
+
'symbol', symbol,
|
|
880
|
+
'ts', tostring(timestamp),
|
|
881
|
+
'sequence', tostring(trade_seq)
|
|
882
|
+
)
|
|
883
|
+
until true
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
local remaining_before_post = remaining
|
|
887
|
+
local order_status
|
|
888
|
+
|
|
889
|
+
local market_seq = redis.call('INCR', seq_key)
|
|
890
|
+
if is_market then
|
|
891
|
+
if executed_quantity == 0 then
|
|
892
|
+
order_status = 'NO_LIQUIDITY'
|
|
893
|
+
elseif remaining_before_post > 0 then
|
|
894
|
+
order_status = 'PARTIALLY_FILLED'
|
|
895
|
+
else
|
|
896
|
+
order_status = 'FILLED'
|
|
897
|
+
end
|
|
898
|
+
remaining = 0
|
|
899
|
+
else
|
|
900
|
+
if remaining == 0 then
|
|
901
|
+
order_status = 'FILLED'
|
|
902
|
+
else
|
|
903
|
+
order_status = executed_quantity > 0 and 'PARTIALLY_FILLED' or 'OPEN'
|
|
904
|
+
if remaining > 0 then
|
|
905
|
+
if is_buy then
|
|
906
|
+
local reserve_quote = math.floor((price_limit * remaining) / quantity_scale)
|
|
907
|
+
-- No fee reservation for BUY orders (Maker fee deducted from Base received)
|
|
908
|
+
local reserve_fee = 0
|
|
909
|
+
local reserve_total = reserve_quote + reserve_fee
|
|
910
|
+
if reserve_total > 0 then
|
|
911
|
+
if would_overflow_mul(reserve_total, quote_factor) then
|
|
912
|
+
return overflow_error('reserve_total * quote_factor', reserve_total, quote_factor)
|
|
913
|
+
end
|
|
914
|
+
local reserve_quote_balance = reserve_total * quote_factor
|
|
915
|
+
if taker_available_quote_after < reserve_quote_balance then
|
|
916
|
+
return cjson.encode({ error = 'INSUFFICIENT_FUNDS_TO_REST', asset = quote_asset })
|
|
917
|
+
end
|
|
918
|
+
taker_available_quote_after = taker_available_quote_after - reserve_quote_balance
|
|
919
|
+
taker_reserved_quote = taker_reserved_quote + reserve_quote_balance
|
|
920
|
+
reserved_quote_for_order = reserve_quote_balance
|
|
921
|
+
op_lock(user_id, quote_asset, reserve_quote_balance, 'TAKER_RESERVE', timestamp,
|
|
922
|
+
'order_submit:' .. market_seq .. ':' .. order_id)
|
|
923
|
+
end
|
|
924
|
+
else
|
|
925
|
+
local reserve_qty = remaining
|
|
926
|
+
if reserve_qty > 0 then
|
|
927
|
+
if would_overflow_mul(reserve_qty, base_factor) then
|
|
928
|
+
return overflow_error('reserve_qty * base_factor', reserve_qty, base_factor)
|
|
929
|
+
end
|
|
930
|
+
local reserve_qty_balance = reserve_qty * base_factor
|
|
931
|
+
if taker_available_base_after < reserve_qty_balance then
|
|
932
|
+
return cjson.encode({ error = 'INSUFFICIENT_FUNDS_TO_REST', asset = base_asset })
|
|
933
|
+
end
|
|
934
|
+
taker_available_base_after = taker_available_base_after - reserve_qty_balance
|
|
935
|
+
taker_reserved_base = taker_reserved_base + reserve_qty_balance
|
|
936
|
+
reserved_base_for_order = reserve_qty_balance
|
|
937
|
+
op_lock(user_id, base_asset, reserve_qty_balance, 'TAKER_RESERVE', timestamp,
|
|
938
|
+
'order_submit:' .. market_seq .. ':' .. order_id)
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
local price_member = format_int(price_limit)
|
|
943
|
+
local queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. price_member
|
|
944
|
+
redis.call('RPUSH', queue_key, order_id)
|
|
945
|
+
|
|
946
|
+
if is_buy then
|
|
947
|
+
redis.call('ZADD', own_book_key, -price_limit, price_member)
|
|
948
|
+
else
|
|
949
|
+
redis.call('ZADD', own_book_key, price_limit, price_member)
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
redis.call('HINCRBY', own_volume_key, price_member, format_int(remaining))
|
|
953
|
+
publish_depth_update(symbol, side, price_member, remaining, redis.call('HGET', own_volume_key, price_member),
|
|
954
|
+
timestamp, market_seq)
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
redis.call('HMSET', order_key,
|
|
960
|
+
'order_id', order_id,
|
|
961
|
+
'user_id', user_id,
|
|
962
|
+
'symbol', symbol,
|
|
963
|
+
'side', side,
|
|
964
|
+
'type', order_type,
|
|
965
|
+
'price', format_int(price_limit),
|
|
966
|
+
'quantity', format_int(quantity),
|
|
967
|
+
'remaining', format_int(remaining),
|
|
968
|
+
'reserved_base', format_int(reserved_base_for_order),
|
|
969
|
+
'reserved_quote', format_int(reserved_quote_for_order),
|
|
970
|
+
'executed_quote', format_int(executed_quote),
|
|
971
|
+
'trade_count', format_int(#trades),
|
|
972
|
+
'status', order_status,
|
|
973
|
+
'created_at', tostring(timestamp),
|
|
974
|
+
'sequence', tostring(market_seq)
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
-- Maintain per-user active orders index for the taker.
|
|
978
|
+
-- Index only resting LIMIT orders with remaining > 0.
|
|
979
|
+
local taker_index_key = 'user_orders:' .. user_id .. ':' .. symbol
|
|
980
|
+
if order_type == 'LIMIT' and remaining > 0 and (order_status == 'OPEN' or order_status == 'PARTIALLY_FILLED') then
|
|
981
|
+
redis.call('ZADD', taker_index_key, market_seq, order_id)
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
update_ticker_state(trades)
|
|
985
|
+
|
|
986
|
+
-- Publish taker order event
|
|
987
|
+
redis.call('XADD', orders_key, 'MAXLEN', '~', orders_maxlen, '*',
|
|
988
|
+
'order_id', order_id,
|
|
989
|
+
'user_id', user_id,
|
|
990
|
+
'symbol', symbol,
|
|
991
|
+
'side', side,
|
|
992
|
+
'type', order_type,
|
|
993
|
+
'status', order_status,
|
|
994
|
+
'price', format_int(price_limit),
|
|
995
|
+
'quantity', format_int(quantity),
|
|
996
|
+
'remaining', format_int(remaining),
|
|
997
|
+
'executed_quote', format_int(executed_quote),
|
|
998
|
+
'trade_count', format_int(#trades),
|
|
999
|
+
'ts', tostring(timestamp),
|
|
1000
|
+
'sequence', tostring(market_seq)
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
-- Delete terminal taker orders to keep memory bounded
|
|
1004
|
+
if order_type == 'MARKET' or order_status == 'FILLED' or order_status == 'NO_LIQUIDITY' then
|
|
1005
|
+
mark_tombstone(order_id, order_status)
|
|
1006
|
+
redis.call('DEL', order_key)
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
local result = {
|
|
1010
|
+
order_id = order_id,
|
|
1011
|
+
order_type = order_type,
|
|
1012
|
+
status = order_status,
|
|
1013
|
+
requested_quantity = quantity,
|
|
1014
|
+
executed_quantity = executed_quantity,
|
|
1015
|
+
remaining_quantity = remaining_before_post,
|
|
1016
|
+
price_limit = price_limit,
|
|
1017
|
+
taker_quote_spent = taker_quote_spent,
|
|
1018
|
+
taker_quote_received = taker_quote_received,
|
|
1019
|
+
taker_base_received = taker_base_received,
|
|
1020
|
+
taker_base_sold = taker_base_sold,
|
|
1021
|
+
taker_fee_paid = taker_fees_collected,
|
|
1022
|
+
maker_fee_charged = maker_fees_collected,
|
|
1023
|
+
trades = trades
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return cjson.encode(result)
|