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,1544 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RedisExchange::Client
|
|
4
|
+
# ----------------------
|
|
5
|
+
# High-level Ruby façade for the Redis/Lua matching engine.
|
|
6
|
+
#
|
|
7
|
+
# Responsibilities:
|
|
8
|
+
# - Load and register the `match_order.lua` script against a provided Redis
|
|
9
|
+
# connection.
|
|
10
|
+
# - Expose convenience helpers to seed market metadata, manage balances,
|
|
11
|
+
# submit limit and market orders, and clear a market during tests.
|
|
12
|
+
# - Surfaces `ticker(symbol)` which reads the `ticker:{symbol}` hash maintained
|
|
13
|
+
# by the Lua script (last trade + best bid/ask snapshot).
|
|
14
|
+
#
|
|
15
|
+
# Numeric conventions:
|
|
16
|
+
# - All amounts are integer minor units; scales are defined in `seed_market`.
|
|
17
|
+
# - Keep values < 9e15 to stay within Lua double precision.
|
|
18
|
+
|
|
19
|
+
require 'json'
|
|
20
|
+
require 'bigdecimal'
|
|
21
|
+
require 'bigdecimal/util'
|
|
22
|
+
require 'redis' unless defined?(Redis)
|
|
23
|
+
require 'time'
|
|
24
|
+
require 'securerandom'
|
|
25
|
+
|
|
26
|
+
module RedisExchange
|
|
27
|
+
# Returns the root directory of the gem
|
|
28
|
+
def self.root
|
|
29
|
+
File.expand_path('..', __dir__)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
TICKER_FIELDS = %w[
|
|
33
|
+
last_price
|
|
34
|
+
last_quantity
|
|
35
|
+
last_quote
|
|
36
|
+
last_timestamp
|
|
37
|
+
best_bid_price
|
|
38
|
+
best_bid_volume
|
|
39
|
+
best_ask_price
|
|
40
|
+
best_ask_volume
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Order Sides
|
|
44
|
+
SIDE_BUY = 'BUY'
|
|
45
|
+
SIDE_SELL = 'SELL'
|
|
46
|
+
SIDES = [SIDE_BUY, SIDE_SELL].freeze
|
|
47
|
+
|
|
48
|
+
# Order Types
|
|
49
|
+
ORDER_TYPE_LIMIT = 'LIMIT'
|
|
50
|
+
ORDER_TYPE_MARKET = 'MARKET'
|
|
51
|
+
ORDER_TYPES = [ORDER_TYPE_LIMIT, ORDER_TYPE_MARKET].freeze
|
|
52
|
+
|
|
53
|
+
# Order Statuses
|
|
54
|
+
ORDER_STATUS_OPEN = 'OPEN'
|
|
55
|
+
ORDER_STATUS_PARTIALLY_FILLED = 'PARTIALLY_FILLED'
|
|
56
|
+
ORDER_STATUS_FILLED = 'FILLED'
|
|
57
|
+
ORDER_STATUS_CANCELLED = 'CANCELLED'
|
|
58
|
+
ORDER_STATUS_REJECTED = 'REJECTED'
|
|
59
|
+
ORDER_STATUS_UNKNOWN = 'UNKNOWN'
|
|
60
|
+
ORDER_STATUS_NOT_FOUND = 'NOT_FOUND'
|
|
61
|
+
|
|
62
|
+
# Market Statuses
|
|
63
|
+
MARKET_STATUS_OPEN = 'OPEN'
|
|
64
|
+
MARKET_STATUS_HALTED = 'HALTED'
|
|
65
|
+
MARKET_STATUS_CLOSED = 'CLOSED'
|
|
66
|
+
MARKET_STATUS_MAINTENANCE = 'MAINTENANCE'
|
|
67
|
+
MARKET_STATUSES = [
|
|
68
|
+
MARKET_STATUS_OPEN,
|
|
69
|
+
MARKET_STATUS_HALTED,
|
|
70
|
+
MARKET_STATUS_CLOSED,
|
|
71
|
+
MARKET_STATUS_MAINTENANCE
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
# Asset Statuses
|
|
75
|
+
ASSET_STATUS_ACTIVE = 'ACTIVE'
|
|
76
|
+
ASSET_STATUS_HALTED = 'HALTED'
|
|
77
|
+
ASSET_STATUSES = [ASSET_STATUS_ACTIVE, ASSET_STATUS_HALTED].freeze
|
|
78
|
+
|
|
79
|
+
# Balance Actions
|
|
80
|
+
ACTION_ADD = 'ADD'
|
|
81
|
+
ACTION_SUB = 'SUB'
|
|
82
|
+
ACTION_LOCK = 'LOCK'
|
|
83
|
+
ACTION_UNLOCK = 'UNLOCK'
|
|
84
|
+
ACTION_ADD_LOCKED = 'ADD_LOCKED'
|
|
85
|
+
ACTION_SUB_LOCKED = 'SUB_LOCKED'
|
|
86
|
+
BALANCE_ACTIONS = [
|
|
87
|
+
ACTION_ADD, ACTION_SUB, ACTION_LOCK, ACTION_UNLOCK,
|
|
88
|
+
ACTION_ADD_LOCKED, ACTION_SUB_LOCKED
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
# Balance Buckets
|
|
92
|
+
BUCKET_AVAILABLE = 'available'
|
|
93
|
+
BUCKET_RESERVED = 'reserved'
|
|
94
|
+
BUCKETS = [BUCKET_AVAILABLE, BUCKET_RESERVED].freeze
|
|
95
|
+
|
|
96
|
+
# Ledger Reasons
|
|
97
|
+
REASON_DEPOSIT = 'DEPOSIT'
|
|
98
|
+
REASON_WITHDRAWAL = 'WITHDRAWAL'
|
|
99
|
+
REASON_MANUAL = 'MANUAL'
|
|
100
|
+
REASON_MANUAL_ADJUST = 'MANUAL_ADJUST'
|
|
101
|
+
REASON_LOCK = 'LOCK'
|
|
102
|
+
REASON_UNLOCK = 'UNLOCK'
|
|
103
|
+
|
|
104
|
+
# Force Cancel Modes
|
|
105
|
+
CANCEL_MODE_DELETE_ONLY = 'DELETE_ONLY'
|
|
106
|
+
CANCEL_MODE_BEST_EFFORT_UNLOCK = 'BEST_EFFORT_UNLOCK'
|
|
107
|
+
CANCEL_MODES = [CANCEL_MODE_DELETE_ONLY, CANCEL_MODE_BEST_EFFORT_UNLOCK].freeze
|
|
108
|
+
|
|
109
|
+
# Error Codes
|
|
110
|
+
# System/Market Status Errors
|
|
111
|
+
ERR_SYSTEM_MAINTENANCE = 'SYSTEM_MAINTENANCE'
|
|
112
|
+
ERR_MARKET_MAINTENANCE = 'MARKET_MAINTENANCE'
|
|
113
|
+
ERR_MARKET_HALTED = 'MARKET_HALTED'
|
|
114
|
+
ERR_MARKET_CLOSED = 'MARKET_CLOSED'
|
|
115
|
+
|
|
116
|
+
# Argument Validation Errors
|
|
117
|
+
ERR_INVALID_ARGUMENTS = 'INVALID_ARGUMENTS'
|
|
118
|
+
ERR_INVALID_ORDER_TYPE = 'INVALID_ORDER_TYPE'
|
|
119
|
+
ERR_INVALID_SIDE = 'INVALID_SIDE'
|
|
120
|
+
ERR_INVALID_PRICE_OR_QUANTITY = 'INVALID_PRICE_OR_QUANTITY'
|
|
121
|
+
ERR_INVALID_AMOUNT = 'INVALID_AMOUNT'
|
|
122
|
+
ERR_INVALID_ACTION = 'INVALID_ACTION'
|
|
123
|
+
ERR_INVALID_ORDER = 'INVALID_ORDER'
|
|
124
|
+
|
|
125
|
+
# Order Errors
|
|
126
|
+
ERR_DUPLICATE_ORDER_ID = 'DUPLICATE_ORDER_ID'
|
|
127
|
+
ERR_UNKNOWN_MARKET = 'UNKNOWN_MARKET'
|
|
128
|
+
|
|
129
|
+
# Asset Errors
|
|
130
|
+
ERR_ASSET_NOT_CONFIGURED = 'ASSET_NOT_CONFIGURED'
|
|
131
|
+
ERR_ASSET_SCALE_MISMATCH = 'ASSET_SCALE_MISMATCH'
|
|
132
|
+
ERR_ASSET_SCALE_MISMATCH_WITH_NEW_CONFIG = 'ASSET_SCALE_MISMATCH_WITH_NEW_CONFIG'
|
|
133
|
+
|
|
134
|
+
# Trading Rule Errors
|
|
135
|
+
ERR_MIN_QTY_NOT_SATISFIED = 'MIN_QTY_NOT_SATISFIED'
|
|
136
|
+
ERR_MIN_NOTIONAL_NOT_SATISFIED = 'MIN_NOTIONAL_NOT_SATISFIED'
|
|
137
|
+
ERR_PRECISION_OVERFLOW = 'PRECISION_OVERFLOW'
|
|
138
|
+
|
|
139
|
+
# Balance Errors
|
|
140
|
+
ERR_INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS'
|
|
141
|
+
ERR_INSUFFICIENT_FUNDS_TO_REST = 'INSUFFICIENT_FUNDS_TO_REST'
|
|
142
|
+
|
|
143
|
+
# Max integer safe for Lua double precision (approx 9e15)
|
|
144
|
+
MAX_SAFE_INTEGER = 9_000_000_000_000_000
|
|
145
|
+
|
|
146
|
+
# Orders used to re-read market, asset and user-fee configuration on every
|
|
147
|
+
# request (four extra round trips per order). Those hashes only change when an
|
|
148
|
+
# admin mutates them, so the client caches them for this many seconds; every
|
|
149
|
+
# mutator in this file invalidates the cache immediately.
|
|
150
|
+
DEFAULT_CONFIG_CACHE_TTL = 5.0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
require_relative 'redis_exchange/version'
|
|
154
|
+
## No Ruby fallback or in-memory shim; all logic uses real Redis + Lua
|
|
155
|
+
|
|
156
|
+
module RedisExchange
|
|
157
|
+
# Client encapsulates Lua script execution and convenience helpers.
|
|
158
|
+
class Client
|
|
159
|
+
attr_reader :redis
|
|
160
|
+
|
|
161
|
+
# @param redis [Redis,nil] configured Redis connection; if nil, auto-connect
|
|
162
|
+
# Auto-connect priority: ENV['REDIS_URL'] -> 'redis://127.0.0.1:6379/0'
|
|
163
|
+
def initialize(redis: nil)
|
|
164
|
+
@redis = redis || Redis.new(url: (ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0'))
|
|
165
|
+
|
|
166
|
+
@match_order_script = load_script('match_order.lua')
|
|
167
|
+
@cancel_order_script = load_script('cancel_order.lua')
|
|
168
|
+
@balance_op_script = load_script('balance_op.lua')
|
|
169
|
+
@audit_script = load_script('audit_user_balance.lua')
|
|
170
|
+
@update_precision_script = load_script('update_market_precision.lua')
|
|
171
|
+
|
|
172
|
+
# Cache for market/asset/user-fee config used by the order path. Filled by
|
|
173
|
+
# #order_config, invalidated by every mutator that touches those hashes.
|
|
174
|
+
@config_cache = {}
|
|
175
|
+
@config_cache_ttl = RedisExchange::DEFAULT_CONFIG_CACHE_TTL
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Seconds a cached market/asset/user-fee entry stays valid. Set to 0 to
|
|
179
|
+
# disable caching (every order then re-reads configuration from Redis).
|
|
180
|
+
attr_accessor :config_cache_ttl
|
|
181
|
+
|
|
182
|
+
# Drop every cached configuration entry. Called by the seed/set/delete APIs;
|
|
183
|
+
# call it directly after mutating configuration outside this client.
|
|
184
|
+
def clear_config_cache!
|
|
185
|
+
@config_cache.clear
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Submit a limit order and return the Redis/Lua response hash.
|
|
190
|
+
# @param symbol [String] market symbol
|
|
191
|
+
# @param side [String, Symbol] 'BUY' or 'SELL'
|
|
192
|
+
# @param order_id [String] unique order identifier
|
|
193
|
+
# @param user_id [String] owner of the order
|
|
194
|
+
# @param price [Integer, Float, BigDecimal] limit price
|
|
195
|
+
# @param quantity [Integer, Float, BigDecimal] quantity
|
|
196
|
+
# @param timestamp_ms [Integer,nil] optional epoch in milliseconds
|
|
197
|
+
# @param auto_scale [Boolean] if true, scales price and quantity using market config
|
|
198
|
+
# @return [Hash] parsed Lua response
|
|
199
|
+
def place_limit_order(symbol:, side:, order_id:, user_id:, price:, quantity:, timestamp_ms: nil, auto_scale: false)
|
|
200
|
+
final_price = auto_scale ? scale_price(symbol, price) : price.to_i
|
|
201
|
+
final_quantity = auto_scale ? scale_quantity(symbol, quantity) : quantity.to_i
|
|
202
|
+
|
|
203
|
+
raise ArgumentError, 'price must be positive for limit orders' unless final_price.positive?
|
|
204
|
+
raise ArgumentError, 'quantity must be positive for limit orders' unless final_quantity.positive?
|
|
205
|
+
|
|
206
|
+
execute_order(
|
|
207
|
+
symbol: symbol,
|
|
208
|
+
side: side,
|
|
209
|
+
order_type: RedisExchange::ORDER_TYPE_LIMIT,
|
|
210
|
+
order_id: order_id,
|
|
211
|
+
user_id: user_id,
|
|
212
|
+
price_limit: final_price,
|
|
213
|
+
quantity: final_quantity,
|
|
214
|
+
timestamp_ms: timestamp_ms
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Submit a market order; optional price_cap acts as a worst-case limit.
|
|
219
|
+
# @param price_cap [Integer, Float, BigDecimal] 0 for uncapped markets
|
|
220
|
+
# @param auto_scale [Boolean] if true, scales price_cap and quantity using market config
|
|
221
|
+
# Other params mirror #place_limit_order
|
|
222
|
+
def place_market_order(symbol:, side:, order_id:, user_id:, quantity:, price_cap: 0, timestamp_ms: nil, auto_scale: false)
|
|
223
|
+
final_price_cap = auto_scale ? scale_price(symbol, price_cap) : price_cap.to_i
|
|
224
|
+
final_quantity = auto_scale ? scale_quantity(symbol, quantity) : quantity.to_i
|
|
225
|
+
|
|
226
|
+
raise ArgumentError, 'price_cap must be non-negative' if final_price_cap.negative?
|
|
227
|
+
|
|
228
|
+
execute_order(
|
|
229
|
+
symbol: symbol,
|
|
230
|
+
side: side,
|
|
231
|
+
order_type: RedisExchange::ORDER_TYPE_MARKET,
|
|
232
|
+
order_id: order_id,
|
|
233
|
+
user_id: user_id,
|
|
234
|
+
price_limit: final_price_cap,
|
|
235
|
+
quantity: final_quantity,
|
|
236
|
+
timestamp_ms: timestamp_ms
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Return the ticker hash for a market with integer values.
|
|
241
|
+
# @param symbol [String] market symbol
|
|
242
|
+
# @return [Hash{Symbol=>Integer}] last trade + top-of-book snapshot
|
|
243
|
+
def ticker(symbol)
|
|
244
|
+
raw = @redis.hgetall("ticker:#{symbol}")
|
|
245
|
+
return {} if raw.empty?
|
|
246
|
+
|
|
247
|
+
RedisExchange::TICKER_FIELDS.to_h do |field|
|
|
248
|
+
[field.to_sym, raw.fetch(field, '0').to_i]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Seed or update market metadata.
|
|
253
|
+
# @param symbol [String] market symbol (e.g., 'BTCUSDT')
|
|
254
|
+
# @param base_asset [String] base asset code (e.g., 'BTC')
|
|
255
|
+
# @param quote_asset [String] quote asset code (e.g., 'USDT')
|
|
256
|
+
# @param price_scale [Integer] price precision scale (price in quote minor units)
|
|
257
|
+
# @param quantity_scale [Integer] quantity precision scale (quantity in base minor units)
|
|
258
|
+
# @param maker_fee_bps [Integer] maker fee in basis points (0..10000)
|
|
259
|
+
# @param taker_fee_bps [Integer] taker fee in basis points (0..10000)
|
|
260
|
+
# @param min_qty [Integer] minimum order quantity in base minor units
|
|
261
|
+
# @param min_notional [Integer] minimum notional in quote minor units
|
|
262
|
+
# @param trades_maxlen [Integer,nil] max trades stream length; nil uses default (1_000_000)
|
|
263
|
+
# @param orders_maxlen [Integer,nil] max orders stream length; nil uses default (1_000_000)
|
|
264
|
+
def seed_market(symbol:, base_asset:, quote_asset:, price_scale: 1, quantity_scale: 1,
|
|
265
|
+
maker_fee_bps: 0, taker_fee_bps: 0, min_qty: 1, min_notional: 1,
|
|
266
|
+
status: RedisExchange::MARKET_STATUS_OPEN, trades_maxlen: nil, orders_maxlen: nil)
|
|
267
|
+
key = "market:#{symbol}"
|
|
268
|
+
stat = status.to_s.upcase
|
|
269
|
+
raise ArgumentError, 'invalid market status' unless RedisExchange::MARKET_STATUSES.include?(stat)
|
|
270
|
+
mapping = {
|
|
271
|
+
'base_asset' => base_asset,
|
|
272
|
+
'quote_asset' => quote_asset,
|
|
273
|
+
'price_scale' => price_scale,
|
|
274
|
+
'quantity_scale' => quantity_scale,
|
|
275
|
+
'maker_fee_bps' => maker_fee_bps,
|
|
276
|
+
'taker_fee_bps' => taker_fee_bps,
|
|
277
|
+
'min_qty' => min_qty,
|
|
278
|
+
'min_notional' => min_notional,
|
|
279
|
+
'status' => stat
|
|
280
|
+
}
|
|
281
|
+
mapping['trades_maxlen'] = trades_maxlen.to_i if trades_maxlen
|
|
282
|
+
mapping['orders_maxlen'] = orders_maxlen.to_i if orders_maxlen
|
|
283
|
+
@redis.hset(key, mapping)
|
|
284
|
+
clear_config_cache!
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def market_exist?(symbol)
|
|
288
|
+
@redis.exists?("market:#{symbol}")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def set_market_status(symbol:, status:)
|
|
292
|
+
key = "market:#{symbol}"
|
|
293
|
+
return { error: 'UNKNOWN_MARKET' } unless @redis.exists?(key)
|
|
294
|
+
|
|
295
|
+
stat = status.to_s.upcase
|
|
296
|
+
raise ArgumentError, 'invalid market status' unless RedisExchange::MARKET_STATUSES.include?(stat)
|
|
297
|
+
|
|
298
|
+
@redis.hset(key, 'status', stat)
|
|
299
|
+
clear_config_cache!
|
|
300
|
+
market_config(symbol)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def set_market_fees(symbol:, maker_fee_bps:, taker_fee_bps:)
|
|
304
|
+
key = "market:#{symbol}"
|
|
305
|
+
return { error: 'UNKNOWN_MARKET' } unless @redis.exists?(key)
|
|
306
|
+
|
|
307
|
+
maker_fee_bps = maker_fee_bps.to_i
|
|
308
|
+
taker_fee_bps = taker_fee_bps.to_i
|
|
309
|
+
raise ArgumentError, 'maker_fee_bps must be between 0 and 10000' unless (0..10000).include?(maker_fee_bps)
|
|
310
|
+
raise ArgumentError, 'taker_fee_bps must be between 0 and 10000' unless (0..10000).include?(taker_fee_bps)
|
|
311
|
+
|
|
312
|
+
@redis.hset(key, 'maker_fee_bps', maker_fee_bps, 'taker_fee_bps', taker_fee_bps)
|
|
313
|
+
clear_config_cache!
|
|
314
|
+
{ ok: true, symbol: symbol, maker_fee_bps: maker_fee_bps, taker_fee_bps: taker_fee_bps }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Seed or update an asset's balance scale and status.
|
|
318
|
+
# Balances and ledger entries MUST use this scale; matching converts market deltas into asset units.
|
|
319
|
+
# @param asset [String] asset code (e.g., 'BTC')
|
|
320
|
+
# @param balance_scale [Integer] integer minor unit scale for balances/ledger
|
|
321
|
+
# @param status [String] 'ACTIVE' | 'HALTED'
|
|
322
|
+
def seed_asset(asset:, balance_scale:, status: RedisExchange::ASSET_STATUS_ACTIVE)
|
|
323
|
+
raise ArgumentError, 'balance_scale must be positive' unless balance_scale.to_i > 0
|
|
324
|
+
stat = status.to_s.upcase
|
|
325
|
+
raise ArgumentError, 'invalid status' unless RedisExchange::ASSET_STATUSES.include?(stat)
|
|
326
|
+
key = "asset:#{asset}"
|
|
327
|
+
mapping = {
|
|
328
|
+
'balance_scale' => balance_scale.to_i,
|
|
329
|
+
'status' => stat
|
|
330
|
+
}
|
|
331
|
+
@redis.hset(key, mapping)
|
|
332
|
+
clear_config_cache!
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Read asset configuration.
|
|
336
|
+
# @param asset [String]
|
|
337
|
+
# @return [Hash] { balance_scale: Integer, status: String } or {}
|
|
338
|
+
def asset_config(asset)
|
|
339
|
+
parse_asset_config(@redis.hgetall("asset:#{asset}"))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Read market configuration.
|
|
343
|
+
# @param symbol [String]
|
|
344
|
+
# @return [Hash] full market config or {} if not found
|
|
345
|
+
def market_config(symbol)
|
|
346
|
+
raw = @redis.hgetall("market:#{symbol}")
|
|
347
|
+
return {} if raw.empty?
|
|
348
|
+
{
|
|
349
|
+
base_asset: raw.fetch('base_asset', nil),
|
|
350
|
+
quote_asset: raw.fetch('quote_asset', nil),
|
|
351
|
+
price_scale: raw.fetch('price_scale', '1').to_i,
|
|
352
|
+
quantity_scale: raw.fetch('quantity_scale', '1').to_i,
|
|
353
|
+
maker_fee_bps: raw.fetch('maker_fee_bps', '0').to_i,
|
|
354
|
+
taker_fee_bps: raw.fetch('taker_fee_bps', '0').to_i,
|
|
355
|
+
min_qty: raw.fetch('min_qty', '1').to_i,
|
|
356
|
+
min_notional: raw.fetch('min_notional', '1').to_i,
|
|
357
|
+
status: raw.fetch('status', RedisExchange::MARKET_STATUS_OPEN),
|
|
358
|
+
trades_maxlen: raw.fetch('trades_maxlen', '1000000').to_i,
|
|
359
|
+
orders_maxlen: raw.fetch('orders_maxlen', '1000000').to_i,
|
|
360
|
+
order_tombstone_ttl_sec: raw.fetch('order_tombstone_ttl_sec', '86400').to_i
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Validate that asset balance scales are compatible with a market's scales (integer multiples).
|
|
365
|
+
# @return [Hash] { ok: true, base_factor: Integer, quote_factor: Integer } or { ok: false, error: 'ASSET_NOT_CONFIGURED'|'ASSET_SCALE_MISMATCH', asset: String, balance_scale: Integer, market_scale: Integer }
|
|
366
|
+
def validate_asset_scales_for_market(base_asset:, quote_asset:, price_scale:, quantity_scale:)
|
|
367
|
+
base_cfg = asset_config(base_asset)
|
|
368
|
+
quote_cfg = asset_config(quote_asset)
|
|
369
|
+
base_bs = base_cfg[:balance_scale].to_i
|
|
370
|
+
quote_bs = quote_cfg[:balance_scale].to_i
|
|
371
|
+
unless base_bs.positive? && quote_bs.positive?
|
|
372
|
+
missing = !base_bs.positive? ? base_asset : quote_asset
|
|
373
|
+
return { ok: false, error: 'ASSET_NOT_CONFIGURED', asset: missing }
|
|
374
|
+
end
|
|
375
|
+
ps = price_scale.to_i
|
|
376
|
+
qs = quantity_scale.to_i
|
|
377
|
+
base_factor = base_bs / qs
|
|
378
|
+
quote_factor = quote_bs / ps
|
|
379
|
+
unless (base_factor * qs) == base_bs
|
|
380
|
+
return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: base_asset, balance_scale: base_bs, market_scale: qs }
|
|
381
|
+
end
|
|
382
|
+
unless (quote_factor * ps) == quote_bs
|
|
383
|
+
return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: quote_asset, balance_scale: quote_bs, market_scale: ps }
|
|
384
|
+
end
|
|
385
|
+
{ ok: true, base_factor: base_factor, quote_factor: quote_factor }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Overwrite a user's available/reserved buckets for an asset.
|
|
389
|
+
def set_balance(user_id:, asset:, available: 0, reserved: 0)
|
|
390
|
+
key = "balance:#{user_id}"
|
|
391
|
+
mapping = {
|
|
392
|
+
"#{asset}:available" => available,
|
|
393
|
+
"#{asset}:reserved" => reserved
|
|
394
|
+
}
|
|
395
|
+
@redis.hset(key, mapping)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Fetch all balance fields for a user as integers keyed by symbols.
|
|
399
|
+
# @param user_id [String]
|
|
400
|
+
# @return [Hash{Symbol=>Integer}]
|
|
401
|
+
# Get all balances for a user.
|
|
402
|
+
# @param user_id [String] user identifier
|
|
403
|
+
# @param auto_scale [Boolean] if true, converts integer balances to human-readable floats
|
|
404
|
+
# @return [Hash{Symbol=>Integer,Float}]
|
|
405
|
+
def balances(user_id, auto_scale: false)
|
|
406
|
+
raw = @redis.hgetall("balance:#{user_id}")
|
|
407
|
+
scales = {} if auto_scale
|
|
408
|
+
|
|
409
|
+
raw.each_with_object({}) do |(field, value), result|
|
|
410
|
+
val = value.to_i
|
|
411
|
+
if auto_scale
|
|
412
|
+
asset = field.split(':').first
|
|
413
|
+
scales[asset] ||= (asset_config(asset)[:balance_scale] || 1)
|
|
414
|
+
val = val.to_f / scales[asset]
|
|
415
|
+
end
|
|
416
|
+
result[field.to_sym] = val
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Get balance for a specific asset.
|
|
421
|
+
# @param user_id [String]
|
|
422
|
+
# @param asset [String]
|
|
423
|
+
# @param auto_scale [Boolean]
|
|
424
|
+
# @param include_ledger_seq [Boolean] include ledger_seq in result
|
|
425
|
+
# @return [Hash] { available: Integer|Float, reserved: Integer|Float, ledger_seq: Integer|nil }
|
|
426
|
+
def balance(user_id:, asset:, auto_scale: false, include_ledger_seq: false)
|
|
427
|
+
key = "balance:#{user_id}"
|
|
428
|
+
vals = @redis.hmget(key, "#{asset}:available", "#{asset}:reserved")
|
|
429
|
+
available = vals[0].to_i
|
|
430
|
+
reserved = vals[1].to_i
|
|
431
|
+
|
|
432
|
+
result = { available: available, reserved: reserved }
|
|
433
|
+
|
|
434
|
+
if include_ledger_seq
|
|
435
|
+
ledger_seq = @redis.get("ledger_seq:#{user_id}:#{asset}")&.to_i || 0
|
|
436
|
+
result[:ledger_seq] = ledger_seq
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
if auto_scale
|
|
440
|
+
scale = asset_config(asset)[:balance_scale] || 1
|
|
441
|
+
result[:available] = available.to_d / scale
|
|
442
|
+
result[:reserved] = reserved.to_d / scale
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
result
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Retrieve an order snapshot by id with typed fields.
|
|
449
|
+
# @param order_id [String]
|
|
450
|
+
# @return [Hash] empty hash when not found; otherwise typed fields
|
|
451
|
+
# Keys:
|
|
452
|
+
# - :order_id, :user_id, :symbol, :side, :type, :status (String)
|
|
453
|
+
# - :price, :quantity, :remaining, :created_at, :sequence (Integer)
|
|
454
|
+
def order(order_id)
|
|
455
|
+
raw = @redis.hgetall("order:#{order_id}")
|
|
456
|
+
return {} if raw.empty?
|
|
457
|
+
|
|
458
|
+
{
|
|
459
|
+
order_id: raw.fetch('order_id', order_id),
|
|
460
|
+
user_id: raw['user_id'],
|
|
461
|
+
symbol: raw['symbol'],
|
|
462
|
+
side: raw['side'],
|
|
463
|
+
type: raw['type'],
|
|
464
|
+
status: raw['status'],
|
|
465
|
+
price: raw.fetch('price', '0').to_i,
|
|
466
|
+
quantity: raw.fetch('quantity', '0').to_i,
|
|
467
|
+
remaining: raw.fetch('remaining', '0').to_i,
|
|
468
|
+
created_at: raw.fetch('created_at', '0').to_i,
|
|
469
|
+
sequence: raw.fetch('sequence', '0').to_i
|
|
470
|
+
}
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Cancel a resting order and release any reserved balances.
|
|
474
|
+
# Returns a status hash mirroring Lua responses.
|
|
475
|
+
def cancel_order(id=nil, order_id: nil)
|
|
476
|
+
# We need symbol to determine seq key.
|
|
477
|
+
# However, cancel_order.lua currently only takes order_key as KEYS[1].
|
|
478
|
+
# To provide seq key as KEYS[2], we need to know the symbol beforehand.
|
|
479
|
+
# But the caller only provides order_id.
|
|
480
|
+
# Option 1: Look up symbol from order hash first (requires extra RTT).
|
|
481
|
+
# Option 2: Change Lua script to accept symbol as ARGV and construct seq key? No, dynamic keys are bad for cluster.
|
|
482
|
+
# But seq key is {seq:{symbol}}, so we need symbol in KEYS for cluster compatibility.
|
|
483
|
+
order_id ||= id
|
|
484
|
+
raise ArgumentError, 'order_id or id must be provided' unless order_id
|
|
485
|
+
# Let's read the order first to get the symbol.
|
|
486
|
+
# This adds a round trip, but ensures correctness for cluster/key slots.
|
|
487
|
+
symbol = @redis.hget("order:#{order_id}", "symbol")
|
|
488
|
+
|
|
489
|
+
# If order is not found, the Lua script will handle it gracefully if we just pass a dummy key or handle it here.
|
|
490
|
+
# If we don't find symbol, the order likely doesn't exist.
|
|
491
|
+
return { status: 'NOT_FOUND', order_id: order_id } unless symbol
|
|
492
|
+
|
|
493
|
+
payload = {
|
|
494
|
+
keys: ["order:#{order_id}", "seq:#{symbol}"],
|
|
495
|
+
argv: [order_id]
|
|
496
|
+
}
|
|
497
|
+
raw = call_script(@cancel_order_script, payload)
|
|
498
|
+
JSON.parse(raw, symbolize_names: true)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Cancel all resting orders for a user across all markets.
|
|
502
|
+
# Implemented in Ruby to avoid Lua script timeouts on large data sets.
|
|
503
|
+
# @param user_id [String]
|
|
504
|
+
# @param return_results [Boolean] if true, returns cancellation results; if false, returns nil
|
|
505
|
+
# @return [Array<Hash>, nil] cancellation results if return_results is true, otherwise nil
|
|
506
|
+
def cancel_all_orders(user_id, return_results: true)
|
|
507
|
+
if maintenance.enabled?
|
|
508
|
+
return { error: 'SYSTEM_MAINTENANCE' }
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
results = []
|
|
512
|
+
cursor = '0'
|
|
513
|
+
loop do
|
|
514
|
+
# 1. Scan user's order indexes
|
|
515
|
+
cursor, keys = @redis.scan(cursor, match: "user_orders:#{user_id}:*", count: 100)
|
|
516
|
+
|
|
517
|
+
keys.each do |index_key|
|
|
518
|
+
# index_key format: user_orders:{user_id}:{symbol}
|
|
519
|
+
symbol = index_key.split(':').last
|
|
520
|
+
|
|
521
|
+
# 2. Get all order IDs for this symbol
|
|
522
|
+
order_ids = @redis.zrange(index_key, 0, -1)
|
|
523
|
+
|
|
524
|
+
order_ids.each do |oid|
|
|
525
|
+
# 3. Cancel each order individually
|
|
526
|
+
# This calls cancel_order.lua which handles status checks, balance release, etc.
|
|
527
|
+
# It returns a Hash with :status, :order_id, :symbol etc. or :error
|
|
528
|
+
res = cancel_order(oid)
|
|
529
|
+
|
|
530
|
+
# 4. Collect successful cancellations
|
|
531
|
+
if res[:status] == RedisExchange::ORDER_STATUS_CANCELLED
|
|
532
|
+
results << res
|
|
533
|
+
elsif res[:error] == 'MARKET_MAINTENANCE'
|
|
534
|
+
# Capture error but continue processing other orders (which might be in different markets)
|
|
535
|
+
results << { order_id: oid, error: 'MARKET_MAINTENANCE' }
|
|
536
|
+
elsif res[:status] == RedisExchange::ORDER_STATUS_NOT_FOUND || res[:status] == RedisExchange::ORDER_STATUS_UNKNOWN
|
|
537
|
+
# Order might be already gone or finished, clean up index just in case?
|
|
538
|
+
# cancel_order.lua doesn't clean up index if order not found in hash.
|
|
539
|
+
# But if it's not found, we should probably remove it from ZSET to avoid stuck keys.
|
|
540
|
+
# However, standard cancel_order doesn't know about user_id if order hash is gone.
|
|
541
|
+
# Here we know the index_key, so we can clean it.
|
|
542
|
+
@redis.zrem(index_key, oid)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
break if cursor == '0'
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# 5. Return results (or nil if return_results is false)
|
|
551
|
+
return_results ? results : nil
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Generic atomic balance operation.
|
|
555
|
+
# @param user_id [String]
|
|
556
|
+
# @param asset [String]
|
|
557
|
+
# @param action [String] LOCK, UNLOCK, ADD, SUB, SUB_LOCKED, ADD_LOCKED
|
|
558
|
+
# @param amount [Integer, Float, BigDecimal] positive amount
|
|
559
|
+
# @param reason [String]
|
|
560
|
+
# @param timestamp_ms [Integer,nil]
|
|
561
|
+
# @param biz_id [String,nil]
|
|
562
|
+
# @param auto_scale [Boolean]
|
|
563
|
+
# @return [Hash]
|
|
564
|
+
def operate_balance(user_id:, asset:, action:, amount:, reason: RedisExchange::REASON_MANUAL, timestamp_ms: nil, biz_id: nil, auto_scale: false)
|
|
565
|
+
raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
|
|
566
|
+
raise ArgumentError, 'asset is required' if asset.nil? || asset.to_s.empty?
|
|
567
|
+
raise ArgumentError, 'amount must be positive' unless amount.to_f.positive?
|
|
568
|
+
|
|
569
|
+
final_amount = if auto_scale
|
|
570
|
+
scale_asset_amount(asset, amount)
|
|
571
|
+
else
|
|
572
|
+
amount.to_i
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
timestamp = timestamp_ms || current_millis
|
|
576
|
+
biz = (biz_id && !biz_id.to_s.empty?) ? biz_id.to_s : SecureRandom.uuid
|
|
577
|
+
|
|
578
|
+
payload = {
|
|
579
|
+
keys: ["balance:#{user_id}"],
|
|
580
|
+
argv: [user_id, asset, final_amount.to_s, action, reason, timestamp.to_s, biz]
|
|
581
|
+
}
|
|
582
|
+
raw = call_script(@balance_op_script, payload)
|
|
583
|
+
resp = JSON.parse(raw, symbolize_names: true)
|
|
584
|
+
resp[:biz_id] = biz
|
|
585
|
+
resp
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def lock_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_LOCK, timestamp_ms: nil, biz_id: nil, auto_scale: false)
|
|
589
|
+
operate_balance(
|
|
590
|
+
user_id: user_id,
|
|
591
|
+
asset: asset,
|
|
592
|
+
action: RedisExchange::ACTION_LOCK,
|
|
593
|
+
amount: amount,
|
|
594
|
+
reason: reason,
|
|
595
|
+
timestamp_ms: timestamp_ms,
|
|
596
|
+
biz_id: biz_id,
|
|
597
|
+
auto_scale: auto_scale
|
|
598
|
+
)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def unlock_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_UNLOCK, timestamp_ms: nil, biz_id: nil, auto_scale: false)
|
|
602
|
+
operate_balance(
|
|
603
|
+
user_id: user_id,
|
|
604
|
+
asset: asset,
|
|
605
|
+
action: RedisExchange::ACTION_UNLOCK,
|
|
606
|
+
amount: amount,
|
|
607
|
+
reason: reason,
|
|
608
|
+
timestamp_ms: timestamp_ms,
|
|
609
|
+
biz_id: biz_id,
|
|
610
|
+
auto_scale: auto_scale
|
|
611
|
+
)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Deposit funds into user's available balance for an asset.
|
|
615
|
+
# @param user_id [String]
|
|
616
|
+
# @param asset [String]
|
|
617
|
+
# @param amount [Integer, Float, BigDecimal] positive amount
|
|
618
|
+
# @param reason [String] optional reason (defaults to 'DEPOSIT')
|
|
619
|
+
# @param timestamp_ms [Integer,nil]
|
|
620
|
+
# @param biz_id [String,nil]
|
|
621
|
+
# @param auto_scale [Boolean] if true, scales amount using asset's balance_scale
|
|
622
|
+
# @return [Hash]
|
|
623
|
+
def add_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_DEPOSIT, timestamp_ms: nil, biz_id: nil, auto_scale: false)
|
|
624
|
+
operate_balance(
|
|
625
|
+
user_id: user_id,
|
|
626
|
+
asset: asset,
|
|
627
|
+
action: RedisExchange::ACTION_ADD,
|
|
628
|
+
amount: amount,
|
|
629
|
+
reason: reason,
|
|
630
|
+
timestamp_ms: timestamp_ms,
|
|
631
|
+
biz_id: biz_id,
|
|
632
|
+
auto_scale: auto_scale
|
|
633
|
+
)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Withdraw funds from user's available balance for an asset.
|
|
637
|
+
# @param user_id [String]
|
|
638
|
+
# @param asset [String]
|
|
639
|
+
# @param amount [Integer, Float, BigDecimal] positive amount
|
|
640
|
+
# @param reason [String] optional reason (defaults to 'WITHDRAWAL')
|
|
641
|
+
# @param timestamp_ms [Integer,nil]
|
|
642
|
+
# @param biz_id [String,nil]
|
|
643
|
+
# @param auto_scale [Boolean] if true, scales amount using asset's balance_scale
|
|
644
|
+
# @return [Hash]
|
|
645
|
+
def sub_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_WITHDRAWAL, timestamp_ms: nil, biz_id: nil, auto_scale: false)
|
|
646
|
+
operate_balance(
|
|
647
|
+
user_id: user_id,
|
|
648
|
+
asset: asset,
|
|
649
|
+
action: RedisExchange::ACTION_SUB,
|
|
650
|
+
amount: amount,
|
|
651
|
+
reason: reason,
|
|
652
|
+
timestamp_ms: timestamp_ms,
|
|
653
|
+
biz_id: biz_id,
|
|
654
|
+
auto_scale: auto_scale
|
|
655
|
+
)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Withdraw funds from user's reserved (locked) balance for an asset.
|
|
659
|
+
# Useful for manual corrections or external liquidations.
|
|
660
|
+
# @param user_id [String]
|
|
661
|
+
# @param asset [String]
|
|
662
|
+
# @param amount [Integer, Float, BigDecimal] positive amount
|
|
663
|
+
# @param reason [String] optional reason (defaults to 'WITHDRAWAL')
|
|
664
|
+
# @param timestamp_ms [Integer,nil]
|
|
665
|
+
# @param biz_id [String,nil]
|
|
666
|
+
# @param auto_scale [Boolean] if true, scales amount using asset's balance_scale
|
|
667
|
+
# @return [Hash]
|
|
668
|
+
def sub_locked_funds(user_id:, asset:, amount:, reason: RedisExchange::REASON_WITHDRAWAL, timestamp_ms: nil, biz_id: nil, auto_scale: false)
|
|
669
|
+
operate_balance(
|
|
670
|
+
user_id: user_id,
|
|
671
|
+
asset: asset,
|
|
672
|
+
action: RedisExchange::ACTION_SUB_LOCKED,
|
|
673
|
+
amount: amount,
|
|
674
|
+
reason: reason,
|
|
675
|
+
timestamp_ms: timestamp_ms,
|
|
676
|
+
biz_id: biz_id,
|
|
677
|
+
auto_scale: auto_scale
|
|
678
|
+
)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Set user-specific trading fee rates.
|
|
682
|
+
# User fees take precedence over market default fees when placing orders.
|
|
683
|
+
# @param user_id [String] user identifier
|
|
684
|
+
# @param maker_fee_bps [Integer] maker fee in basis points (0..10000)
|
|
685
|
+
# @param taker_fee_bps [Integer] taker fee in basis points (0..10000)
|
|
686
|
+
# @return [Hash] { ok: true } or { error: String }
|
|
687
|
+
def set_user_fees(user_id:, maker_fee_bps:, taker_fee_bps:)
|
|
688
|
+
raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
|
|
689
|
+
raise ArgumentError, 'maker_fee_bps must be between 0 and 10000' unless (0..10000).include?(maker_fee_bps.to_i)
|
|
690
|
+
raise ArgumentError, 'taker_fee_bps must be between 0 and 10000' unless (0..10000).include?(taker_fee_bps.to_i)
|
|
691
|
+
|
|
692
|
+
key = "user_fees:#{user_id}"
|
|
693
|
+
@redis.hset(key, 'maker_fee_bps', maker_fee_bps.to_i, 'taker_fee_bps', taker_fee_bps.to_i)
|
|
694
|
+
clear_config_cache!
|
|
695
|
+
{ ok: true }
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Get user-specific trading fee rates.
|
|
699
|
+
# @param user_id [String] user identifier
|
|
700
|
+
# @return [Hash] { maker_fee_bps: Integer, taker_fee_bps: Integer } or {} if not set
|
|
701
|
+
def get_user_fees(user_id:)
|
|
702
|
+
raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
|
|
703
|
+
|
|
704
|
+
parse_user_fees(@redis.hgetall("user_fees:#{user_id}"))
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Delete user-specific trading fee rates, reverting to market defaults.
|
|
708
|
+
# @param user_id [String] user identifier
|
|
709
|
+
# @return [Hash] { ok: true } or { error: String }
|
|
710
|
+
def delete_user_fees(user_id:)
|
|
711
|
+
raise ArgumentError, 'user_id is required' if user_id.nil? || user_id.to_s.empty?
|
|
712
|
+
|
|
713
|
+
key = "user_fees:#{user_id}"
|
|
714
|
+
unless @redis.exists?(key)
|
|
715
|
+
return { error: 'USER_FEES_NOT_FOUND' }
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
@redis.del(key)
|
|
719
|
+
clear_config_cache!
|
|
720
|
+
{ ok: true }
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def fees_total(asset:, auto_scale: false)
|
|
724
|
+
raise ArgumentError, 'asset is required' if asset.nil? || asset.to_s.empty?
|
|
725
|
+
|
|
726
|
+
total = @redis.hget("fees:#{asset}", 'total').to_i
|
|
727
|
+
return total unless auto_scale
|
|
728
|
+
|
|
729
|
+
scale = asset_config(asset)[:balance_scale] || 1
|
|
730
|
+
total.to_d / scale
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def fees(asset:, auto_scale: false)
|
|
734
|
+
{ total: fees_total(asset: asset, auto_scale: auto_scale) }
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# 分页查询用户在某交易对的活跃订单(OPEN/PARTIALLY_FILLED 且 remaining>0)。
|
|
738
|
+
# 依赖 Lua 维护的 ZSET 索引:`user_orders:{user_id}:{symbol}`,score 使用撮合序列。
|
|
739
|
+
# @param user_id [String]
|
|
740
|
+
# @param symbol [String]
|
|
741
|
+
# @param page [Integer] 从 1 开始的页码
|
|
742
|
+
# @param per_page [Integer] 每页条数,默认 50
|
|
743
|
+
# @param direction [Symbol] :asc 或 :desc(根据序列顺序)
|
|
744
|
+
# @param auto_scale [Boolean] if true, unscales price/quantity using market config
|
|
745
|
+
# @return [Array<Hash>] 订单快照数组
|
|
746
|
+
def active_orders(user_id:, symbol:, page: 1, per_page: 50, direction: :desc, auto_scale: false)
|
|
747
|
+
page = page.to_i
|
|
748
|
+
per_page = per_page.to_i
|
|
749
|
+
page = 1 if page <= 0
|
|
750
|
+
per_page = 50 if per_page <= 0
|
|
751
|
+
|
|
752
|
+
index_key = "user_orders:#{user_id}:#{symbol}"
|
|
753
|
+
start = (page - 1) * per_page
|
|
754
|
+
stop = start + per_page - 1
|
|
755
|
+
|
|
756
|
+
range_method = direction.to_sym == :asc ? :zrange : :zrevrange
|
|
757
|
+
ids = Array(@redis.public_send(range_method, index_key, start, stop))
|
|
758
|
+
return [] if ids.empty?
|
|
759
|
+
|
|
760
|
+
orders = ids.map { |id| order(id) }.select { |o| !o.empty? && o[:remaining].positive? && (o[:status] == RedisExchange::ORDER_STATUS_OPEN || o[:status] == RedisExchange::ORDER_STATUS_PARTIALLY_FILLED) }
|
|
761
|
+
|
|
762
|
+
if auto_scale && !orders.empty?
|
|
763
|
+
cfg = market_config(symbol)
|
|
764
|
+
p_scale = (cfg[:price_scale] || 1).to_d
|
|
765
|
+
q_scale = (cfg[:quantity_scale] || 1).to_d
|
|
766
|
+
|
|
767
|
+
orders.each do |o|
|
|
768
|
+
o[:price] = o[:price] / p_scale
|
|
769
|
+
o[:quantity] = o[:quantity] / q_scale
|
|
770
|
+
o[:remaining] = o[:remaining] / q_scale
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
orders
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Get active order count for a user in a specific market.
|
|
778
|
+
# @param user_id [String]
|
|
779
|
+
# @param symbol [String] if nil, counts across all markets
|
|
780
|
+
# @return [Integer] number of active orders
|
|
781
|
+
def active_order_count(user_id:, symbol: nil)
|
|
782
|
+
if symbol
|
|
783
|
+
@redis.zcard("user_orders:#{user_id}:#{symbol}")
|
|
784
|
+
else
|
|
785
|
+
total = 0
|
|
786
|
+
cursor = '0'
|
|
787
|
+
loop do
|
|
788
|
+
cursor, keys = @redis.scan(cursor, match: "user_orders:#{user_id}:*", count: 100)
|
|
789
|
+
total += keys.sum { |key| @redis.zcard(key) }
|
|
790
|
+
break if cursor == '0'
|
|
791
|
+
end
|
|
792
|
+
total
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# Iterate over all orders for a user across all markets.
|
|
797
|
+
# Yields each order hash to the block.
|
|
798
|
+
# @param user_id [String]
|
|
799
|
+
# @yield [Hash] order hash with keys: order_id, user_id, symbol, side, type, status, price, quantity, remaining, etc.
|
|
800
|
+
# @return [Array<Hash>] all orders if block not given, otherwise nil
|
|
801
|
+
def all_orders(user_id:)
|
|
802
|
+
orders = []
|
|
803
|
+
cursor = '0'
|
|
804
|
+
loop do
|
|
805
|
+
cursor, keys = @redis.scan(cursor, match: "user_orders:#{user_id}:*", count: 100)
|
|
806
|
+
|
|
807
|
+
keys.each do |index_key|
|
|
808
|
+
symbol = index_key.split(':').last
|
|
809
|
+
order_ids = @redis.zrange(index_key, 0, -1)
|
|
810
|
+
|
|
811
|
+
order_ids.each do |oid|
|
|
812
|
+
o = order(oid)
|
|
813
|
+
next if o.empty?
|
|
814
|
+
if block_given?
|
|
815
|
+
yield o
|
|
816
|
+
else
|
|
817
|
+
orders << o
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
break if cursor == '0'
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
orders unless block_given?
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Iterate over all orders for a user in a specific market.
|
|
829
|
+
# Yields each order hash to the block.
|
|
830
|
+
# @param user_id [String]
|
|
831
|
+
# @param symbol [String] market symbol
|
|
832
|
+
# @yield [Hash] order hash with keys: order_id, user_id, symbol, side, type, status, price, quantity, remaining, etc.
|
|
833
|
+
# @return [Array<Hash>] all orders if block not given, otherwise nil
|
|
834
|
+
def all_orders_for_market(user_id:, symbol:)
|
|
835
|
+
orders = []
|
|
836
|
+
index_key = "user_orders:#{user_id}:#{symbol}"
|
|
837
|
+
order_ids = @redis.zrange(index_key, 0, -1)
|
|
838
|
+
|
|
839
|
+
order_ids.each do |oid|
|
|
840
|
+
o = order(oid)
|
|
841
|
+
next if o.empty?
|
|
842
|
+
if block_given?
|
|
843
|
+
yield o
|
|
844
|
+
else
|
|
845
|
+
orders << o
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
orders unless block_given?
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Fetch all active orders for a market (scanning the order book).
|
|
853
|
+
# WARNING: This can be expensive on large order books as it scans all price levels and queues.
|
|
854
|
+
# @param symbol [String]
|
|
855
|
+
# @return [Array<Hash>] list of order hashes
|
|
856
|
+
def market_active_orders(symbol:)
|
|
857
|
+
orders = []
|
|
858
|
+
[RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
|
|
859
|
+
# 1. Get all price levels from the book
|
|
860
|
+
book_key = "book:#{symbol}:#{side}"
|
|
861
|
+
prices = @redis.zrange(book_key, 0, -1)
|
|
862
|
+
|
|
863
|
+
prices.each do |price|
|
|
864
|
+
# 2. Get all order IDs at this price level
|
|
865
|
+
queue_key = "queue:#{symbol}:#{side}:#{price}"
|
|
866
|
+
order_ids = @redis.lrange(queue_key, 0, -1)
|
|
867
|
+
|
|
868
|
+
order_ids.each do |oid|
|
|
869
|
+
# 3. Fetch order details
|
|
870
|
+
o = order(oid)
|
|
871
|
+
orders << o unless o.empty?
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
orders
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# Iterate over all orders for a market.
|
|
879
|
+
# Yields each order hash to the block.
|
|
880
|
+
# WARNING: This can be expensive on large order books as it scans all price levels and queues.
|
|
881
|
+
# @param symbol [String] market symbol
|
|
882
|
+
# @yield [Hash] order hash with keys: order_id, user_id, symbol, side, type, status, price, quantity, remaining, etc.
|
|
883
|
+
# @return [Array<Hash>] all orders if block not given, otherwise nil
|
|
884
|
+
def all_market_orders(symbol:)
|
|
885
|
+
orders = []
|
|
886
|
+
[RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
|
|
887
|
+
book_key = "book:#{symbol}:#{side}"
|
|
888
|
+
prices = @redis.zrange(book_key, 0, -1)
|
|
889
|
+
|
|
890
|
+
prices.each do |price|
|
|
891
|
+
queue_key = "queue:#{symbol}:#{side}:#{price}"
|
|
892
|
+
order_ids = @redis.lrange(queue_key, 0, -1)
|
|
893
|
+
|
|
894
|
+
order_ids.each do |oid|
|
|
895
|
+
o = order(oid)
|
|
896
|
+
next if o.empty?
|
|
897
|
+
if block_given?
|
|
898
|
+
yield o
|
|
899
|
+
else
|
|
900
|
+
orders << o
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
orders unless block_given?
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# Get active order count for a market.
|
|
910
|
+
# @param symbol [String] market symbol
|
|
911
|
+
# @return [Hash] { total: Integer, buy: Integer, sell: Integer }
|
|
912
|
+
def market_order_count(symbol:)
|
|
913
|
+
total = 0
|
|
914
|
+
buy_count = 0
|
|
915
|
+
sell_count = 0
|
|
916
|
+
|
|
917
|
+
[RedisExchange::SIDE_BUY, RedisExchange::SIDE_SELL].each do |side|
|
|
918
|
+
book_key = "book:#{symbol}:#{side}"
|
|
919
|
+
prices = @redis.zrange(book_key, 0, -1)
|
|
920
|
+
|
|
921
|
+
side_count = 0
|
|
922
|
+
prices.each do |price|
|
|
923
|
+
queue_key = "queue:#{symbol}:#{side}:#{price}"
|
|
924
|
+
side_count += @redis.llen(queue_key)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
if side == RedisExchange::SIDE_BUY
|
|
928
|
+
buy_count = side_count
|
|
929
|
+
else
|
|
930
|
+
sell_count = side_count
|
|
931
|
+
end
|
|
932
|
+
total += side_count
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
{ total: total, buy: buy_count, sell: sell_count }
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Monitor order book changes via Redis Keyspace Notifications.
|
|
939
|
+
# Yields events to the block.
|
|
940
|
+
# Note: This method blocks while listening for events.
|
|
941
|
+
#
|
|
942
|
+
# @param symbol [String] market symbol
|
|
943
|
+
# @param redis_config [Hash] optional Redis configuration for the subscriber connection
|
|
944
|
+
# @yield [type, side]
|
|
945
|
+
# @yieldparam type [String] event type (e.g. 'zadd', 'zrem')
|
|
946
|
+
# @yieldparam side [String] 'BUY' or 'SELL'
|
|
947
|
+
def monitor_order_book(symbol:, redis_config: nil, &block)
|
|
948
|
+
raise ArgumentError, 'block required' unless block_given?
|
|
949
|
+
|
|
950
|
+
# Ensure Keyspace notifications are enabled for Generic (keys) and Sorted Sets
|
|
951
|
+
# K: Keyspace events, E: Keyevent events, z: Sorted sets
|
|
952
|
+
# We need Kz or Ez. We'll use Kz to subscribe to specific keys.
|
|
953
|
+
begin
|
|
954
|
+
config = @redis.config('GET', 'notify-keyspace-events')
|
|
955
|
+
current_policy = if config.is_a?(Hash)
|
|
956
|
+
config['notify-keyspace-events']
|
|
957
|
+
elsif config.is_a?(Array)
|
|
958
|
+
Hash[*config]['notify-keyspace-events']
|
|
959
|
+
else
|
|
960
|
+
''
|
|
961
|
+
end.to_s
|
|
962
|
+
|
|
963
|
+
unless current_policy.include?('K') && current_policy.include?('z')
|
|
964
|
+
new_policy = (current_policy.chars + ['K', 'z']).uniq.join
|
|
965
|
+
@redis.config('SET', 'notify-keyspace-events', new_policy)
|
|
966
|
+
end
|
|
967
|
+
rescue Redis::CommandError => e
|
|
968
|
+
# If CONFIG command is restricted (e.g. managed Redis), we warn but proceed
|
|
969
|
+
# assuming the user might have configured it externally.
|
|
970
|
+
warn "Warning: Failed to set notify-keyspace-events: #{e.message}"
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# Get current DB
|
|
974
|
+
# Use CLIENT INFO to reliably get the current database index
|
|
975
|
+
db = begin
|
|
976
|
+
info = @redis.call('CLIENT', 'INFO')
|
|
977
|
+
info[/db=(\d+)/, 1] || 0
|
|
978
|
+
rescue StandardError
|
|
979
|
+
0
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# Channels
|
|
983
|
+
buy_channel = "__keyspace@#{db}__:book:#{symbol}:#{RedisExchange::SIDE_BUY}"
|
|
984
|
+
sell_channel = "__keyspace@#{db}__:book:#{symbol}:#{RedisExchange::SIDE_SELL}"
|
|
985
|
+
|
|
986
|
+
# Use a new connection for subscription to avoid blocking the main client
|
|
987
|
+
# If redis_config is not provided, try to extract from current client
|
|
988
|
+
sub_redis = if redis_config
|
|
989
|
+
Redis.new(redis_config)
|
|
990
|
+
else
|
|
991
|
+
# Clone connection options
|
|
992
|
+
opts = if @redis.respond_to?(:_client)
|
|
993
|
+
@redis._client.instance_variable_get(:@options)
|
|
994
|
+
else
|
|
995
|
+
# Fallback: try to access options via instance var if _client missing
|
|
996
|
+
@redis.instance_variable_get(:@options)
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
if opts
|
|
1000
|
+
Redis.new(opts)
|
|
1001
|
+
else
|
|
1002
|
+
# Last resort: try default or ENV
|
|
1003
|
+
Redis.new(url: ENV['REDIS_URL'])
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
begin
|
|
1008
|
+
sub_redis.subscribe(buy_channel, sell_channel) do |on|
|
|
1009
|
+
on.message do |channel, message|
|
|
1010
|
+
side = if channel.end_with?(RedisExchange::SIDE_BUY)
|
|
1011
|
+
RedisExchange::SIDE_BUY
|
|
1012
|
+
else
|
|
1013
|
+
RedisExchange::SIDE_SELL
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
block.call(message, side)
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
ensure
|
|
1020
|
+
sub_redis.close
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# Build a TradeStreamConsumer for a market symbol.
|
|
1025
|
+
# Usage: consumer = exchange.trade_consumer(symbol: 'BTCUSDT', group: 'trades-consumers', consumer: 'worker-1')
|
|
1026
|
+
def trade_consumer(symbol:, group:, consumer:)
|
|
1027
|
+
require_relative 'redis_exchange/trade_stream_consumer'
|
|
1028
|
+
c = RedisExchange::TradeStreamConsumer.new(redis: @redis, symbol: symbol, group: group, consumer: consumer)
|
|
1029
|
+
c.ensure_group(start_id: '0')
|
|
1030
|
+
c
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# Build a DepthStreamConsumer for a market symbol.
|
|
1034
|
+
# Usage: consumer = exchange.depth_consumer(symbol: 'BTCUSDT', group: 'depth-consumers', consumer: 'worker-1')
|
|
1035
|
+
def depth_consumer(symbol:, group:, consumer:)
|
|
1036
|
+
require_relative 'redis_exchange/depth_stream_consumer'
|
|
1037
|
+
c = RedisExchange::DepthStreamConsumer.new(redis: @redis, symbol: symbol, group: group, consumer: consumer)
|
|
1038
|
+
c.ensure_group(start_id: '$')
|
|
1039
|
+
c
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
def maintenance
|
|
1043
|
+
require_relative 'redis_exchange/maintenance'
|
|
1044
|
+
@maintenance ||= RedisExchange::Maintenance.new(@redis)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# Build an OrderStreamConsumer for a market symbol.
|
|
1048
|
+
# Usage: consumer = exchange.order_consumer(symbol: 'BTCUSDT', group: 'orders-consumers', consumer: 'worker-1')
|
|
1049
|
+
def order_consumer(symbol:, group:, consumer:)
|
|
1050
|
+
require_relative 'redis_exchange/order_stream_consumer'
|
|
1051
|
+
c = RedisExchange::OrderStreamConsumer.new(redis: @redis, symbol: symbol, group: group, consumer: consumer)
|
|
1052
|
+
c.ensure_group(start_id: '0')
|
|
1053
|
+
c
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# Build a LedgerStreamConsumer for account ledger stream.
|
|
1057
|
+
# Usage: consumer = exchange.ledger_consumer(group: 'ledger-consumers', consumer: 'worker-1')
|
|
1058
|
+
def ledger_consumer(group:, consumer:, stream_key: 'account_ledger')
|
|
1059
|
+
require_relative 'redis_exchange/ledger_stream_consumer'
|
|
1060
|
+
c = RedisExchange::LedgerStreamConsumer.new(redis: @redis, group: group, consumer: consumer, stream_key: stream_key)
|
|
1061
|
+
c.ensure_group(start_id: '0')
|
|
1062
|
+
c
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# Remove book, volume, sequence, trades, and ticker keys for tests.
|
|
1066
|
+
def clear_books(symbol)
|
|
1067
|
+
keys = [
|
|
1068
|
+
"book:#{symbol}:#{RedisExchange::SIDE_BUY}",
|
|
1069
|
+
"book:#{symbol}:#{RedisExchange::SIDE_SELL}",
|
|
1070
|
+
"volume:#{symbol}:#{RedisExchange::SIDE_BUY}",
|
|
1071
|
+
"volume:#{symbol}:#{RedisExchange::SIDE_SELL}",
|
|
1072
|
+
"seq:#{symbol}",
|
|
1073
|
+
"trades:#{symbol}",
|
|
1074
|
+
"orders:#{symbol}",
|
|
1075
|
+
"depth:#{symbol}",
|
|
1076
|
+
"ticker:#{symbol}"
|
|
1077
|
+
]
|
|
1078
|
+
@redis.del(*keys)
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
# Snapshot the current book depth for both sides.
|
|
1082
|
+
# @param limit [Integer,nil] maximum number of price levels per side
|
|
1083
|
+
# @return [Hash{Symbol=>Array<Hash{Symbol=>Integer}>}]
|
|
1084
|
+
def order_book(symbol, limit: nil)
|
|
1085
|
+
limit = limit&.to_i
|
|
1086
|
+
{
|
|
1087
|
+
bids: fetch_book_side(symbol, RedisExchange::SIDE_BUY, descending: true, limit: limit),
|
|
1088
|
+
asks: fetch_book_side(symbol, RedisExchange::SIDE_SELL, descending: false, limit: limit)
|
|
1089
|
+
}
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# Get the number of price levels for a market.
|
|
1093
|
+
# @param symbol [String] market symbol
|
|
1094
|
+
# @return [Hash] { total: Integer, bids: Integer, asks: Integer }
|
|
1095
|
+
def price_level_count(symbol:)
|
|
1096
|
+
bids_count = @redis.zcard("book:#{symbol}:#{RedisExchange::SIDE_BUY}")
|
|
1097
|
+
asks_count = @redis.zcard("book:#{symbol}:#{RedisExchange::SIDE_SELL}")
|
|
1098
|
+
{ total: bids_count + asks_count, bids: bids_count, asks: asks_count }
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Merge depth levels by aggregating volumes within price ranges.
|
|
1102
|
+
# This method consolidates multiple price levels into aggregated buckets for better visualization.
|
|
1103
|
+
# @param symbol [String] market symbol
|
|
1104
|
+
# @param side [String] 'BUY' or 'SELL'
|
|
1105
|
+
# @param bucket_size [Integer] price bucket size in scaled units for aggregation
|
|
1106
|
+
# @param limit [Integer,nil] maximum number of aggregated buckets to return
|
|
1107
|
+
# @return [Array<Hash{Symbol=>Integer}>] array of aggregated depth levels
|
|
1108
|
+
# Each hash contains: :price, :volume, :original_levels (count of original levels merged)
|
|
1109
|
+
def merge_depth(symbol, side, bucket_size, limit: nil)
|
|
1110
|
+
raise ArgumentError, 'side must be BUY or SELL' unless RedisExchange::SIDES.include?(side.to_s.upcase)
|
|
1111
|
+
raise ArgumentError, 'bucket_size must be positive' unless bucket_size&.positive?
|
|
1112
|
+
|
|
1113
|
+
side_up = side.to_s.upcase
|
|
1114
|
+
bucket_size = bucket_size.to_i
|
|
1115
|
+
limit = limit&.to_i
|
|
1116
|
+
|
|
1117
|
+
# Fetch the full book side
|
|
1118
|
+
full_depth = fetch_book_side(symbol, side_up, descending: side_up == 'BUY', limit: nil)
|
|
1119
|
+
return [] if full_depth.empty?
|
|
1120
|
+
|
|
1121
|
+
# Aggregate price levels into buckets
|
|
1122
|
+
merged_buckets = {}
|
|
1123
|
+
full_depth.each do |level|
|
|
1124
|
+
price = level[:price]
|
|
1125
|
+
volume = level[:volume]
|
|
1126
|
+
|
|
1127
|
+
# Calculate bucket index based on price and bucket size
|
|
1128
|
+
bucket_index = (price / bucket_size).floor * bucket_size
|
|
1129
|
+
|
|
1130
|
+
# Initialize bucket if not exists
|
|
1131
|
+
unless merged_buckets[bucket_index]
|
|
1132
|
+
merged_buckets[bucket_index] = {
|
|
1133
|
+
price: bucket_index,
|
|
1134
|
+
volume: 0,
|
|
1135
|
+
original_levels: 0
|
|
1136
|
+
}
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# Aggregate volume and count levels
|
|
1140
|
+
merged_buckets[bucket_index][:volume] += volume
|
|
1141
|
+
merged_buckets[bucket_index][:original_levels] += 1
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
# Convert to sorted array
|
|
1145
|
+
result = merged_buckets.values
|
|
1146
|
+
if side_up == 'BUY'
|
|
1147
|
+
# For BUY side, sort by price descending (highest prices first)
|
|
1148
|
+
result.sort_by! { |bucket| -bucket[:price] }
|
|
1149
|
+
else
|
|
1150
|
+
# For SELL side, sort by price ascending (lowest prices first)
|
|
1151
|
+
result.sort_by! { |bucket| bucket[:price] }
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Apply limit if specified
|
|
1155
|
+
limit ? result.first(limit) : result
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
# Merge depth for both sides with configurable bucket sizes.
|
|
1159
|
+
# @param symbol [String] market symbol
|
|
1160
|
+
# @param buy_bucket_size [Integer] bucket size for BUY side
|
|
1161
|
+
# @param sell_bucket_size [Integer] bucket size for SELL side
|
|
1162
|
+
# @param limit [Integer,nil] maximum number of buckets per side
|
|
1163
|
+
# @return [Hash{Symbol=>Array<Hash{Symbol=>Integer}>}] merged depth for both sides
|
|
1164
|
+
def merge_depth_both_sides(symbol, buy_bucket_size, sell_bucket_size, limit: nil)
|
|
1165
|
+
{
|
|
1166
|
+
bids: merge_depth(symbol, RedisExchange::SIDE_BUY, buy_bucket_size, limit: limit),
|
|
1167
|
+
asks: merge_depth(symbol, RedisExchange::SIDE_SELL, sell_bucket_size, limit: limit)
|
|
1168
|
+
}
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
private
|
|
1172
|
+
|
|
1173
|
+
def check_market_status(market_config)
|
|
1174
|
+
st = market_config[:status].to_s.upcase
|
|
1175
|
+
return nil if st == RedisExchange::MARKET_STATUS_OPEN
|
|
1176
|
+
|
|
1177
|
+
case st
|
|
1178
|
+
when RedisExchange::MARKET_STATUS_HALTED then { error: 'MARKET_HALTED' }
|
|
1179
|
+
when RedisExchange::MARKET_STATUS_MAINTENANCE then { error: 'MARKET_MAINTENANCE' }
|
|
1180
|
+
when RedisExchange::MARKET_STATUS_CLOSED then { error: 'MARKET_CLOSED' }
|
|
1181
|
+
else { error: 'MARKET_CLOSED' }
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
# Market/asset/user-fee bundle for the order path, cached for
|
|
1186
|
+
# #config_cache_ttl seconds. Cache miss costs one HGETALL for the market plus
|
|
1187
|
+
# one pipelined round trip for both assets and the user's fee rates; a hit
|
|
1188
|
+
# costs nothing. Unknown markets are not cached so a freshly created market
|
|
1189
|
+
# is picked up immediately.
|
|
1190
|
+
def order_config(symbol, user_id)
|
|
1191
|
+
cache_key = "#{symbol}\u0000#{user_id}"
|
|
1192
|
+
now = monotonic_seconds
|
|
1193
|
+
cached = @config_cache[cache_key]
|
|
1194
|
+
# A zero TTL disables both storing and serving, so a stale entry left over
|
|
1195
|
+
# from an earlier setting can never be handed out.
|
|
1196
|
+
if @config_cache_ttl.positive? && cached && now < cached[:expires_at]
|
|
1197
|
+
return cached[:value]
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
mc = market_config(symbol)
|
|
1201
|
+
return { market: {}, base_asset: {}, quote_asset: {}, user_fees: {} } if mc.empty?
|
|
1202
|
+
|
|
1203
|
+
base_raw, quote_raw, fees_raw = @redis.pipelined do |pipe|
|
|
1204
|
+
pipe.hgetall("asset:#{mc[:base_asset]}")
|
|
1205
|
+
pipe.hgetall("asset:#{mc[:quote_asset]}")
|
|
1206
|
+
pipe.hgetall("user_fees:#{user_id}")
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
value = {
|
|
1210
|
+
market: mc,
|
|
1211
|
+
base_asset: parse_asset_config(base_raw),
|
|
1212
|
+
quote_asset: parse_asset_config(quote_raw),
|
|
1213
|
+
user_fees: parse_user_fees(fees_raw)
|
|
1214
|
+
}
|
|
1215
|
+
@config_cache[cache_key] = { value: value, expires_at: now + @config_cache_ttl } if @config_cache_ttl.positive?
|
|
1216
|
+
value
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
def parse_asset_config(raw)
|
|
1220
|
+
return {} if raw.nil? || raw.empty?
|
|
1221
|
+
|
|
1222
|
+
{
|
|
1223
|
+
balance_scale: raw.fetch('balance_scale', '0').to_i,
|
|
1224
|
+
status: raw.fetch('status', RedisExchange::ASSET_STATUS_ACTIVE)
|
|
1225
|
+
}
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
def parse_user_fees(raw)
|
|
1229
|
+
return {} if raw.nil? || raw.empty?
|
|
1230
|
+
|
|
1231
|
+
{
|
|
1232
|
+
maker_fee_bps: raw.fetch('maker_fee_bps', '0').to_i,
|
|
1233
|
+
taker_fee_bps: raw.fetch('taker_fee_bps', '0').to_i
|
|
1234
|
+
}
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
# Same validation as #validate_asset_scales_for_market but against configs
|
|
1238
|
+
# that were already fetched, so the order path does not re-read them.
|
|
1239
|
+
def validate_asset_scale_configs(base_cfg:, quote_cfg:, price_scale:, quantity_scale:,
|
|
1240
|
+
base_asset:, quote_asset:)
|
|
1241
|
+
base_bs = base_cfg[:balance_scale].to_i
|
|
1242
|
+
quote_bs = quote_cfg[:balance_scale].to_i
|
|
1243
|
+
unless base_bs.positive? && quote_bs.positive?
|
|
1244
|
+
missing = !base_bs.positive? ? base_asset : quote_asset
|
|
1245
|
+
return { ok: false, error: 'ASSET_NOT_CONFIGURED', asset: missing }
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
ps = price_scale.to_i
|
|
1249
|
+
qs = quantity_scale.to_i
|
|
1250
|
+
base_factor = base_bs / qs
|
|
1251
|
+
quote_factor = quote_bs / ps
|
|
1252
|
+
unless (base_factor * qs) == base_bs
|
|
1253
|
+
return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: base_asset,
|
|
1254
|
+
balance_scale: base_bs, market_scale: qs }
|
|
1255
|
+
end
|
|
1256
|
+
unless (quote_factor * ps) == quote_bs
|
|
1257
|
+
return { ok: false, error: 'ASSET_SCALE_MISMATCH', asset: quote_asset,
|
|
1258
|
+
balance_scale: quote_bs, market_scale: ps }
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
{ ok: true, base_factor: base_factor, quote_factor: quote_factor }
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def monotonic_seconds
|
|
1265
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
# Internal helper to execute the Lua script with typed arguments.
|
|
1269
|
+
def execute_order(symbol:, side:, order_type:, order_id:, user_id:, price_limit:, quantity:, timestamp_ms: nil)
|
|
1270
|
+
if maintenance.enabled?
|
|
1271
|
+
return { error: 'SYSTEM_MAINTENANCE' }
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# Duplicate id detection lives in match_order.lua (tombstone + order hash,
|
|
1275
|
+
# returning DUPLICATE_ORDER_ID). Checking it again here only added a round
|
|
1276
|
+
# trip to every order, so it is deliberately not repeated in Ruby.
|
|
1277
|
+
|
|
1278
|
+
side_up = side.to_s.upcase
|
|
1279
|
+
raise ArgumentError, 'side must be BUY or SELL' unless RedisExchange::SIDES.include?(side_up)
|
|
1280
|
+
|
|
1281
|
+
type_up = order_type.to_s.upcase
|
|
1282
|
+
raise ArgumentError, 'order_type must be LIMIT or MARKET' unless RedisExchange::ORDER_TYPES.include?(type_up)
|
|
1283
|
+
|
|
1284
|
+
# Precision pre-check (Ruby layer): both price_limit and quantity are scaled integers.
|
|
1285
|
+
# Prevent sending arguments that would overflow Lua's double precision (~9e15).
|
|
1286
|
+
pl = price_limit.to_i
|
|
1287
|
+
qty = quantity.to_i
|
|
1288
|
+
if pl.negative? || qty <= 0
|
|
1289
|
+
return { error: 'INVALID_PRICE_OR_QUANTITY' }
|
|
1290
|
+
end
|
|
1291
|
+
if pl > 0
|
|
1292
|
+
# If either operand or the product breaches the bound, abort.
|
|
1293
|
+
# allow product equal to bound; overflow only when strictly greater
|
|
1294
|
+
if pl >= RedisExchange::MAX_SAFE_INTEGER || qty >= RedisExchange::MAX_SAFE_INTEGER || (pl.to_i * qty.to_i) > RedisExchange::MAX_SAFE_INTEGER
|
|
1295
|
+
return { error: 'PRECISION_OVERFLOW', details: "price_limit * quantity: #{pl} * #{qty}" }
|
|
1296
|
+
end
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
# Market status + asset scale + user fee checks, served from the config
|
|
1300
|
+
# cache: one pipelined round trip on a miss, zero on a hit.
|
|
1301
|
+
cfg = order_config(symbol, user_id)
|
|
1302
|
+
mc = cfg[:market]
|
|
1303
|
+
return { error: 'UNKNOWN_MARKET' } if mc.empty?
|
|
1304
|
+
|
|
1305
|
+
status_check = check_market_status(mc)
|
|
1306
|
+
return status_check if status_check
|
|
1307
|
+
|
|
1308
|
+
# Asset scale compatibility pre-check: ensure asset balance scales are integer multiples
|
|
1309
|
+
# of market scales before invoking the Lua matcher. This surfaces friendly errors early.
|
|
1310
|
+
scale_check = validate_asset_scale_configs(
|
|
1311
|
+
base_cfg: cfg[:base_asset],
|
|
1312
|
+
quote_cfg: cfg[:quote_asset],
|
|
1313
|
+
price_scale: mc[:price_scale],
|
|
1314
|
+
quantity_scale: mc[:quantity_scale],
|
|
1315
|
+
base_asset: mc[:base_asset],
|
|
1316
|
+
quote_asset: mc[:quote_asset]
|
|
1317
|
+
)
|
|
1318
|
+
unless scale_check[:ok]
|
|
1319
|
+
err = { error: scale_check[:error] }
|
|
1320
|
+
[:asset, :balance_scale, :market_scale].each do |k|
|
|
1321
|
+
v = scale_check[k]
|
|
1322
|
+
err[k] = v if v
|
|
1323
|
+
end
|
|
1324
|
+
return err
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
ts = (timestamp_ms || current_millis).to_i
|
|
1328
|
+
|
|
1329
|
+
# Load user-specific taker fee rate if configured
|
|
1330
|
+
user_taker_fee_bps = cfg[:user_fees][:taker_fee_bps].to_s
|
|
1331
|
+
|
|
1332
|
+
keys = market_keys(symbol: symbol, user_id: user_id)
|
|
1333
|
+
script_payload = {
|
|
1334
|
+
keys: [
|
|
1335
|
+
keys[:market],
|
|
1336
|
+
keys[:sequence],
|
|
1337
|
+
keys[:book_buy],
|
|
1338
|
+
keys[:book_sell],
|
|
1339
|
+
keys[:volume_buy],
|
|
1340
|
+
keys[:volume_sell],
|
|
1341
|
+
keys[:balance],
|
|
1342
|
+
keys[:trades],
|
|
1343
|
+
keys[:orders]
|
|
1344
|
+
],
|
|
1345
|
+
argv: [
|
|
1346
|
+
symbol,
|
|
1347
|
+
side_up,
|
|
1348
|
+
type_up,
|
|
1349
|
+
order_id,
|
|
1350
|
+
user_id,
|
|
1351
|
+
price_limit.to_s,
|
|
1352
|
+
quantity.to_s,
|
|
1353
|
+
ts.to_s,
|
|
1354
|
+
user_taker_fee_bps
|
|
1355
|
+
]
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
raw = execute_via_match_script(script_payload)
|
|
1359
|
+
JSON.parse(raw, symbolize_names: true)
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
# Build all Redis keys touched by the Lua script for a market/user pair.
|
|
1363
|
+
def market_keys(symbol:, user_id:)
|
|
1364
|
+
{
|
|
1365
|
+
market: "market:#{symbol}",
|
|
1366
|
+
sequence: "seq:#{symbol}",
|
|
1367
|
+
book_buy: "book:#{symbol}:#{RedisExchange::SIDE_BUY}",
|
|
1368
|
+
book_sell: "book:#{symbol}:#{RedisExchange::SIDE_SELL}",
|
|
1369
|
+
volume_buy: "volume:#{symbol}:#{RedisExchange::SIDE_BUY}",
|
|
1370
|
+
volume_sell: "volume:#{symbol}:#{RedisExchange::SIDE_SELL}",
|
|
1371
|
+
balance: "balance:#{user_id}",
|
|
1372
|
+
trades: "trades:#{symbol}",
|
|
1373
|
+
orders: "orders:#{symbol}"
|
|
1374
|
+
}
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
# Current epoch time in milliseconds.
|
|
1378
|
+
def current_millis
|
|
1379
|
+
(Time.now.to_f * 1000).floor
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
# Simple Lua script wrapper using SCRIPT LOAD + EVALSHA.
|
|
1383
|
+
class LuaScript
|
|
1384
|
+
def initialize(source)
|
|
1385
|
+
@source = source
|
|
1386
|
+
@sha = nil
|
|
1387
|
+
end
|
|
1388
|
+
|
|
1389
|
+
def call(redis, keys:, argv:)
|
|
1390
|
+
@sha ||= redis.script(:load, @source)
|
|
1391
|
+
redis.evalsha(@sha, keys: keys, argv: argv)
|
|
1392
|
+
rescue ::Redis::CommandError => e
|
|
1393
|
+
if e.message =~ /NOSCRIPT/
|
|
1394
|
+
@sha = redis.script(:load, @source)
|
|
1395
|
+
retry
|
|
1396
|
+
else
|
|
1397
|
+
raise
|
|
1398
|
+
end
|
|
1399
|
+
end
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
def register_script(lua_source)
|
|
1403
|
+
raise ArgumentError, 'missing Lua source' unless lua_source && !lua_source.empty?
|
|
1404
|
+
LuaScript.new(lua_source)
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
def execute_via_match_script(script_payload)
|
|
1408
|
+
call_script(@match_order_script, script_payload)
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
def read_script(path)
|
|
1412
|
+
return nil unless path && File.exist?(path)
|
|
1413
|
+
|
|
1414
|
+
File.read(path)
|
|
1415
|
+
end
|
|
1416
|
+
|
|
1417
|
+
def load_script(filename)
|
|
1418
|
+
path = File.expand_path("../redis_scripts/#{filename}", __dir__)
|
|
1419
|
+
register_script(read_script(path))
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
def call_script(script, payload)
|
|
1423
|
+
script.call(@redis, **payload)
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
def collect_order_keys
|
|
1427
|
+
collect_keys('order:*')
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
def collect_queue_keys(symbol, side)
|
|
1431
|
+
collect_keys("queue:#{symbol}:#{side}:*")
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
def collect_keys(pattern)
|
|
1435
|
+
if @redis.respond_to?(:scan)
|
|
1436
|
+
cursor = '0'
|
|
1437
|
+
keys = []
|
|
1438
|
+
loop do
|
|
1439
|
+
scan_result = @redis.scan(cursor, match: pattern, count: 200)
|
|
1440
|
+
cursor = scan_result[0]
|
|
1441
|
+
keys.concat(Array(scan_result[1]))
|
|
1442
|
+
break if cursor == '0'
|
|
1443
|
+
end
|
|
1444
|
+
keys
|
|
1445
|
+
elsif @redis.respond_to?(:keys)
|
|
1446
|
+
Array(@redis.keys(pattern))
|
|
1447
|
+
else
|
|
1448
|
+
[]
|
|
1449
|
+
end
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
def fetch_book_side(symbol, side, descending:, limit:)
|
|
1453
|
+
if supports_sorted_book?
|
|
1454
|
+
return fetch_sorted_book_side(symbol, side, descending: descending, limit: limit)
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
queue_keys = collect_queue_keys(symbol, side)
|
|
1458
|
+
entries = queue_keys.map do |key|
|
|
1459
|
+
price = key.split(':').last
|
|
1460
|
+
{
|
|
1461
|
+
price: price.to_i,
|
|
1462
|
+
volume: @redis.hget("volume:#{symbol}:#{side}", price).to_i
|
|
1463
|
+
}
|
|
1464
|
+
end
|
|
1465
|
+
entries.select! { |row| row[:volume].positive? }
|
|
1466
|
+
entries.sort_by! { |row| descending ? -row[:price] : row[:price] }
|
|
1467
|
+
limit ? entries.first(limit) : entries
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
def supports_sorted_book?
|
|
1471
|
+
@redis.respond_to?(:zrange) && @redis.respond_to?(:zrevrange)
|
|
1472
|
+
end
|
|
1473
|
+
|
|
1474
|
+
def fetch_sorted_book_side(symbol, side, descending:, limit:)
|
|
1475
|
+
book_key = "book:#{symbol}:#{side}"
|
|
1476
|
+
volume_key = "volume:#{symbol}:#{side}"
|
|
1477
|
+
|
|
1478
|
+
# For BUY side, scores are negative (e.g. Price 100 -> Score -100).
|
|
1479
|
+
# - descending=true (Price Descending 100->90) => Score Ascending (-100->-90) => zrange
|
|
1480
|
+
# - descending=false (Price Ascending 90->100) => Score Descending (-90->-100) => zrevrange
|
|
1481
|
+
# For SELL side, scores are positive.
|
|
1482
|
+
# - descending=true (Price Descending 100->90) => Score Descending (100->90) => zrevrange
|
|
1483
|
+
# - descending=false (Price Ascending 90->100) => Score Ascending (90->100) => zrange
|
|
1484
|
+
|
|
1485
|
+
range_method_flag = descending
|
|
1486
|
+
range_method_flag = !range_method_flag if side == RedisExchange::SIDE_BUY
|
|
1487
|
+
range_method = range_method_flag ? :zrevrange : :zrange
|
|
1488
|
+
|
|
1489
|
+
stop = limit ? limit - 1 : -1
|
|
1490
|
+
raw = @redis.public_send(range_method, book_key, 0, stop, with_scores: true)
|
|
1491
|
+
pairs = if raw.is_a?(Array) && raw.first.is_a?(Array)
|
|
1492
|
+
raw
|
|
1493
|
+
else
|
|
1494
|
+
Array(raw).each_slice(2).to_a
|
|
1495
|
+
end
|
|
1496
|
+
prices = pairs.map { |member, _score| member.to_s }
|
|
1497
|
+
# One HMGET instead of one HGET per level: a 20-level book used to cost 20
|
|
1498
|
+
# round trips per side, dominate the read path, and now costs one.
|
|
1499
|
+
volumes = prices.empty? ? [] : Array(@redis.hmget(volume_key, *prices))
|
|
1500
|
+
list = pairs.each_with_index.map do |(member, score), idx|
|
|
1501
|
+
{
|
|
1502
|
+
price: member.to_s.to_i,
|
|
1503
|
+
volume: volumes[idx].to_i,
|
|
1504
|
+
score: score.to_f
|
|
1505
|
+
}
|
|
1506
|
+
end
|
|
1507
|
+
list.select! { |row| row[:volume].positive? }
|
|
1508
|
+
limit ? list.first(limit) : list
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
# Fallback-related helpers removed; cancellations handled via Lua scripts.
|
|
1513
|
+
|
|
1514
|
+
private
|
|
1515
|
+
|
|
1516
|
+
# Scale a human-readable asset amount (e.g., 1.5 BTC) into an integer using balance_scale.
|
|
1517
|
+
def scale_asset_amount(asset, amount)
|
|
1518
|
+
cfg = asset_config(asset)
|
|
1519
|
+
scale = cfg[:balance_scale] || 1
|
|
1520
|
+
(amount.to_f * scale).floor
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
# Scale a human-readable price into an integer using market price_scale.
|
|
1524
|
+
def scale_price(symbol, price)
|
|
1525
|
+
cfg = market_config(symbol)
|
|
1526
|
+
scale = cfg[:price_scale] || 1
|
|
1527
|
+
(price.to_f * scale).floor
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
# Scale a human-readable quantity into an integer using market quantity_scale.
|
|
1531
|
+
def scale_quantity(symbol, quantity)
|
|
1532
|
+
cfg = market_config(symbol)
|
|
1533
|
+
scale = cfg[:quantity_scale] || 1
|
|
1534
|
+
(quantity.to_f * scale).floor
|
|
1535
|
+
end
|
|
1536
|
+
|
|
1537
|
+
# Unscale an integer amount into a human-readable float using balance_scale.
|
|
1538
|
+
def unscale_asset_amount(asset, integer_amount)
|
|
1539
|
+
cfg = asset_config(asset)
|
|
1540
|
+
scale = cfg[:balance_scale] || 1
|
|
1541
|
+
integer_amount.to_f / scale
|
|
1542
|
+
end
|
|
1543
|
+
end
|
|
1544
|
+
end
|