crowdbook 0.13.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christopher Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: crowdbook
3
+ Version: 0.13.0
4
+ Summary: Trade in a crowdbook market from Python, by hand or with a bot
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # crowdbook for Python
11
+
12
+ Trade in a market served by `crowdbook serve`, from Python, by hand or with a bot. The package
13
+ uses only the standard library and needs Python 3.11 or later.
14
+
15
+ ```bash
16
+ pip install ./clients/python
17
+ ```
18
+
19
+ A bot overrides the callbacks it needs and acts through its methods:
20
+
21
+ ```python
22
+ import crowdbook
23
+
24
+ class DipBuyer(crowdbook.Bot):
25
+ def on_trade(self, trade):
26
+ if trade.price < self.settings.reference_price - 5 and self.ledger.position < 10:
27
+ self.buy_market(1)
28
+
29
+ end = crowdbook.run(DipBuyer(), host="127.0.0.1", port=7878, seat="alice")
30
+ print(end.pnl)
31
+ ```
32
+
33
+ - **Callbacks:** `on_start`, `on_wakeup`, `on_trade`, `on_top`, `on_depth`, `on_phase`,
34
+ `on_indicative`, `on_accepted`, `on_rejected`, `on_modified`, `on_filled`, `on_cancelled`,
35
+ `on_error` and `on_end`, each with the message as it arrived.
36
+ - **Actions:** `buy` and `sell` at a limit price, with a time in force (`good-till-cancel`,
37
+ `immediate-or-cancel` or `post-only`), `buy_market` and `sell_market`, `cancel`, `cancel_all`
38
+ and `modify`. Orders are named by ids the bot gives them, so a bot can cancel an order before
39
+ it is acknowledged. `wake_after` asks for `on_wakeup` after a stretch of the market's time.
40
+ - **State:** `ledger` holds the seat's cash, position, fees and orders, built from what the bot
41
+ sent and heard back; `book` the best prices, the depth, the last trade and, in a market with a
42
+ trading day, the phase and, in an auction, the indicative price; `settings` the
43
+ market's length, reference price, fees, latency and the seat's limits; `now` the market's time
44
+ in nanoseconds.
45
+
46
+ Lower down, `crowdbook.Connection` sends and receives the protocol's messages, defined in
47
+ `crowdbook.protocol`; [docs/protocol.md](../../docs/protocol.md) specifies them.
48
+
49
+ [examples](examples) has a market maker, a trend follower and an execution algorithm that works a
50
+ challenge's target evenly over the session. Each runs against a served market:
51
+
52
+ ```bash
53
+ ./build/release/apps/crowdbook serve examples/scenarios/playable.toml --seat maker --seat trend
54
+ python clients/python/examples/market_maker.py --seat maker
55
+ python clients/python/examples/momentum.py --seat trend
56
+ ```
57
+
58
+ A seat in a scored challenge gets the challenge's name, briefing and scoring rules in
59
+ `settings.challenge` and `settings.scoring`, and its score at the end, in points, in the `End`
60
+ that `run` returns.
61
+
62
+ The tests run with `python -m unittest discover -s tests` from this directory; the ones that
63
+ trade against a real server need `CROWDBOOK` set to the `crowdbook` command, as CTest does.
@@ -0,0 +1,54 @@
1
+ # crowdbook for Python
2
+
3
+ Trade in a market served by `crowdbook serve`, from Python, by hand or with a bot. The package
4
+ uses only the standard library and needs Python 3.11 or later.
5
+
6
+ ```bash
7
+ pip install ./clients/python
8
+ ```
9
+
10
+ A bot overrides the callbacks it needs and acts through its methods:
11
+
12
+ ```python
13
+ import crowdbook
14
+
15
+ class DipBuyer(crowdbook.Bot):
16
+ def on_trade(self, trade):
17
+ if trade.price < self.settings.reference_price - 5 and self.ledger.position < 10:
18
+ self.buy_market(1)
19
+
20
+ end = crowdbook.run(DipBuyer(), host="127.0.0.1", port=7878, seat="alice")
21
+ print(end.pnl)
22
+ ```
23
+
24
+ - **Callbacks:** `on_start`, `on_wakeup`, `on_trade`, `on_top`, `on_depth`, `on_phase`,
25
+ `on_indicative`, `on_accepted`, `on_rejected`, `on_modified`, `on_filled`, `on_cancelled`,
26
+ `on_error` and `on_end`, each with the message as it arrived.
27
+ - **Actions:** `buy` and `sell` at a limit price, with a time in force (`good-till-cancel`,
28
+ `immediate-or-cancel` or `post-only`), `buy_market` and `sell_market`, `cancel`, `cancel_all`
29
+ and `modify`. Orders are named by ids the bot gives them, so a bot can cancel an order before
30
+ it is acknowledged. `wake_after` asks for `on_wakeup` after a stretch of the market's time.
31
+ - **State:** `ledger` holds the seat's cash, position, fees and orders, built from what the bot
32
+ sent and heard back; `book` the best prices, the depth, the last trade and, in a market with a
33
+ trading day, the phase and, in an auction, the indicative price; `settings` the
34
+ market's length, reference price, fees, latency and the seat's limits; `now` the market's time
35
+ in nanoseconds.
36
+
37
+ Lower down, `crowdbook.Connection` sends and receives the protocol's messages, defined in
38
+ `crowdbook.protocol`; [docs/protocol.md](../../docs/protocol.md) specifies them.
39
+
40
+ [examples](examples) has a market maker, a trend follower and an execution algorithm that works a
41
+ challenge's target evenly over the session. Each runs against a served market:
42
+
43
+ ```bash
44
+ ./build/release/apps/crowdbook serve examples/scenarios/playable.toml --seat maker --seat trend
45
+ python clients/python/examples/market_maker.py --seat maker
46
+ python clients/python/examples/momentum.py --seat trend
47
+ ```
48
+
49
+ A seat in a scored challenge gets the challenge's name, briefing and scoring rules in
50
+ `settings.challenge` and `settings.scoring`, and its score at the end, in points, in the `End`
51
+ that `run` returns.
52
+
53
+ The tests run with `python -m unittest discover -s tests` from this directory; the ones that
54
+ trade against a real server need `CROWDBOOK` set to the `crowdbook` command, as CTest does.
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "crowdbook"
3
+ version = "0.13.0"
4
+ description = "Trade in a crowdbook market from Python, by hand or with a bot"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = []
10
+
11
+ [build-system]
12
+ requires = ["uv_build>=0.8,<0.12"]
13
+ build-backend = "uv_build"
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "crowdbook"
3
+ version = "0.13.0"
4
+ description = "Trade in a crowdbook market from Python, by hand or with a bot"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = []
10
+
11
+ [build-system]
12
+ requires = ["uv_build>=0.8,<0.12"]
13
+ build-backend = "uv_build"
@@ -0,0 +1,15 @@
1
+ """A client for crowdbook: trade in a served market from Python.
2
+
3
+ import crowdbook
4
+
5
+ class Buyer(crowdbook.Bot):
6
+ def on_start(self):
7
+ self.buy_market(5)
8
+
9
+ crowdbook.run(Buyer(), port=7878, seat="alice")
10
+ """
11
+
12
+ from .client import Book, Bot, Connection, Ledger, Order, run
13
+ from .protocol import ProtocolError
14
+
15
+ __all__ = ["Book", "Bot", "Connection", "Ledger", "Order", "ProtocolError", "run"]
@@ -0,0 +1,334 @@
1
+ """Trading in a served crowdbook market: the connection, the seat's own books, and bots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import itertools
7
+ import select
8
+ import socket
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from . import protocol
13
+ from .protocol import (AUCTIONS, Accepted, Cancel, Cancelled, ClientMessage, Clock, Depth, End,
14
+ Error, Filled, Hello, Indicative, Level, Modified, Modify, NewOrder,
15
+ OrderEvent, PhaseChange, Rejected, ServerMessage, Side, Start,
16
+ TimeInForce, Top, Trade, Welcome)
17
+
18
+
19
+ @dataclass
20
+ class Order:
21
+ """One of the seat's own orders, as the client knows it."""
22
+
23
+ id: int
24
+ side: Side
25
+ order_type: str
26
+ price: Optional[int]
27
+ leaves: int # open quantity as last reported; the full size until acknowledged
28
+ order_id: int = 0 # the exchange's id, 0 until acknowledged
29
+ acknowledged: bool = False
30
+ cancel_requested: bool = False
31
+
32
+
33
+ class Ledger:
34
+ """The seat's cash, position, fees and orders, built only from the requests the client sent
35
+ and the order events it received. With latency it lags the exchange: orders are in flight
36
+ until acknowledged, and a cancel can cross a fill."""
37
+
38
+ def __init__(self, cash: int = 0, position: int = 0, fees: int = 0) -> None:
39
+ self.cash = cash
40
+ self.position = position
41
+ self.fees = fees # in thousandths of a tick-lot, net of rebates
42
+ self.orders: dict[int, Order] = {} # open or in flight, by the client's id
43
+
44
+ def record(self, message: ClientMessage) -> None:
45
+ """Records a request the client is sending. Raises ValueError for a new order whose id
46
+ is already live."""
47
+ if isinstance(message, NewOrder):
48
+ if message.id in self.orders:
49
+ raise ValueError(f"order id {message.id} is already live")
50
+ self.orders[message.id] = Order(id=message.id, side=message.side,
51
+ order_type=message.order_type, price=message.price,
52
+ leaves=message.quantity)
53
+ elif isinstance(message, Cancel) and message.id in self.orders:
54
+ self.orders[message.id].cancel_requested = True
55
+ # A modify changes nothing until the exchange confirms it.
56
+
57
+ def apply(self, event: OrderEvent) -> None:
58
+ """Applies one of the seat's order events. Raises KeyError for an event about an order
59
+ the client never sent."""
60
+ if isinstance(event, Accepted):
61
+ order = self.orders[event.id]
62
+ order.acknowledged = True
63
+ order.order_id = event.order_id
64
+ elif isinstance(event, Rejected):
65
+ if event.request == "new":
66
+ del self.orders[event.id]
67
+ elif event.request == "cancel" and event.id in self.orders:
68
+ # The cancel failed; an order that is gone finished while it was on its way.
69
+ self.orders[event.id].cancel_requested = False
70
+ elif isinstance(event, Modified):
71
+ order = self.orders[event.id]
72
+ order.price = event.price
73
+ order.leaves = event.quantity
74
+ elif isinstance(event, Filled):
75
+ order = self.orders[event.id]
76
+ sign = 1 if event.side == "buy" else -1
77
+ self.position += sign * event.quantity
78
+ self.cash -= sign * event.price * event.quantity
79
+ self.fees += event.fee
80
+ order.leaves = event.leaves
81
+ if order.leaves == 0:
82
+ del self.orders[event.id]
83
+ elif isinstance(event, Cancelled):
84
+ del self.orders[event.id]
85
+
86
+ def open_quantity(self, side: Side) -> int:
87
+ """Open quantity on one side, including orders still in flight."""
88
+ return sum(order.leaves for order in self.orders.values() if order.side == side)
89
+
90
+
91
+ class Book:
92
+ """The public market as the client has heard it: best prices, depth, the last trade, and in
93
+ a market with a trading day its phase and, during an auction, the indicative price."""
94
+
95
+ def __init__(self) -> None:
96
+ self.bid: Optional[Level] = None
97
+ self.ask: Optional[Level] = None
98
+ self.bids: tuple[Level, ...] = () # best first; empty without a depth feed
99
+ self.asks: tuple[Level, ...] = ()
100
+ self.last_price: Optional[int] = None
101
+ self.trades = 0
102
+ self.volume = 0
103
+ self.phase = "continuous"
104
+ self.indicative: Optional[Indicative] = None
105
+
106
+ @property
107
+ def in_auction(self) -> bool:
108
+ return self.phase in AUCTIONS
109
+
110
+ def apply(self, message: ServerMessage) -> None:
111
+ if isinstance(message, PhaseChange):
112
+ self.phase = message.phase
113
+ if not self.in_auction:
114
+ self.indicative = None
115
+ elif isinstance(message, Indicative):
116
+ self.indicative = message if message.price is not None else None
117
+ elif isinstance(message, Trade):
118
+ self.last_price = message.price
119
+ self.trades += 1
120
+ self.volume += message.quantity
121
+ elif isinstance(message, Top):
122
+ self.bid, self.ask = message.bid, message.ask
123
+ elif isinstance(message, Depth):
124
+ self.bids, self.asks = message.bids, message.asks
125
+
126
+ @property
127
+ def mid(self) -> Optional[float]:
128
+ if self.bid is None or self.ask is None:
129
+ return None
130
+ return (self.bid.price + self.ask.price) / 2
131
+
132
+
133
+ class Connection:
134
+ """A TCP connection to a crowdbook server that sends and receives whole messages."""
135
+
136
+ def __init__(self, host: str = "127.0.0.1", port: int = 7878, timeout: float = 10.0) -> None:
137
+ self._socket = socket.create_connection((host, port), timeout=timeout)
138
+ self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
139
+ self._buffer = b""
140
+ self.closed = False
141
+
142
+ def send(self, message: ClientMessage) -> bool:
143
+ """Sends a message. Returns False, and marks the connection closed, if the server has
144
+ gone."""
145
+ if self.closed:
146
+ return False
147
+ try:
148
+ self._socket.sendall(protocol.encode(message).encode("ascii"))
149
+ except (BrokenPipeError, ConnectionResetError):
150
+ self.closed = True
151
+ return False
152
+ return True
153
+
154
+ def receive(self, timeout: float) -> list[ServerMessage]:
155
+ """The messages that arrive within `timeout` seconds, as soon as there is at least one;
156
+ none if the time runs out or the server has closed the connection."""
157
+ messages: list[ServerMessage] = []
158
+ while not messages and not self.closed:
159
+ ready, _, _ = select.select([self._socket], [], [], timeout)
160
+ if not ready:
161
+ break
162
+ try:
163
+ chunk = self._socket.recv(65536)
164
+ except ConnectionResetError:
165
+ chunk = b""
166
+ if not chunk:
167
+ self.closed = True
168
+ break
169
+ self._buffer += chunk
170
+ *lines, self._buffer = self._buffer.split(b"\n")
171
+ messages.extend(protocol.decode(line.decode("ascii")) for line in lines)
172
+ return messages
173
+
174
+ def close(self) -> None:
175
+ self._socket.close()
176
+ self.closed = True
177
+
178
+
179
+ class Bot:
180
+ """A trading program for one seat. Override the callbacks you need and act through the
181
+ methods; `run` connects it and calls them as messages arrive.
182
+
183
+ Times are the market's simulated nanoseconds. Each callback runs after the message has been
184
+ applied, so `ledger` and `book` already include it."""
185
+
186
+ def __init__(self) -> None:
187
+ self.ledger = Ledger()
188
+ self.book = Book()
189
+ self.settings: Optional[Welcome] = None
190
+ self.now = 0
191
+ self.started = False
192
+ self.ended: Optional[End] = None
193
+ self._connection: Optional[Connection] = None
194
+ self._next_id = 1
195
+ self._timers: list[tuple[int, int, object]] = []
196
+ self._sequence = itertools.count()
197
+
198
+ # Callbacks.
199
+
200
+ def on_start(self) -> None: ...
201
+ def on_wakeup(self, tag: object) -> None: ...
202
+ def on_trade(self, trade: Trade) -> None: ...
203
+ def on_top(self, top: Top) -> None: ...
204
+ def on_depth(self, depth: Depth) -> None: ...
205
+ def on_phase(self, phase: PhaseChange) -> None: ...
206
+ def on_indicative(self, indicative: Indicative) -> None: ...
207
+ def on_accepted(self, event: Accepted) -> None: ...
208
+ def on_rejected(self, event: Rejected) -> None: ...
209
+ def on_modified(self, event: Modified) -> None: ...
210
+ def on_filled(self, event: Filled) -> None: ...
211
+ def on_cancelled(self, event: Cancelled) -> None: ...
212
+ def on_error(self, error: Error) -> None: ...
213
+ def on_end(self, end: End) -> None: ...
214
+
215
+ # Actions. Each returns the id that names the order in later events.
216
+
217
+ def buy(self, price: int, quantity: int,
218
+ time_in_force: TimeInForce = "good-till-cancel") -> int:
219
+ return self._submit("buy", "limit", quantity, price, time_in_force)
220
+
221
+ def sell(self, price: int, quantity: int,
222
+ time_in_force: TimeInForce = "good-till-cancel") -> int:
223
+ return self._submit("sell", "limit", quantity, price, time_in_force)
224
+
225
+ def buy_market(self, quantity: int) -> int:
226
+ return self._submit("buy", "market", quantity, None, None)
227
+
228
+ def sell_market(self, quantity: int) -> int:
229
+ return self._submit("sell", "market", quantity, None, None)
230
+
231
+ def cancel(self, order_id: int) -> None:
232
+ self._send(Cancel(id=order_id))
233
+
234
+ def cancel_all(self) -> None:
235
+ """Cancels every open order whose cancel has not been asked for already."""
236
+ for order in list(self.ledger.orders.values()):
237
+ if not order.cancel_requested:
238
+ self.cancel(order.id)
239
+
240
+ def modify(self, order_id: int, price: int, quantity: int) -> None:
241
+ self._send(Modify(id=order_id, price=price, quantity=quantity))
242
+
243
+ def wake_after(self, delay: int, tag: object = None) -> None:
244
+ """Asks for on_wakeup once the market's time has moved on by `delay` nanoseconds. Wakeups
245
+ happen as messages bring news of the time, about ten times a second at the least."""
246
+ heapq.heappush(self._timers, (self.now + delay, next(self._sequence), tag))
247
+
248
+ # Running.
249
+
250
+ def _submit(self, side: Side, order_type: str, quantity: int, price: Optional[int],
251
+ time_in_force: Optional[TimeInForce]) -> int:
252
+ order = NewOrder(id=self._next_id, side=side, order_type=order_type, # type: ignore
253
+ quantity=quantity, price=price, time_in_force=time_in_force)
254
+ self._send(order)
255
+ self._next_id += 1
256
+ return order.id
257
+
258
+ def _send(self, message: ClientMessage) -> None:
259
+ if self._connection is None or not self.started or self.ended is not None:
260
+ raise RuntimeError("orders can only be sent while the market is open")
261
+ # Once the server has gone nothing more reaches the market, so nothing is recorded.
262
+ if self._connection.send(message):
263
+ self.ledger.record(message)
264
+
265
+ def _dispatch(self, message: ServerMessage) -> None:
266
+ if isinstance(message, Welcome):
267
+ self.settings = message
268
+ account = message.account
269
+ self.ledger = Ledger(account.cash, account.position, account.fees)
270
+ for order in message.orders:
271
+ self.ledger.orders[order.id] = Order(
272
+ id=order.id, side=order.side, order_type=order.order_type, price=order.price,
273
+ leaves=order.leaves, order_id=order.order_id,
274
+ acknowledged=order.acknowledged, cancel_requested=order.cancel_requested)
275
+ self._next_id = max(self._next_id, order.id + 1)
276
+ self.now = message.time
277
+ self.book.phase = message.phase
278
+ if message.started:
279
+ self.started = True
280
+ self.on_start()
281
+ return
282
+ if isinstance(message, Error):
283
+ self.on_error(message)
284
+ return
285
+ self.now = message.time
286
+ if isinstance(message, Start):
287
+ self.started = True
288
+ self.on_start()
289
+ elif isinstance(message, End):
290
+ self.ended = message
291
+ self.on_end(message)
292
+ return
293
+ elif isinstance(message, (Accepted, Rejected, Modified, Filled, Cancelled)):
294
+ self.ledger.apply(message)
295
+ handler = {Accepted: self.on_accepted, Rejected: self.on_rejected,
296
+ Modified: self.on_modified, Filled: self.on_filled,
297
+ Cancelled: self.on_cancelled}[type(message)]
298
+ handler(message) # type: ignore[operator]
299
+ elif isinstance(message, (Trade, Top, Depth, PhaseChange, Indicative)):
300
+ self.book.apply(message)
301
+ if isinstance(message, Trade):
302
+ self.on_trade(message)
303
+ elif isinstance(message, Top):
304
+ self.on_top(message)
305
+ elif isinstance(message, Depth):
306
+ self.on_depth(message)
307
+ elif isinstance(message, PhaseChange):
308
+ self.on_phase(message)
309
+ else:
310
+ self.on_indicative(message)
311
+ while self._timers and self._timers[0][0] <= self.now and self.ended is None:
312
+ _, _, tag = heapq.heappop(self._timers)
313
+ self.on_wakeup(tag)
314
+
315
+
316
+ def run(bot: Bot, host: str = "127.0.0.1", port: int = 7878, seat: str = "you",
317
+ token: Optional[str] = None) -> Optional[End]:
318
+ """Connects the bot to a server, claims the seat and runs the bot until the session ends.
319
+ Returns the seat's results, or None if the connection closed first. Raises ConnectionError if
320
+ the server turns the seat away."""
321
+ connection = Connection(host, port)
322
+ bot._connection = connection
323
+ try:
324
+ connection.send(Hello(seat=seat, token=token))
325
+ while bot.ended is None and not connection.closed:
326
+ for message in connection.receive(timeout=1.0):
327
+ bot._dispatch(message)
328
+ if isinstance(message, Error) and message.fatal:
329
+ raise ConnectionError(f"the server closed the connection: {message.message}")
330
+ if bot.ended is not None:
331
+ break
332
+ return bot.ended
333
+ finally:
334
+ connection.close()
@@ -0,0 +1,392 @@
1
+ """The messages of the crowdbook protocol and their encoding as JSON lines.
2
+
3
+ docs/protocol.md in the crowdbook repository specifies them. Client messages are encoded exactly
4
+ as the server expects; server messages are decoded leniently, ignoring fields this version does
5
+ not know, so that a newer server can add fields without breaking older clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from typing import Literal, Optional, Union
13
+
14
+ VERSION = 1
15
+
16
+ Side = Literal["buy", "sell"]
17
+ OrderType = Literal["limit", "market"]
18
+ TimeInForce = Literal["good-till-cancel", "immediate-or-cancel", "post-only"]
19
+
20
+ SIDES = ("buy", "sell")
21
+ ORDER_TYPES = ("limit", "market")
22
+ TIMES_IN_FORCE = ("good-till-cancel", "immediate-or-cancel", "post-only")
23
+
24
+
25
+ class ProtocolError(ValueError):
26
+ """A line that is not a message this client understands."""
27
+
28
+
29
+ # Client messages.
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Hello:
34
+ seat: str
35
+ token: Optional[str] = None
36
+ protocol: int = VERSION
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class NewOrder:
41
+ id: int
42
+ side: Side
43
+ order_type: OrderType
44
+ quantity: int
45
+ price: Optional[int] = None # limit orders only
46
+ time_in_force: Optional[TimeInForce] = None # limit orders only
47
+ parent: Optional[int] = None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Cancel:
52
+ id: int
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Modify:
57
+ id: int
58
+ price: int
59
+ quantity: int
60
+
61
+
62
+ ClientMessage = Union[Hello, NewOrder, Cancel, Modify]
63
+
64
+
65
+ def encode(message: ClientMessage) -> str:
66
+ """One line of JSON, ending in a newline."""
67
+ if isinstance(message, Hello):
68
+ body: dict = {"type": "hello", "protocol": message.protocol, "seat": message.seat}
69
+ if message.token is not None:
70
+ body["token"] = message.token
71
+ elif isinstance(message, NewOrder):
72
+ if message.side not in SIDES or message.order_type not in ORDER_TYPES:
73
+ raise ValueError(f"not an order: {message}")
74
+ body = {"type": "new", "id": message.id, "side": message.side,
75
+ "order_type": message.order_type}
76
+ if message.order_type == "limit":
77
+ if message.price is None:
78
+ raise ValueError("a limit order needs a price")
79
+ time_in_force = message.time_in_force or "good-till-cancel"
80
+ if time_in_force not in TIMES_IN_FORCE:
81
+ raise ValueError(f"no time in force '{time_in_force}'")
82
+ body["time_in_force"] = time_in_force
83
+ body["price"] = message.price
84
+ elif message.price is not None or message.time_in_force is not None:
85
+ raise ValueError("a market order has no price and no time in force")
86
+ body["quantity"] = message.quantity
87
+ if message.parent is not None:
88
+ body["parent"] = message.parent
89
+ elif isinstance(message, Cancel):
90
+ body = {"type": "cancel", "id": message.id}
91
+ elif isinstance(message, Modify):
92
+ body = {"type": "modify", "id": message.id, "price": message.price,
93
+ "quantity": message.quantity}
94
+ else:
95
+ raise TypeError(f"not a client message: {message!r}")
96
+ return json.dumps(body, separators=(",", ":")) + "\n"
97
+
98
+
99
+ # Server messages.
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Level:
104
+ price: int
105
+ quantity: int
106
+ orders: int
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Latency:
111
+ to_exchange: int
112
+ from_exchange: int
113
+ jitter: int
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Account:
118
+ initial_cash: int
119
+ initial_position: int
120
+ cash: int
121
+ position: int
122
+ fees: int
123
+ max_position: int
124
+ max_order_quantity: int
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class OpenOrder:
129
+ id: int
130
+ order_id: int
131
+ side: Side
132
+ order_type: OrderType
133
+ price: int
134
+ leaves: int
135
+ acknowledged: bool
136
+ cancel_requested: bool
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Challenge:
141
+ name: str
142
+ briefing: str
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class TargetRule:
147
+ """A large order the seat is asked to work."""
148
+
149
+ side: Side
150
+ quantity: int
151
+ benchmark: Literal["vwap", "reference"]
152
+ unfinished_penalty: int # points per lot not done
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Scoring:
157
+ """How the seat is scored. Amounts are in points, thousandths of a tick-lot."""
158
+
159
+ mark: Literal["last", "value", "book"]
160
+ inventory_penalty: int # per lot per second held
161
+ close_penalty: int # per lot held at the end
162
+ max_loss: int # in tick-lots; 0 for no limit
163
+ target: Optional[TargetRule] = None
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class Score:
168
+ """A seat's score and its parts, in points: total = pnl - inventory - close - paper -
169
+ unfinished."""
170
+
171
+ total: int
172
+ pnl: int
173
+ inventory: int
174
+ close: int
175
+ paper: int
176
+ unfinished: int
177
+ unfinished_lots: int
178
+ stopped_at: Optional[int] = None
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class Welcome:
183
+ protocol: int
184
+ seat: str
185
+ started: bool
186
+ time: int
187
+ duration: int
188
+ reference_price: int
189
+ depth_levels: int
190
+ maker_fee: int
191
+ taker_fee: int
192
+ latency: Latency
193
+ account: Account
194
+ orders: tuple[OpenOrder, ...] = ()
195
+ challenge: Optional[Challenge] = None
196
+ scoring: Optional[Scoring] = None
197
+ auction_fee: int = 0
198
+ phase: str = "continuous"
199
+ # "flat", or "probability": a fill at price p pays the fees times 4 p (1 - p), for p the
200
+ # price as a share of the payout, max_price + 1
201
+ fee_curve: str = "flat"
202
+ max_price: int = 1_000_000_000 # the highest limit price: 99 in a prediction market
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Start:
207
+ time: int
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class Clock:
212
+ time: int
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class Accepted:
217
+ time: int
218
+ id: int
219
+ order_id: int
220
+ side: Side
221
+ order_type: OrderType
222
+ quantity: int
223
+ price: Optional[int] = None
224
+ time_in_force: Optional[TimeInForce] = None
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Rejected:
229
+ time: int
230
+ id: int
231
+ request: Literal["new", "cancel", "modify"]
232
+ reason: str
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class Modified:
237
+ time: int
238
+ id: int
239
+ order_id: int
240
+ price: int
241
+ quantity: int
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class Filled:
246
+ time: int
247
+ id: int
248
+ order_id: int
249
+ side: Side
250
+ price: int
251
+ quantity: int
252
+ leaves: int
253
+ liquidity: Literal["maker", "taker"]
254
+ fee: int
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class Cancelled:
259
+ time: int
260
+ id: int
261
+ order_id: int
262
+ quantity: int
263
+ reason: Literal["requested", "immediate-or-cancel", "self-trade", "cleared"]
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class Trade:
268
+ time: int
269
+ price: int
270
+ quantity: int
271
+ aggressor: Side
272
+ auction: bool = False # part of an auction's uncross
273
+
274
+
275
+ Phase = Literal["opening-auction", "continuous", "halt", "closing-auction", "closed"]
276
+ AUCTIONS = ("opening-auction", "halt", "closing-auction")
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class PhaseChange:
281
+ """The market moved to another phase of its trading day; leaving an auction, `price` is
282
+ where it uncrossed."""
283
+
284
+ time: int
285
+ phase: Phase
286
+ price: Optional[int] = None
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class Indicative:
291
+ """During an auction: where the book would uncross now; `price` is None while nothing
292
+ would trade."""
293
+
294
+ time: int
295
+ price: Optional[int]
296
+ volume: int
297
+ imbalance: int
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class Top:
302
+ time: int
303
+ bid: Optional[Level]
304
+ ask: Optional[Level]
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class Depth:
309
+ time: int
310
+ bids: tuple[Level, ...] = field(default_factory=tuple)
311
+ asks: tuple[Level, ...] = field(default_factory=tuple)
312
+
313
+
314
+ @dataclass(frozen=True)
315
+ class End:
316
+ time: int
317
+ cash: int
318
+ position: int
319
+ fees: int
320
+ pnl: int
321
+ score: Optional[Score] = None
322
+
323
+
324
+ @dataclass(frozen=True)
325
+ class Error:
326
+ message: str
327
+ fatal: bool
328
+
329
+
330
+ OrderEvent = Union[Accepted, Rejected, Modified, Filled, Cancelled]
331
+ ServerMessage = Union[Welcome, Start, Clock, Accepted, Rejected, Modified, Filled, Cancelled,
332
+ Trade, Top, Depth, PhaseChange, Indicative, End, Error]
333
+
334
+
335
+ def _fields(cls, body: dict) -> dict:
336
+ """The body's values for the dataclass's fields, ignoring any others."""
337
+ names = cls.__dataclass_fields__.keys()
338
+ return {name: body[name] for name in names if name in body}
339
+
340
+
341
+ def _level(body: Optional[dict]) -> Optional[Level]:
342
+ return None if body is None else Level(**_fields(Level, body))
343
+
344
+
345
+ def _levels(bodies: list) -> tuple[Level, ...]:
346
+ return tuple(Level(**_fields(Level, body)) for body in bodies)
347
+
348
+
349
+ def _scoring(body: Optional[dict]) -> Optional[Scoring]:
350
+ if body is None:
351
+ return None
352
+ target = body.get("target")
353
+ return Scoring(**{k: v for k, v in _fields(Scoring, body).items() if k != "target"},
354
+ target=None if target is None else TargetRule(**_fields(TargetRule, target)))
355
+
356
+
357
+ _SIMPLE = {
358
+ "start": Start, "clock": Clock, "accepted": Accepted, "rejected": Rejected,
359
+ "modified": Modified, "filled": Filled, "cancelled": Cancelled, "trade": Trade,
360
+ "phase": PhaseChange, "indicative": Indicative, "error": Error,
361
+ }
362
+
363
+
364
+ def decode(line: str) -> ServerMessage:
365
+ """Decodes one line from the server. Raises ProtocolError if it is not a known message."""
366
+ try:
367
+ body = json.loads(line)
368
+ kind = body["type"]
369
+ if kind == "welcome":
370
+ return Welcome(
371
+ **{k: v for k, v in _fields(Welcome, body).items()
372
+ if k not in ("latency", "account", "orders", "challenge", "scoring")},
373
+ latency=Latency(**_fields(Latency, body["latency"])),
374
+ account=Account(**_fields(Account, body["account"])),
375
+ orders=tuple(OpenOrder(**_fields(OpenOrder, order)) for order in body["orders"]),
376
+ challenge=(None if body.get("challenge") is None
377
+ else Challenge(**_fields(Challenge, body["challenge"]))),
378
+ scoring=_scoring(body.get("scoring")),
379
+ )
380
+ if kind == "end":
381
+ score = body.get("score")
382
+ return End(**{k: v for k, v in _fields(End, body).items() if k != "score"},
383
+ score=None if score is None else Score(**_fields(Score, score)))
384
+ if kind == "top":
385
+ return Top(time=body["time"], bid=_level(body["bid"]), ask=_level(body["ask"]))
386
+ if kind == "depth":
387
+ return Depth(time=body["time"], bids=_levels(body["bids"]),
388
+ asks=_levels(body["asks"]))
389
+ cls = _SIMPLE[kind]
390
+ return cls(**_fields(cls, body))
391
+ except (ValueError, KeyError, TypeError) as error:
392
+ raise ProtocolError(f"cannot decode {line.strip()!r}: {error}") from error