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,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)