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