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.
- crowdbook-0.13.0/LICENSE +21 -0
- crowdbook-0.13.0/PKG-INFO +63 -0
- crowdbook-0.13.0/README.md +54 -0
- crowdbook-0.13.0/pyproject.toml +13 -0
- crowdbook-0.13.0/pyproject.toml.orig +13 -0
- crowdbook-0.13.0/src/crowdbook/__init__.py +15 -0
- crowdbook-0.13.0/src/crowdbook/client.py +334 -0
- crowdbook-0.13.0/src/crowdbook/protocol.py +392 -0
crowdbook-0.13.0/LICENSE
ADDED
|
@@ -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
|