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