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,326 @@
|
|
|
1
|
+
# Redis Exchange Architecture
|
|
2
|
+
|
|
3
|
+
This document describes the Redis-centric exchange architecture used in this repository. It outlines the core data model, the matching flow, and the invariants the Lua script enforces.
|
|
4
|
+
|
|
5
|
+
## Key Concepts
|
|
6
|
+
|
|
7
|
+
- **Atomic operations** – Order placement and matching run inside a single Lua script to guarantee atomicity with respect to Redis state.
|
|
8
|
+
- **Deterministic queues** – Each price level keeps FIFO ordering through Redis lists; price discovery uses sorted sets keyed by price level.
|
|
9
|
+
- **Strict accounting** – User balances are split into `available` and `reserved` buckets per asset. The matching script is the only component allowed to mutate reserved balances to avoid race conditions.
|
|
10
|
+
|
|
11
|
+
## Data Model
|
|
12
|
+
|
|
13
|
+
### Markets
|
|
14
|
+
|
|
15
|
+
Markets are stored as hashes:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
market:{symbol}
|
|
19
|
+
base_asset -> e.g. BTC
|
|
20
|
+
quote_asset -> e.g. USDT
|
|
21
|
+
price_scale -> integer scale (1 = whole units, 100 = cents)
|
|
22
|
+
quantity_scale -> integer scale for base asset quantities
|
|
23
|
+
maker_fee_bps -> maker fee in basis points
|
|
24
|
+
taker_fee_bps -> taker fee in basis points
|
|
25
|
+
min_qty -> minimum order quantity (scaled integer)
|
|
26
|
+
min_notional -> minimum notional (quote units)
|
|
27
|
+
status -> OPEN | HALTED | CLOSED
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Assets (New)
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
asset:{asset}
|
|
34
|
+
balance_scale -> integer scale used for balances and ledger (e.g., 1, 100, 1_000_000)
|
|
35
|
+
status -> ACTIVE | HALTED
|
|
36
|
+
# optional display hints (not used by Lua): name, display_decimals
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Purpose: decouple account/ledger precision from per-market matching precision.
|
|
40
|
+
- All deposits/withdrawals/ledger entries must use `balance_scale` minor units for the asset.
|
|
41
|
+
- Markets reference assets via `base_asset` and `quote_asset`; the matching script converts market-scale deltas into asset `balance_scale` when mutating balances and writing ledger.
|
|
42
|
+
|
|
43
|
+
### User Fee Rates
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
user_fees:{user_id}
|
|
47
|
+
maker_fee_bps -> maker fee in basis points for this user (0..10000)
|
|
48
|
+
taker_fee_bps -> taker fee in basis points for this user (0..10000)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- Purpose: allow per-user customization of trading fee rates.
|
|
52
|
+
- User fees take precedence over market default fees when placing orders.
|
|
53
|
+
- If `user_fees:{user_id}` does not exist, the user is charged market default fees.
|
|
54
|
+
- Fee rates are specified in basis points (bps), where 100 bps = 1%.
|
|
55
|
+
- Zero fee rates (0 bps) are supported for fee-free trading.
|
|
56
|
+
|
|
57
|
+
### Balances
|
|
58
|
+
|
|
59
|
+
Balances live in a hash per user:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
balance:{user_id}
|
|
63
|
+
{asset}:available -> funds that can be spent immediately
|
|
64
|
+
{asset}:reserved -> funds locked on active orders
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
All balances are stored as integers in the asset's minor units (`asset.balance_scale`).
|
|
68
|
+
|
|
69
|
+
**Atomic Operations (`balance_op.lua`)**:
|
|
70
|
+
All balance mutations are performed via a unified Lua script supporting these primitives:
|
|
71
|
+
- `LOCK`: Move funds from `available` to `reserved`.
|
|
72
|
+
- `UNLOCK`: Move funds from `reserved` to `available`.
|
|
73
|
+
- `ADD`: Increase `available`.
|
|
74
|
+
- `SUB`: Decrease `available` (ensure non-negative).
|
|
75
|
+
- `ADD_LOCKED`: Increase `reserved`.
|
|
76
|
+
- `SUB_LOCKED`: Decrease `reserved` (ensure non-negative).
|
|
77
|
+
|
|
78
|
+
### Account Ledger Stream
|
|
79
|
+
|
|
80
|
+
A global stream records all balance changes for auditing and reconciliation:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
account_ledger
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Entry Fields**:
|
|
87
|
+
- `user_id`: User identifier.
|
|
88
|
+
- `asset`: Asset symbol (e.g., BTC).
|
|
89
|
+
- `action`: One of `LOCK`, `UNLOCK`, `ADD`, `SUB`, `ADD_LOCKED`, `SUB_LOCKED`.
|
|
90
|
+
- `amount`: Absolute change amount (always positive integer string).
|
|
91
|
+
- `reason`: Context string (e.g., `MATCH`, `CANCEL`, `DEPOSIT`).
|
|
92
|
+
- `ts`: Timestamp in ms.
|
|
93
|
+
- `sequence`: Per-user-asset incremental sequence number.
|
|
94
|
+
- `biz_id`: Idempotency key or auto-generated unique ID.
|
|
95
|
+
|
|
96
|
+
### Order Books
|
|
97
|
+
|
|
98
|
+
Two sorted sets maintain price discovery:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
book:{symbol}:buy
|
|
102
|
+
book:{symbol}:sell
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Members are price levels (encoded as strings), scores are the integer price. Buys are stored with negative scores so that `ZRANGE` retrieves the highest bid first.
|
|
106
|
+
|
|
107
|
+
Each price level has a FIFO queue of order ids:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
queue:{symbol}:{side}:{price}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
An aggregated volume hash tracks open quantity at each level:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
volume:{symbol}:{side}
|
|
117
|
+
{price} -> remaining base quantity at this level
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Orders
|
|
121
|
+
|
|
122
|
+
Individual orders are tracked as hashes:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
order:{order_id}
|
|
126
|
+
user_id
|
|
127
|
+
symbol
|
|
128
|
+
side -> BUY | SELL
|
|
129
|
+
type -> LIMIT | MARKET
|
|
130
|
+
price -> limit price cap (scaled; 0 for uncapped market)
|
|
131
|
+
quantity -> original quantity (scaled)
|
|
132
|
+
remaining -> open quantity (scaled)
|
|
133
|
+
status -> OPEN | PARTIALLY_FILLED | FILLED | NO_LIQUIDITY
|
|
134
|
+
created_at -> unix timestamp in ms
|
|
135
|
+
|
|
136
|
+
### Order Tombstones
|
|
137
|
+
|
|
138
|
+
Finished orders (FILLED, NO_LIQUIDITY, CANCELLED) are deleted from the `order:{id}` hash to conserve memory. To prevent ID reuse and facilitate idempotency checks, a lightweight "tombstone" key is created:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
order_tombstone:{order_id} -> status string (e.g. "FILLED")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **TTL**: 24 hours (default), configurable via `market:{symbol}` field `order_tombstone_ttl_sec`.
|
|
145
|
+
- **Purpose**: Prevents submitting a new order with the same ID as a recently finished one.
|
|
146
|
+
- **Check**: Order placement checks both `order:{id}` (active) and `order_tombstone:{id}` (finished) for duplicates.
|
|
147
|
+
|
|
148
|
+
### Sequencing
|
|
149
|
+
|
|
150
|
+
Each market has a monotonically increasing sequence to break ties at the same price:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
seq:{symbol} -> integer
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The sequence is appended to the order hash (`sequence`) and is used purely for auditing; FIFO ordering is handled by the Redis lists.
|
|
157
|
+
|
|
158
|
+
### Trades Stream
|
|
159
|
+
|
|
160
|
+
Matches are appended to a capped stream for downstream consumers (tickers, candles, history):
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
trades:{symbol}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Entry Fields**:
|
|
167
|
+
- `symbol`: Market symbol (e.g., BTCUSDT).
|
|
168
|
+
- `taker_order_id`: ID of the incoming order.
|
|
169
|
+
- `taker_user_id`: Taker's user ID.
|
|
170
|
+
- `maker_order_id`: ID of the resting order.
|
|
171
|
+
- `maker_user_id`: Maker's user ID.
|
|
172
|
+
- `buy_order_id`: Normalized ID for the buyer.
|
|
173
|
+
- `buy_user_id`: Buyer's user ID.
|
|
174
|
+
- `sell_order_id`: Normalized ID for the seller.
|
|
175
|
+
- `sell_user_id`: Seller's user ID.
|
|
176
|
+
- `taker_side`: `BUY` or `SELL`.
|
|
177
|
+
- `price`: Executed price (scaled integer).
|
|
178
|
+
- `quantity`: Executed base quantity (scaled integer).
|
|
179
|
+
- `quote`: Executed quote amount (scaled integer).
|
|
180
|
+
- `taker_fee`: Fee charged to taker (scaled integer).
|
|
181
|
+
- `maker_fee`: Fee charged to maker (scaled integer).
|
|
182
|
+
- `ts`: Unix timestamp in milliseconds.
|
|
183
|
+
- `trade_seq`: Global trade sequence number (per-market).
|
|
184
|
+
|
|
185
|
+
### Orders Stream (Private Events)
|
|
186
|
+
|
|
187
|
+
Order lifecycle events are emitted to a capped stream for user data push:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
orders:{symbol}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Entry Fields**:
|
|
194
|
+
- `order_id`: Unique order identifier.
|
|
195
|
+
- `user_id`: Owner of the order.
|
|
196
|
+
- `symbol`: Market symbol.
|
|
197
|
+
- `side`: `BUY` or `SELL`.
|
|
198
|
+
- `type`: `LIMIT` or `MARKET`.
|
|
199
|
+
- `status`: New status (`OPEN`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `NO_LIQUIDITY`).
|
|
200
|
+
- `price`: Limit price (scaled).
|
|
201
|
+
- `quantity`: Original quantity (scaled).
|
|
202
|
+
- `remaining`: Remaining quantity (scaled).
|
|
203
|
+
- `executed_quote`: Total executed quote amount (scaled).
|
|
204
|
+
- `trade_count`: Number of trades executed for this order.
|
|
205
|
+
- `ts`: Timestamp in ms.
|
|
206
|
+
- `sequence`: Market-wide sequence number.
|
|
207
|
+
|
|
208
|
+
### Depth Stream (Public Events)
|
|
209
|
+
|
|
210
|
+
A real-time stream of order book changes:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
depth:{symbol}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Entry Fields**:
|
|
217
|
+
- `e`: Event type (`u` for update).
|
|
218
|
+
- `s`: Side (`BUY` | `SELL`).
|
|
219
|
+
- `p`: Price level (integer string).
|
|
220
|
+
- `d`: Delta amount (signed integer string; negative = volume decrease).
|
|
221
|
+
- `v`: Final volume at this level (snapshot).
|
|
222
|
+
- `ts`: Timestamp (ms).
|
|
223
|
+
|
|
224
|
+
### Ticker
|
|
225
|
+
|
|
226
|
+
Per-market ticker information is stored in a hash:
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
ticker:{symbol}
|
|
230
|
+
last_price -> price of the most recent trade (scaled integer)
|
|
231
|
+
last_quantity -> quantity of the most recent trade
|
|
232
|
+
last_quote -> quote amount of the most recent trade
|
|
233
|
+
last_timestamp -> unix timestamp in ms
|
|
234
|
+
best_bid_price -> highest bid price (0 if no bids)
|
|
235
|
+
best_bid_volume -> quantity resting at best bid
|
|
236
|
+
best_ask_price -> lowest ask price (0 if no asks)
|
|
237
|
+
best_ask_volume -> quantity resting at best ask
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
## Matching Flow
|
|
242
|
+
|
|
243
|
+
1. **Validation**
|
|
244
|
+
- Market must be `OPEN`.
|
|
245
|
+
- The Ruby client performs a pre-check and rejects new order placement when market status is `HALTED` or `CLOSED` (returns `MARKET_HALTED` or `MARKET_CLOSED`).
|
|
246
|
+
- Order quantity must satisfy the market minimum; limit orders must also meet the minimum notional.
|
|
247
|
+
- The taker must have sufficient `available` balance to cover the order (quote asset for buys, base asset for sells).
|
|
248
|
+
|
|
249
|
+
2. **Fee Rate Selection**
|
|
250
|
+
- The Ruby client checks `user_fees:{user_id}` for user-specific fee rates.
|
|
251
|
+
- If user fees are configured, they are passed to the Lua script as optional parameters.
|
|
252
|
+
- Inside Lua, user fees take precedence over market default fees (`market:{symbol}`).
|
|
253
|
+
- If user fees are not set or are invalid, market defaults are used.
|
|
254
|
+
|
|
255
|
+
3. **Execution**
|
|
256
|
+
- Iterate through the book on the opposite side, starting at the best price level.
|
|
257
|
+
- Stop when either the book is empty or the next maker price falls outside the taker limit (for market orders a price cap of zero means "no limit").
|
|
258
|
+
- Pop maker orders FIFO from the price level queue, loading their order hash for remaining quantity and owner id.
|
|
259
|
+
- Compute the trade size (minimum of taker remaining and maker remaining) and notional.
|
|
260
|
+
- Apply fees: taker fee debited from the taker’s filled amount, maker fee debited from the maker’s proceeds. Fee rates are selected per-user (taker uses taker's rate, maker uses maker's rate).
|
|
261
|
+
- Update balances atomically:
|
|
262
|
+
- Buyer receives base asset in `available`, quote asset is decreased (reserved for taker, available for maker).
|
|
263
|
+
- Seller receives quote asset in `available`, base asset reserved is released.
|
|
264
|
+
- Reduce each order’s remaining quantity; if a maker order hits zero it is removed from the queue, price level aggregates, and its status is transitioned to `FILLED`.
|
|
265
|
+
- Append the trade payload to `trades:{symbol}`.
|
|
266
|
+
|
|
267
|
+
3. **Resting Orders**
|
|
268
|
+
- For limit orders, if the taker still has open quantity after exhausting marketable liquidity, convert the remaining stake into a resting maker order:
|
|
269
|
+
- Move funds from taker `available` to `reserved` bucket.
|
|
270
|
+
- Persist the order hash with status `OPEN` and push to the appropriate price level queue.
|
|
271
|
+
- Update the price level volume and (if new) add the price to the sorted set.
|
|
272
|
+
|
|
273
|
+
4. **Results**
|
|
274
|
+
- The script returns a JSON-encoded payload summarising:
|
|
275
|
+
- Order status, remaining quantity (market orders report leftover as unfilled and close immediately), and any reserved amount
|
|
276
|
+
- Trades executed during the call
|
|
277
|
+
- Balance deltas applied to the taker
|
|
278
|
+
- The ticker hash is refreshed with the most recent trade (if any) and current best bid/ask levels
|
|
279
|
+
|
|
280
|
+
## Numeric Precision & Scale Compatibility
|
|
281
|
+
|
|
282
|
+
- Lua runs on IEEE-754 double; keep every integer value strictly within ~9e15 to avoid precision loss.
|
|
283
|
+
- Market scales:
|
|
284
|
+
- `price_scale` (quote minor units)
|
|
285
|
+
- `quantity_scale` (base minor units)
|
|
286
|
+
- Asset scales:
|
|
287
|
+
- `asset.balance_scale` per asset, used for balances and ledger.
|
|
288
|
+
|
|
289
|
+
### Conversion Rules
|
|
290
|
+
|
|
291
|
+
- Base conversions (quantities):
|
|
292
|
+
- From market to balance units: `base_balance = base_market * (base_balance_scale / quantity_scale)`
|
|
293
|
+
- Quote conversions (notional and fees):
|
|
294
|
+
- From market to balance units: `quote_balance = quote_market * (quote_balance_scale / price_scale)`
|
|
295
|
+
|
|
296
|
+
To guarantee integer conversions and avoid rounding, enforce:
|
|
297
|
+
- `base_balance_scale` must be a multiple of `quantity_scale` for any market that trades the base asset.
|
|
298
|
+
- `quote_balance_scale` must be a multiple of `price_scale` for any market that trades the quote asset.
|
|
299
|
+
|
|
300
|
+
If this compatibility is violated, the engine should reject seeding or order placement with `ASSET_SCALE_MISMATCH`.
|
|
301
|
+
|
|
302
|
+
### Overflow Protection (unchanged but extended)
|
|
303
|
+
|
|
304
|
+
- The script aborts with `PRECISION_OVERFLOW` when any of the following would exceed ~9e15:
|
|
305
|
+
- `price * quantity` (market notional in market scales)
|
|
306
|
+
- Any conversion multiplier application (e.g., `quote_market * (quote_balance_scale / price_scale)`)
|
|
307
|
+
- Prefer "divide first, then multiply" or precomputed integer factors to minimize intermediate products.
|
|
308
|
+
|
|
309
|
+
## Fees Deduction Policy
|
|
310
|
+
|
|
311
|
+
- **Receive-Side Deduction**: Fees are always deducted from the asset received by the user.
|
|
312
|
+
- **Buyer (Taker or Maker)**: Receives Base Asset (e.g., BTC). Fee is deducted from the received Base Asset quantity.
|
|
313
|
+
- **Seller (Taker or Maker)**: Receives Quote Asset (e.g., USDT). Fee is deducted from the received Quote Asset amount.
|
|
314
|
+
- **No Fee Reservation**:
|
|
315
|
+
- Resting Limit BUY orders reserve only the nominal quote amount (`price * quantity`); no extra buffer for fees is reserved because fees will be paid in Base Asset upon execution.
|
|
316
|
+
- Resting Limit SELL orders reserve only the base quantity.
|
|
317
|
+
- **Market Orders**:
|
|
318
|
+
- Market BUY orders check for sufficient Quote balance to cover the trade notional. Since fees are paid in Base, no Quote fee buffer is needed.
|
|
319
|
+
- **Cancellations**: Release the nominal reserved amounts back to `available`.
|
|
320
|
+
|
|
321
|
+
## Invariants
|
|
322
|
+
|
|
323
|
+
- No double-spend: balances never go negative because all debits happen before credits and checks guard insufficiency.
|
|
324
|
+
- FIFO: queues guarantee chronological execution for same-price orders.
|
|
325
|
+
- Atomicity: Lua script wraps the entire life cycle of an order placement.
|
|
326
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'stream_consumer'
|
|
4
|
+
|
|
5
|
+
module RedisExchange
|
|
6
|
+
class DepthStreamConsumer < StreamConsumer
|
|
7
|
+
# Typed wrapper for raw Redis fields
|
|
8
|
+
DepthEvent = Struct.new(
|
|
9
|
+
:id, :symbol, :event_type, :side, :price, :delta, :final_volume, :timestamp
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
def initialize(redis:, symbol:, group:, consumer:)
|
|
13
|
+
super(redis: redis, group: group, consumer: consumer, stream_key: "depth:#{symbol}")
|
|
14
|
+
@symbol = symbol
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Reads and normalizes depth events
|
|
18
|
+
def read_events(count: 100, block_ms: 2000)
|
|
19
|
+
entries = read(count: count, block_ms: block_ms)
|
|
20
|
+
entries.map { |e| normalize_event(e) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_event(entry)
|
|
26
|
+
f = entry.fields
|
|
27
|
+
DepthEvent.new(
|
|
28
|
+
entry.id,
|
|
29
|
+
@symbol,
|
|
30
|
+
f['e'],
|
|
31
|
+
f['s'],
|
|
32
|
+
f['p'].to_i,
|
|
33
|
+
f['d'].to_i,
|
|
34
|
+
f['v'].to_i,
|
|
35
|
+
f['ts'].to_i
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'stream_consumer'
|
|
4
|
+
|
|
5
|
+
module RedisExchange
|
|
6
|
+
# LedgerStreamConsumer
|
|
7
|
+
# --------------------
|
|
8
|
+
# Encapsulates Redis Streams consumer-group operations for `account_ledger`.
|
|
9
|
+
class LedgerStreamConsumer < StreamConsumer
|
|
10
|
+
def initialize(redis:, group:, consumer:, stream_key: 'account_ledger')
|
|
11
|
+
super(redis: redis, group: group, consumer: consumer, stream_key: stream_key)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|