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.
- sde/__init__.py +226 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/engines/__init__.py +0 -0
- sde/engines/clickhouse.py +689 -0
- sde/engines/orderbook.py +454 -0
- sde/engines/postgres.py +672 -0
- sde/entity.py +170 -0
- sde/errors.py +88 -0
- sde/explain.py +300 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/infer.py +461 -0
- sde/internal.py +90 -0
- sde/layout.py +660 -0
- sde/logging.py +132 -0
- sde/migration.py +820 -0
- sde/model.py +482 -0
- sde/placement.py +818 -0
- sde/py.typed +0 -0
- sde/routing.py +85 -0
- sde/schema.py +370 -0
- sde/session.py +507 -0
- sde/shapes.py +153 -0
- sde/telemetry.py +736 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +318 -0
- sde/types.py +228 -0
- sde/watermark.py +222 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/METADATA +152 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/RECORD +35 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/NOTICE +13 -0
sde/engines/orderbook.py
ADDED
|
@@ -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
|
+
)
|