redis_exchange 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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