smart-data-engine-sdk 0.1.0.dev0__py3-none-any.whl

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,454 @@
1
+ """Orderbook engine adapter, and the three things it will not pretend to be.
2
+
3
+ This is the first engine here whose physical schema is not ours. PostgreSQL and ClickHouse take a
4
+ schema we derive from the client's model; this one stores L2 depth in a shape fixed in C++, so the
5
+ relationship inverts - either the client's model *is* that shape or the group cannot be placed here.
6
+ ``sde.ORDERBOOK_SHAPE`` is the shape and ``default_layout`` refuses anything else, naming the whole
7
+ expected shape so the refusal is actionable in one read.
8
+
9
+ Three differences from a general-purpose store are **named rather than smoothed over**, because each
10
+ one is a promise this engine does not make and a client planning around it needs to know which:
11
+
12
+ **No transactions.** ``transaction()`` refuses. One group is one engine's transaction semantics, so
13
+ a client who declared ``atomic_with`` gets this engine excluded at planning time rather than
14
+ discovering it here - but the refusal exists anyway, because a context manager that silently did
15
+ nothing would turn a declared atomicity requirement into a comment.
16
+
17
+ **No key enforcement, and no way to get it.** Two writes with the same
18
+ ``(symbol, exchange, timestamp_ns, side, level)`` both persist - measured, with the same
19
+ ``sequence_number`` too. That is not a gap to work around: the engine is an append-only log of depth
20
+ updates, which is what makes it fast. ClickHouse has the same absence and a way out (``FINAL`` over
21
+ ``ReplacingMergeTree``); here there is none, so :meth:`get` **refuses** when a key matches more than
22
+ one row rather than picking one. Returning either would be a read that lies about uniqueness, and
23
+ the condition can only arise from a key violation this engine could not have prevented.
24
+
25
+ **Writes are updates of N levels, not rows.** ``level`` is not a parameter of the engine's write API
26
+ - it is the index of a price within one update. So a single-row insert can only ever produce ``level
27
+ = 0``, and :meth:`insert` refuses any other value rather than writing it to 0 and letting the read
28
+ disagree with the write. The engine's real granularity is available as :meth:`insert_levels`, in the
29
+ same spirit as PostgreSQL's ``range`` and ``count``: beyond the session protocol, because it is
30
+ beyond what the protocol can say.
31
+
32
+ One more property that is a cost rather than a refusal. A write is invisible to a query until
33
+ ``flush()``, and ``flush()`` in local mode tears the engine down and reopens it - **3.4 ms
34
+ measured** on an i3-7100U with one row. So reads flush lazily, only when there is something
35
+ unflushed, and the cost lands on the first read after a write rather than on every write. The
36
+ alternative - not flushing and returning nothing for a row that was just written - is a silent wrong
37
+ answer, which is never the cheaper option.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from collections.abc import Iterator, Mapping, Sequence
43
+ from typing import Any
44
+
45
+ from ..errors import EngineError
46
+ from ..explain import QueryPlan
47
+ from ..layout import ORDERBOOK_KEY, ORDERBOOK_SHAPE, ORDERBOOK_TABLE
48
+ from ..logging import log
49
+ from ..placement import PhysicalLayout
50
+
51
+ __all__ = ["OrderbookEngine"]
52
+
53
+ SIDES = ("ask", "bid")
54
+ """The two values ``side`` may take. Sorted, so the error message is stable."""
55
+
56
+ _UNKNOWN_SEQUENCE = 0
57
+ """What the engine returns when it has no sequence number for a row.
58
+
59
+ A safe sentinel there - its own numbering starts at 1, so 0 is unreachable as a real value - and not
60
+ safe here, because a client comparing sequence numbers cannot tell a sentinel from a datum.
61
+ Converted to ``None`` on the way out, which is the same rule the rest of this library follows:
62
+ unknown is not zero, and flattening the two leads to opposite decisions.
63
+ """
64
+
65
+
66
+ def _quote_literal(value: str) -> str:
67
+ """A single-quoted string literal for the engine's query language.
68
+
69
+ Not in ``sde.schema.QUOTE``, and deliberately: that maps dialects to *identifier* quoting, and
70
+ this engine has no identifier of ours to escape. ``symbol`` and ``exchange`` arrive in the FROM
71
+ clause as literals.
72
+
73
+ A symbol containing a quote is refused rather than escaped. The engine's tokeniser has no escape
74
+ sequence inside a string literal, so there is nothing to escape *to* - and a doubled quote,
75
+ which is what one would reach for, would silently address a different symbol.
76
+ """
77
+ if "'" in value or "\\" in value or "\n" in value:
78
+ raise EngineError(
79
+ f"{value!r} cannot be used as a symbol or exchange: this engine's query language has "
80
+ f"no escape sequence inside a string literal, so a quote or a backslash cannot be "
81
+ f"expressed. Refused rather than escaped, because the escaping one would reach for "
82
+ f"would address a different symbol without saying so."
83
+ )
84
+ return f"'{value}'"
85
+
86
+
87
+ class OrderbookEngine:
88
+ """A thin adapter over the orderbook engine's Python client.
89
+
90
+ Local mode takes a data directory and talks to the shared library in-process; TCP mode takes a
91
+ host and a port. Both are the client's own deployment: this library connects, the control plane
92
+ never does.
93
+ """
94
+
95
+ dialect = "orderbook"
96
+
97
+ def __init__(
98
+ self,
99
+ data_dir: str | None = None,
100
+ *,
101
+ host: str | None = None,
102
+ port: int | None = None,
103
+ ) -> None:
104
+ if (data_dir is None) == (host is None):
105
+ raise EngineError(
106
+ "give either a data directory, for in-process access through the shared library, "
107
+ "or a host and port, for a running ob_tcp_server. Not both and not neither: the "
108
+ "two are different deployments with different durability, and defaulting to one of "
109
+ "them would pick a durability guarantee on the client's behalf."
110
+ )
111
+ if host is not None and port is None:
112
+ raise EngineError("a host needs a port; this engine has no default port worth guessing")
113
+ self._data_dir = data_dir
114
+ self._host = host
115
+ self._port = port
116
+ self._engine: Any = None
117
+ self._unflushed = 0
118
+
119
+ # --- connection ------------------------------------------------------------------------
120
+
121
+ def connect(self) -> None:
122
+ if self._engine is not None:
123
+ return
124
+ try:
125
+ import orderbook_engine
126
+ except ImportError as exc: # pragma: no cover - depends on a separate install
127
+ raise EngineError(
128
+ "the orderbook adapter needs the engine's own Python client, which is not on PyPI: "
129
+ "install it from https://github.com/Smart-Data-Engines/"
130
+ "low-cost-and-low-latency-orderbook-dbengine (its `python/` directory) and point "
131
+ "OB_LIB_PATH at liborderbook_shared.so. It is not declared as an extra here "
132
+ "because an extra resolving to a git URL cannot be published, and a dependency you "
133
+ "cannot install from an index is worse than one you were told about."
134
+ ) from exc
135
+ try:
136
+ if self._data_dir is not None:
137
+ self._engine = orderbook_engine.OrderbookEngine(self._data_dir)
138
+ else:
139
+ self._engine = orderbook_engine.OrderbookEngine(host=self._host, port=self._port)
140
+ except Exception as exc:
141
+ raise EngineError(f"could not open the orderbook engine: {exc}") from exc
142
+
143
+ def close(self) -> None:
144
+ if self._engine is not None:
145
+ self._engine.close()
146
+ self._engine = None
147
+ self._unflushed = 0
148
+
149
+ def __enter__(self) -> OrderbookEngine:
150
+ self.connect()
151
+ return self
152
+
153
+ def __exit__(self, *_: object) -> None:
154
+ self.close()
155
+
156
+ @property
157
+ def _ob(self) -> Any:
158
+ if self._engine is None:
159
+ raise EngineError("not connected; call connect() first")
160
+ return self._engine
161
+
162
+ # --- schema ----------------------------------------------------------------------------
163
+
164
+ def ensure_schema(self, layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]]) -> None:
165
+ """Verify, because there is nothing to create.
166
+
167
+ The storage exists the moment the engine opens its data directory. What can still be wrong
168
+ is the map: a document built for another engine, or for a model that is not this shape,
169
+ would route writes here and fail on the first one. So this checks the layout against the
170
+ fixed shape and against the key, and refuses before a single row is written.
171
+
172
+ Checked here as well as in ``default_layout`` on purpose. That function is ours and runs
173
+ where the map is built; this one runs in the client's process against the document they
174
+ actually hold, which is the only place a map built by an older version of us gets caught.
175
+ """
176
+ entities = sorted(layout.tables)
177
+ if len(entities) != 1:
178
+ raise EngineError(
179
+ f"this engine stores one thing and the map gives it {entities}. A colocation group "
180
+ f"is what shares an engine, so a group of two cannot be placed here."
181
+ )
182
+ entity = entities[0]
183
+ table = layout.tables[entity]
184
+ if table != ORDERBOOK_TABLE:
185
+ raise EngineError(
186
+ f"the map calls the table {table!r} and this engine's storage is "
187
+ f"{ORDERBOOK_TABLE!r}. The name is the engine's, not ours: there is no CREATE "
188
+ f"TABLE to send it, so a map naming something else was built for another engine."
189
+ )
190
+
191
+ declared = dict(layout.columns.get(entity, {}))
192
+ expected_names = set(ORDERBOOK_SHAPE)
193
+ missing = sorted(expected_names - set(declared))
194
+ extra = sorted(set(declared) - expected_names)
195
+ if missing or extra:
196
+ raise EngineError(
197
+ f"the map's layout for {entity} does not match this engine's fixed shape: "
198
+ f"{f'missing {missing}' if missing else ''}"
199
+ f"{'; ' if missing and extra else ''}"
200
+ f"{f'unexpected {extra}' if extra else ''}. The shape is fixed in the engine and "
201
+ f"the whole of it is {sorted(ORDERBOOK_SHAPE)}."
202
+ )
203
+
204
+ key = tuple(keys.get(entity, ()))
205
+ if key != ORDERBOOK_KEY:
206
+ raise EngineError(
207
+ f"the map keys {entity} by {list(key)} and this engine addresses rows by "
208
+ f"{list(ORDERBOOK_KEY)}. The order is positional and it is load-bearing: the "
209
+ f"symbol and the exchange are how a query reaches the data at all."
210
+ )
211
+ log("sde.schema.applied", engine=self.dialect, statements=0)
212
+
213
+ # --- data ------------------------------------------------------------------------------
214
+
215
+ def explain_plan(self, sql: str) -> QueryPlan:
216
+ """Refuses. There is no query planner here, and that is a property of the engine.
217
+
218
+ Requirement 19.4 asks for an execution plan and a cost estimate from a live engine. This
219
+ engine has neither to give, and the reason is the same one that makes it worth having: it
220
+ was built for one access path over one fixed shape, so there is nothing for a planner to
221
+ choose between and no alternative whose cost would need estimating. A plan saying "read the
222
+ book for this symbol" would be true, uninformative, and the fourth thing in this adapter
223
+ that pretends a difference away.
224
+
225
+ Named rather than returned empty. An empty plan reads as "nothing to worry about", which
226
+ is a stronger claim than this adapter can make about a query it cannot see.
227
+ """
228
+ raise EngineError(
229
+ "the orderbook engine has no query planner, so there is no plan and no cost estimate "
230
+ "to give you. That is what it is for: one fixed shape and one access path, so nothing "
231
+ "is chosen at query time and nothing needs estimating. Refused rather than answered "
232
+ "with an empty plan, because an empty plan reads as 'nothing to worry about'. This is "
233
+ "the fourth named difference in this adapter - no transactions, no key enforcement, "
234
+ "writes as level updates - and it is named for the same reason as the other three."
235
+ )
236
+
237
+ def insert(self, table: str, values: Mapping[str, Any]) -> None:
238
+ """One depth level, at level 0, or a refusal.
239
+
240
+ ``level`` is not something the engine's write API accepts - it is the index of a price
241
+ inside one update - so a row declaring level 3 would be stored at 0 and read back at 0.
242
+ Refused rather than written, because a write the read disagrees with is the one failure a
243
+ storage adapter must never produce quietly. :meth:`insert_levels` writes an update of
244
+ several levels, which is the granularity the engine actually has.
245
+ """
246
+ missing = sorted(set(ORDERBOOK_SHAPE) - set(values))
247
+ if missing:
248
+ raise EngineError(
249
+ f"insert into {table} is missing {missing}. Every field of the fixed shape is "
250
+ f"required: this engine has no defaults to fall back on and no nullable columns "
251
+ f"except the sequence number."
252
+ )
253
+ level = int(values["level"])
254
+ if level != 0:
255
+ raise EngineError(
256
+ f"insert into {table} declares level {level}, and this engine's write API has no "
257
+ f"level parameter - a price's level is its index within one update. Writing this "
258
+ f"would store it at level 0 and the read would disagree with the write. Use "
259
+ f"insert_levels() to write an update of several levels, which is the granularity "
260
+ f"this engine has."
261
+ )
262
+ self.insert_levels(
263
+ table,
264
+ symbol=str(values["symbol"]),
265
+ exchange=str(values["exchange"]),
266
+ side=str(values["side"]),
267
+ timestamp_ns=int(values["timestamp_ns"]),
268
+ levels=((int(values["price"]), int(values["quantity"]), int(values["order_count"])),),
269
+ sequence_number=(
270
+ None if values.get("sequence_number") is None else int(values["sequence_number"])
271
+ ),
272
+ )
273
+
274
+ def insert_levels(
275
+ self,
276
+ table: str,
277
+ *,
278
+ symbol: str,
279
+ exchange: str,
280
+ side: str,
281
+ timestamp_ns: int,
282
+ levels: Sequence[tuple[int, int, int]],
283
+ sequence_number: int | None = None,
284
+ ) -> None:
285
+ """One update: N levels of one side of one book at one instant, in order.
286
+
287
+ ``levels`` is (price, quantity, order_count) from the top of the book down, and the position
288
+ in that sequence *is* the level. Beyond the session protocol, because the protocol speaks in
289
+ rows and this engine's unit of work is an update - the same reason PostgreSQL's adapter has
290
+ ``range`` and ``count`` here rather than in the protocol.
291
+ """
292
+ if table != ORDERBOOK_TABLE:
293
+ raise EngineError(f"this engine has one table, {ORDERBOOK_TABLE!r}, not {table!r}")
294
+ if side not in SIDES:
295
+ raise EngineError(f"side must be one of {list(SIDES)}, not {side!r}")
296
+ if not levels:
297
+ raise EngineError(
298
+ "an update with no levels is not an empty update, it is a write that would report "
299
+ "success without storing anything"
300
+ )
301
+ _quote_literal(symbol)
302
+ _quote_literal(exchange)
303
+ prices = [price for price, _, _ in levels]
304
+ quantities = [quantity for _, quantity, _ in levels]
305
+ counts = [count for _, _, count in levels]
306
+ try:
307
+ self._ob.insert(
308
+ symbol,
309
+ exchange,
310
+ side,
311
+ prices,
312
+ quantities,
313
+ counts,
314
+ timestamp_ns=timestamp_ns,
315
+ seq=sequence_number,
316
+ )
317
+ except Exception as exc:
318
+ # Surfaced, not swallowed and not rerouted, exactly as in the PostgreSQL adapter: a
319
+ # write that did not happen is not our internal problem.
320
+ log("sde.write.failed", table=table, error=type(exc).__name__)
321
+ raise EngineError(f"insert into {table} failed: {exc}") from exc
322
+ self._unflushed += len(levels)
323
+
324
+ def flush(self) -> None:
325
+ """Make everything written so far queryable.
326
+
327
+ Exposed because the cost is real and a client ingesting a feed wants to decide when to pay
328
+ it. Reads call it themselves when there is something unflushed, so correctness does not
329
+ depend on anybody remembering.
330
+ """
331
+ if self._unflushed == 0:
332
+ return
333
+ try:
334
+ self._ob.flush()
335
+ except Exception as exc:
336
+ raise EngineError(f"flush failed: {exc}") from exc
337
+ log("sde.orderbook.flushed", rows=self._unflushed)
338
+ self._unflushed = 0
339
+
340
+ def get(self, table: str, key: Mapping[str, Any]) -> dict[str, Any] | None:
341
+ """One row by key, ``None`` if there is none, and a refusal if there are two.
342
+
343
+ The refusal is the interesting half. This engine does not enforce the key - two writes with
344
+ the same one both persist, measured - so "fetch the row with this key" is a question it can
345
+ answer with more than one row. Returning either would be a read that lies about uniqueness,
346
+ and the client cannot see that it happened. The condition arises only from a key violation
347
+ the engine could not have prevented, so the honest response is to say so.
348
+ """
349
+ missing = sorted(set(ORDERBOOK_KEY) - set(key))
350
+ if missing:
351
+ raise EngineError(
352
+ f"get from {table} is missing {missing} from the key. This engine addresses rows "
353
+ f"by {list(ORDERBOOK_KEY)} and cannot scan for a partial one: the symbol and the "
354
+ f"exchange are how a query reaches the data at all."
355
+ )
356
+ timestamp = int(key["timestamp_ns"])
357
+ rows = [
358
+ row
359
+ for row in self.levels(
360
+ symbol=str(key["symbol"]),
361
+ exchange=str(key["exchange"]),
362
+ start_ns=timestamp,
363
+ end_ns=timestamp,
364
+ )
365
+ if row["side"] == key["side"] and row["level"] == int(key["level"])
366
+ ]
367
+ if not rows:
368
+ return None
369
+ if len(rows) > 1:
370
+ raise EngineError(
371
+ f"{len(rows)} rows in {table} share the key "
372
+ f"{ {name: key[name] for name in ORDERBOOK_KEY} }. This engine is an append-only "
373
+ f"log of depth updates and does not enforce a key, so this is a key violation it "
374
+ f"could not have prevented. Refused rather than answered with one of them: picking "
375
+ f"either would be a read that lies about uniqueness, and you would not see it."
376
+ )
377
+ return rows[0]
378
+
379
+ def levels(
380
+ self,
381
+ *,
382
+ symbol: str,
383
+ exchange: str,
384
+ start_ns: int | None = None,
385
+ end_ns: int | None = None,
386
+ limit: int | None = None,
387
+ ) -> list[dict[str, Any]]:
388
+ """Every stored level for one book, optionally within a timestamp range.
389
+
390
+ The symbol and the exchange are not optional and cannot be. The engine's query language
391
+ takes them in the FROM clause, so there is no such thing as a scan across books here - which
392
+ is a property of an engine built for one workload, not a limitation to route around.
393
+
394
+ ``end_ns`` is **inclusive**, because the engine's ``BETWEEN`` is, and translating a
395
+ half-open range into it would need an off-by-one that only shows up at the boundary.
396
+ """
397
+ self.flush()
398
+ where = ""
399
+ if start_ns is not None or end_ns is not None:
400
+ low = 0 if start_ns is None else start_ns
401
+ # The engine's BETWEEN takes two uint64s, so "no upper bound" has to be a number.
402
+ # The largest uint64 rather than a large-looking constant: a timestamp past it cannot
403
+ # exist in a field that holds it.
404
+ high = (1 << 64) - 1 if end_ns is None else end_ns
405
+ where = f" WHERE timestamp BETWEEN {low} AND {high}"
406
+ cap = "" if limit is None else f" LIMIT {int(limit)}"
407
+ query = (
408
+ f"SELECT * FROM {_quote_literal(symbol)}.{_quote_literal(exchange)}{where}{cap}"
409
+ )
410
+ try:
411
+ rows = self._ob.query(query)
412
+ except Exception as exc:
413
+ raise EngineError(f"query failed: {query}: {exc}") from exc
414
+ return [
415
+ {
416
+ "symbol": symbol,
417
+ "exchange": exchange,
418
+ "timestamp_ns": int(row.timestamp_ns),
419
+ "side": str(row.side),
420
+ "level": int(row.level),
421
+ "price": int(row.price),
422
+ "quantity": int(row.quantity),
423
+ "order_count": int(row.order_count),
424
+ # Unknown, not zero. See _UNKNOWN_SEQUENCE.
425
+ "sequence_number": (
426
+ None
427
+ if int(row.sequence_number) == _UNKNOWN_SEQUENCE
428
+ else int(row.sequence_number)
429
+ ),
430
+ }
431
+ for row in rows
432
+ ]
433
+
434
+ # --- transactions ----------------------------------------------------------------------
435
+
436
+ def transaction(self) -> Iterator[OrderbookEngine]:
437
+ """Refused, out loud.
438
+
439
+ A context manager that silently did nothing would turn a declared atomicity requirement into
440
+ a comment. The planner excludes this engine from any group that declared ``atomic_with``, so
441
+ reaching here means the map and the model disagree - and that is worth an exception rather
442
+ than a shrug.
443
+
444
+ Undecorated, like the ClickHouse one and for the same reason: a decorated generator needs a
445
+ ``yield`` after the ``raise`` to keep the type honest, and that statement is unreachable -
446
+ ``mypy --strict`` says so, correctly. A plain method that raises fails one frame earlier.
447
+ """
448
+ raise EngineError(
449
+ "this engine has no multi-statement transactions, so there is nothing here to give "
450
+ "you. One group is one engine's transaction semantics: if two entities must change "
451
+ "together, declare that with atomic_with and the planner will place them somewhere "
452
+ "that can. Refused rather than quietly doing nothing, because a transaction that is "
453
+ "not one is worse than not having the method."
454
+ )