pipsocket 0.1.0__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.
- pipsocket/__init__.py +68 -0
- pipsocket/_core.py +712 -0
- pipsocket/_generated/__init__.py +1 -0
- pipsocket/_generated/operations.py +35 -0
- pipsocket/_generated/tables.py +95 -0
- pipsocket/_generated/types.py +542 -0
- pipsocket/_http.py +235 -0
- pipsocket/_stream_core.py +916 -0
- pipsocket/_version.py +1 -0
- pipsocket/aio.py +702 -0
- pipsocket/client.py +680 -0
- pipsocket/errors.py +275 -0
- pipsocket/models.py +151 -0
- pipsocket/py.typed +0 -0
- pipsocket/stream.py +713 -0
- pipsocket/types.py +48 -0
- pipsocket-0.1.0.dist-info/METADATA +358 -0
- pipsocket-0.1.0.dist-info/RECORD +20 -0
- pipsocket-0.1.0.dist-info/WHEEL +4 -0
- pipsocket-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
"""The stream helper's state machine. No I/O.
|
|
2
|
+
|
|
3
|
+
The shells in stream.py feed it inputs (the socket opened, a message arrived, a timer fired, a
|
|
4
|
+
REST read answered, the caller set ticks or closed) and carry out the actions it returns (open
|
|
5
|
+
or close the socket, send a text, start or cancel a timer, make a REST read, hand an event to the
|
|
6
|
+
caller, notify a wait(), stop with an error).
|
|
7
|
+
|
|
8
|
+
connecting --> failed refused (401, 404, 426 or another 4xx)
|
|
9
|
+
connecting --> backoff network error, or a 5xx from the upgrade
|
|
10
|
+
connecting --> syncing subscribed
|
|
11
|
+
syncing --> live ticks set, snapshot read, resync event sent
|
|
12
|
+
syncing --> syncing lagged, or the local queue overflowed: start again
|
|
13
|
+
syncing --> host_away engine_disconnected: start again once the host is back
|
|
14
|
+
syncing --> failed 401 or 404 on a resync read
|
|
15
|
+
syncing --> backoff socket closed, no pong, or the resync not done in 120 s
|
|
16
|
+
live --> syncing lagged, or the local queue overflowed
|
|
17
|
+
live --> host_away engine_disconnected
|
|
18
|
+
host_away --> syncing an event arrives, or the probe read answers
|
|
19
|
+
host_away --> failed 401 or 404 on the probe read
|
|
20
|
+
host_away --> backoff socket closed, or no pong
|
|
21
|
+
live --> backoff socket closed, or no pong
|
|
22
|
+
backoff --> connecting 0.5 s doubling to 30 s, with jitter
|
|
23
|
+
any --> closed close()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from collections import deque
|
|
30
|
+
from collections.abc import Callable, Iterable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from enum import Enum
|
|
33
|
+
from typing import Any, Literal, cast
|
|
34
|
+
|
|
35
|
+
from ._core import refusal_from_body
|
|
36
|
+
from .errors import AccountUnavailable, AuthenticationError, NotFound, PipsocketError, RangeTooLarge
|
|
37
|
+
|
|
38
|
+
Json = dict[str, Any]
|
|
39
|
+
|
|
40
|
+
# Timings, in seconds.
|
|
41
|
+
PING_EVERY = 20.0
|
|
42
|
+
PONG_WITHIN = 10.0
|
|
43
|
+
SUBSCRIBED_WITHIN = 30.0
|
|
44
|
+
RESYNC_WITHIN = 120.0
|
|
45
|
+
FIRST_BACKOFF = 0.5
|
|
46
|
+
LONGEST_BACKOFF = 30.0
|
|
47
|
+
FIRST_PROBE = 2.0
|
|
48
|
+
LONGEST_PROBE = 30.0
|
|
49
|
+
# History: read back from the checkpoint less this margin, up to the newest time plus a day.
|
|
50
|
+
HISTORY_MARGIN = 300
|
|
51
|
+
HISTORY_AHEAD = 86400
|
|
52
|
+
SMALLEST_SLICE = 60
|
|
53
|
+
# How many deal and order tickets to remember for deduplicating history.
|
|
54
|
+
REMEMBERED_TICKETS = 10_000
|
|
55
|
+
# The API's events. Anything else (answers to calls, refusals of them) isn't one.
|
|
56
|
+
EVENTS = frozenset({"request", "deal", "transaction", "account", "status", "tick"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class State(Enum):
|
|
60
|
+
CONNECTING = "connecting"
|
|
61
|
+
SYNCING = "syncing"
|
|
62
|
+
LIVE = "live"
|
|
63
|
+
HOST_AWAY = "host_away"
|
|
64
|
+
BACKOFF = "backoff"
|
|
65
|
+
FAILED = "failed"
|
|
66
|
+
CLOSED = "closed"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Reason = Literal["connected", "reconnected", "lagged", "host_back", "overflow"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Inputs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Start:
|
|
77
|
+
"""The caller opened the stream."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Opened:
|
|
82
|
+
"""The upgrade succeeded."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Refused:
|
|
87
|
+
"""The upgrade was answered with a status other than 101."""
|
|
88
|
+
|
|
89
|
+
status: int
|
|
90
|
+
error: PipsocketError
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Unreachable:
|
|
95
|
+
"""The upgrade failed for a network reason: no status."""
|
|
96
|
+
|
|
97
|
+
error: Exception
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Closed:
|
|
102
|
+
"""The socket closed."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Text:
|
|
107
|
+
"""A text message that isn't JSON (the stream's "pong")."""
|
|
108
|
+
|
|
109
|
+
text: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Message:
|
|
114
|
+
"""A JSON message from the stream."""
|
|
115
|
+
|
|
116
|
+
body: Json
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Timer:
|
|
121
|
+
name: str
|
|
122
|
+
generation: int
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Answered:
|
|
127
|
+
"""A REST read the machine asked for: its value, or the error it raised."""
|
|
128
|
+
|
|
129
|
+
read: Read
|
|
130
|
+
value: Any = None
|
|
131
|
+
error: PipsocketError | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class SetTicks:
|
|
136
|
+
symbols: tuple[str, ...]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Overflowed:
|
|
141
|
+
"""The caller fell behind and the shell's queue of events filled up: it was emptied.
|
|
142
|
+
`dropped` are the events that never reached the caller, oldest first."""
|
|
143
|
+
|
|
144
|
+
dropped: tuple[Json, ...] = ()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Fatal:
|
|
149
|
+
"""A read made outside the machine (a wait() lookup) failed. A 401 or 404 for the
|
|
150
|
+
account stops the stream too: it can no longer resync."""
|
|
151
|
+
|
|
152
|
+
error: PipsocketError
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class Watch:
|
|
157
|
+
request_id: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Unwatch:
|
|
162
|
+
request_id: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class Close:
|
|
167
|
+
"""The caller closed the stream."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
Input = (
|
|
171
|
+
Start
|
|
172
|
+
| Opened
|
|
173
|
+
| Refused
|
|
174
|
+
| Unreachable
|
|
175
|
+
| Closed
|
|
176
|
+
| Text
|
|
177
|
+
| Message
|
|
178
|
+
| Timer
|
|
179
|
+
| Answered
|
|
180
|
+
| SetTicks
|
|
181
|
+
| Overflowed
|
|
182
|
+
| Fatal
|
|
183
|
+
| Watch
|
|
184
|
+
| Unwatch
|
|
185
|
+
| Close
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Actions
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class Connect:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class Disconnect:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class Send:
|
|
204
|
+
text: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class StartTimer:
|
|
209
|
+
name: str
|
|
210
|
+
seconds: float
|
|
211
|
+
generation: int
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class Read:
|
|
216
|
+
"""A REST read: kind is account, positions, orders, deals, history_orders or request."""
|
|
217
|
+
|
|
218
|
+
kind: str
|
|
219
|
+
generation: int
|
|
220
|
+
request_id: str | None = None
|
|
221
|
+
from_: int | None = None
|
|
222
|
+
to: int | None = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class Emit:
|
|
227
|
+
event: Json
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class Notify:
|
|
232
|
+
"""A recorded request seen on the stream or in a resync lookup, for wait() and for
|
|
233
|
+
trade calls to reconcile a slower REST answer with."""
|
|
234
|
+
|
|
235
|
+
body: Json
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class Fail:
|
|
240
|
+
error: PipsocketError
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
Action = Connect | Disconnect | Send | StartTimer | Read | Emit | Notify | Fail
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# The resync in progress.
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class Resync:
|
|
251
|
+
reason: Reason
|
|
252
|
+
generation: int
|
|
253
|
+
# Waiting for the socket's latest requested tick set to be acknowledged before reading.
|
|
254
|
+
waiting_ticks: bool = False
|
|
255
|
+
account: Json | None = None
|
|
256
|
+
positions: list[Json] | None = None
|
|
257
|
+
orders: list[Json] | None = None
|
|
258
|
+
terminal_away: bool = False
|
|
259
|
+
history_reason: str | None = None
|
|
260
|
+
history_from: int | None = None
|
|
261
|
+
history_to: int | None = None
|
|
262
|
+
deals: list[Json] = field(default_factory=list[Json])
|
|
263
|
+
history_orders: list[Json] = field(default_factory=list[Json])
|
|
264
|
+
# Ranges still to read, as (kind, from, to).
|
|
265
|
+
ranges: deque[tuple[str, int, int]] = field(default_factory=deque[tuple[str, int, int]])
|
|
266
|
+
lookups: set[str] = field(default_factory=set[str])
|
|
267
|
+
waiting: Read | None = None
|
|
268
|
+
# A different login or server than the checkpoint's: committed when the resync finishes.
|
|
269
|
+
new_identity: tuple[Any, Any] | None = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class Checkpoint:
|
|
274
|
+
server_time: int | None = None
|
|
275
|
+
login: int | None = None
|
|
276
|
+
server: str | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class StreamMachine:
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
ticks: Iterable[str] = (),
|
|
284
|
+
read_state: bool = True,
|
|
285
|
+
queue_limit: int = 1000,
|
|
286
|
+
random: Callable[[], float],
|
|
287
|
+
) -> None:
|
|
288
|
+
self.state = State.CONNECTING
|
|
289
|
+
self.ticks: tuple[str, ...] = tuple(ticks)
|
|
290
|
+
self.read_state = read_state
|
|
291
|
+
self.queue_limit = queue_limit
|
|
292
|
+
self.random = random
|
|
293
|
+
self.watched: set[str] = set()
|
|
294
|
+
self.checkpoint = Checkpoint()
|
|
295
|
+
self.connected_before = False
|
|
296
|
+
self.socket_open = False
|
|
297
|
+
self.subscribed = False
|
|
298
|
+
self.failures = 0
|
|
299
|
+
self.probe_every = FIRST_PROBE
|
|
300
|
+
self.resync: Resync | None = None
|
|
301
|
+
self.probe: Read | None = None
|
|
302
|
+
self.pending_reason: Reason | None = None
|
|
303
|
+
self.held: list[Json] = []
|
|
304
|
+
self.newest_ticks: dict[str, Json] = {}
|
|
305
|
+
self.terminal_away = False
|
|
306
|
+
self.calls = 0
|
|
307
|
+
# One /ticks call at a time: calls on a stream run side by side, so the set last sent
|
|
308
|
+
# must be the one the stream keeps.
|
|
309
|
+
self.ticks_call: str | None = None
|
|
310
|
+
self.ticks_sending: tuple[str, ...] = ()
|
|
311
|
+
self.ticks_acknowledged: tuple[str, ...] = ()
|
|
312
|
+
self.generations: dict[str, int] = {}
|
|
313
|
+
self.read_generation = 0
|
|
314
|
+
self.recent_deals: deque[int] = deque(maxlen=REMEMBERED_TICKETS)
|
|
315
|
+
self.recent_orders: deque[int] = deque(maxlen=REMEMBERED_TICKETS)
|
|
316
|
+
# Account events emitted, with the checkpoint before each: an overflow takes back
|
|
317
|
+
# the ones the caller never got.
|
|
318
|
+
self.account_events: deque[tuple[Json, int | None]] = deque(maxlen=4 * queue_limit + 64)
|
|
319
|
+
self.out: list[Action] = []
|
|
320
|
+
|
|
321
|
+
# The one entry point.
|
|
322
|
+
|
|
323
|
+
def handle(self, event: Input) -> list[Action]:
|
|
324
|
+
self.out = []
|
|
325
|
+
if self.state in (State.FAILED, State.CLOSED):
|
|
326
|
+
return []
|
|
327
|
+
match event:
|
|
328
|
+
case Start():
|
|
329
|
+
self.connect()
|
|
330
|
+
case Close():
|
|
331
|
+
self.close()
|
|
332
|
+
case Opened():
|
|
333
|
+
self.opened()
|
|
334
|
+
case Refused(status=status, error=error):
|
|
335
|
+
self.refused(status, error)
|
|
336
|
+
case Unreachable():
|
|
337
|
+
self.lost()
|
|
338
|
+
case Closed():
|
|
339
|
+
self.lost()
|
|
340
|
+
case Text(text=text):
|
|
341
|
+
if text == "pong":
|
|
342
|
+
self.bump("pong")
|
|
343
|
+
case Message(body=body):
|
|
344
|
+
self.message(body)
|
|
345
|
+
case Timer(name=name, generation=generation):
|
|
346
|
+
if self.generations.get(name) == generation:
|
|
347
|
+
self.timer(name)
|
|
348
|
+
case Answered():
|
|
349
|
+
self.answered(event)
|
|
350
|
+
case SetTicks(symbols=symbols):
|
|
351
|
+
self.set_ticks(symbols)
|
|
352
|
+
case Overflowed(dropped=dropped):
|
|
353
|
+
self.take_back(dropped)
|
|
354
|
+
self.overflowed()
|
|
355
|
+
case Fatal(error=error):
|
|
356
|
+
if fatal(error):
|
|
357
|
+
self.fail(error)
|
|
358
|
+
case Watch(request_id=request_id):
|
|
359
|
+
self.watched.add(request_id)
|
|
360
|
+
case Unwatch(request_id=request_id):
|
|
361
|
+
self.watched.discard(request_id)
|
|
362
|
+
return self.out
|
|
363
|
+
|
|
364
|
+
# Timers: starting one with a name replaces the one before; bump() cancels it.
|
|
365
|
+
|
|
366
|
+
def start_timer(self, name: str, seconds: float) -> None:
|
|
367
|
+
generation = self.generations.get(name, 0) + 1
|
|
368
|
+
self.generations[name] = generation
|
|
369
|
+
self.out.append(StartTimer(name, seconds, generation))
|
|
370
|
+
|
|
371
|
+
def bump(self, name: str) -> None:
|
|
372
|
+
self.generations[name] = self.generations.get(name, 0) + 1
|
|
373
|
+
|
|
374
|
+
def stop_timers(self) -> None:
|
|
375
|
+
for name in list(self.generations):
|
|
376
|
+
self.bump(name)
|
|
377
|
+
|
|
378
|
+
# Connecting and losing the socket.
|
|
379
|
+
|
|
380
|
+
def connect(self) -> None:
|
|
381
|
+
self.state = State.CONNECTING
|
|
382
|
+
self.subscribed = False
|
|
383
|
+
self.out.append(Connect())
|
|
384
|
+
|
|
385
|
+
def opened(self) -> None:
|
|
386
|
+
if self.state is not State.CONNECTING:
|
|
387
|
+
return
|
|
388
|
+
self.socket_open = True
|
|
389
|
+
self.start_timer("subscribed", SUBSCRIBED_WITHIN)
|
|
390
|
+
self.start_timer("ping", PING_EVERY)
|
|
391
|
+
|
|
392
|
+
def refused(self, status: int, error: PipsocketError) -> None:
|
|
393
|
+
if status >= 500 or status == 429:
|
|
394
|
+
self.lost()
|
|
395
|
+
else:
|
|
396
|
+
self.fail(error)
|
|
397
|
+
|
|
398
|
+
def lost(self) -> None:
|
|
399
|
+
"""The socket closed or never opened: wait, then connect again from scratch."""
|
|
400
|
+
if self.state in (State.BACKOFF, State.CLOSED, State.FAILED):
|
|
401
|
+
return
|
|
402
|
+
if self.socket_open:
|
|
403
|
+
self.out.append(Disconnect())
|
|
404
|
+
self.socket_open = False
|
|
405
|
+
self.subscribed = False
|
|
406
|
+
self.stop_timers()
|
|
407
|
+
self.abandon_resync()
|
|
408
|
+
self.ticks_call = None
|
|
409
|
+
self.ticks_acknowledged = ()
|
|
410
|
+
self.state = State.BACKOFF
|
|
411
|
+
self.start_timer("backoff", self.backoff())
|
|
412
|
+
|
|
413
|
+
def backoff(self) -> float:
|
|
414
|
+
"""0.5 s doubling to 30 s, each times a random factor from 0.5 to 1."""
|
|
415
|
+
nominal = FIRST_BACKOFF * 2 ** min(self.failures, 16)
|
|
416
|
+
self.failures += 1
|
|
417
|
+
return min(nominal, LONGEST_BACKOFF) * (0.5 + 0.5 * self.random())
|
|
418
|
+
|
|
419
|
+
def close(self) -> None:
|
|
420
|
+
if self.socket_open:
|
|
421
|
+
self.out.append(Disconnect())
|
|
422
|
+
self.socket_open = False
|
|
423
|
+
self.stop_timers()
|
|
424
|
+
self.state = State.CLOSED
|
|
425
|
+
|
|
426
|
+
def fail(self, error: PipsocketError) -> None:
|
|
427
|
+
if self.socket_open:
|
|
428
|
+
self.out.append(Disconnect())
|
|
429
|
+
self.socket_open = False
|
|
430
|
+
self.stop_timers()
|
|
431
|
+
self.state = State.FAILED
|
|
432
|
+
self.out.append(Fail(error))
|
|
433
|
+
|
|
434
|
+
def timer(self, name: str) -> None:
|
|
435
|
+
if name == "backoff" and self.state is State.BACKOFF:
|
|
436
|
+
self.connect()
|
|
437
|
+
elif name == "ping" and self.socket_open:
|
|
438
|
+
self.out.append(Send("ping"))
|
|
439
|
+
self.start_timer("pong", PONG_WITHIN)
|
|
440
|
+
self.start_timer("ping", PING_EVERY)
|
|
441
|
+
elif name in ("pong", "subscribed", "resync_deadline"):
|
|
442
|
+
# The socket is dead, never subscribed, or a resync is stuck: start over.
|
|
443
|
+
self.lost()
|
|
444
|
+
elif name == "ticks_retry":
|
|
445
|
+
self.send_ticks_if_due()
|
|
446
|
+
elif name == "resync_retry" and self.state is State.SYNCING:
|
|
447
|
+
self.begin_resync_reads()
|
|
448
|
+
elif name == "probe" and self.state is State.HOST_AWAY:
|
|
449
|
+
self.read_generation += 1
|
|
450
|
+
self.probe = Read("account", self.read_generation)
|
|
451
|
+
self.out.append(self.probe)
|
|
452
|
+
|
|
453
|
+
# Messages.
|
|
454
|
+
|
|
455
|
+
def message(self, body: Json) -> None:
|
|
456
|
+
kind = body.get("type")
|
|
457
|
+
if kind == "subscribed":
|
|
458
|
+
if self.state is State.CONNECTING and not self.subscribed:
|
|
459
|
+
self.subscribed = True
|
|
460
|
+
self.bump("subscribed")
|
|
461
|
+
reason: Reason = "reconnected" if self.connected_before else "connected"
|
|
462
|
+
self.connected_before = True
|
|
463
|
+
self.begin_resync(reason)
|
|
464
|
+
return
|
|
465
|
+
if kind in ("response", "error") and body.get("id") == self.ticks_call:
|
|
466
|
+
self.ticks_answered(body)
|
|
467
|
+
return
|
|
468
|
+
if kind == "request":
|
|
469
|
+
self.out.append(Notify(body))
|
|
470
|
+
if self.state is State.SYNCING:
|
|
471
|
+
self.during_resync(body)
|
|
472
|
+
elif self.state is State.LIVE:
|
|
473
|
+
self.while_live(body)
|
|
474
|
+
elif self.state is State.HOST_AWAY:
|
|
475
|
+
self.host_back_with(body)
|
|
476
|
+
|
|
477
|
+
def while_live(self, body: Json) -> None:
|
|
478
|
+
kind = body.get("type")
|
|
479
|
+
if kind == "lagged":
|
|
480
|
+
self.emit(body)
|
|
481
|
+
self.begin_resync("lagged")
|
|
482
|
+
elif kind == "engine_disconnected":
|
|
483
|
+
self.emit(body)
|
|
484
|
+
self.host_away()
|
|
485
|
+
else:
|
|
486
|
+
self.deliver(body)
|
|
487
|
+
if kind == "status" and self.terminal_away and body.get("status") == "connected":
|
|
488
|
+
# The last snapshot had no positions or orders: the terminal is back now.
|
|
489
|
+
self.begin_resync("host_back")
|
|
490
|
+
|
|
491
|
+
def during_resync(self, body: Json) -> None:
|
|
492
|
+
kind = body.get("type")
|
|
493
|
+
if kind == "lagged":
|
|
494
|
+
self.begin_resync("lagged")
|
|
495
|
+
elif kind == "engine_disconnected":
|
|
496
|
+
self.host_away()
|
|
497
|
+
else:
|
|
498
|
+
self.hold(body)
|
|
499
|
+
|
|
500
|
+
def host_back_with(self, body: Json) -> None:
|
|
501
|
+
if body.get("type") not in EVENTS:
|
|
502
|
+
return
|
|
503
|
+
self.begin_resync("host_back")
|
|
504
|
+
self.hold(body)
|
|
505
|
+
|
|
506
|
+
def hold(self, body: Json) -> None:
|
|
507
|
+
"""Keep an event that arrives during a resync, to deliver after it."""
|
|
508
|
+
kind = body.get("type")
|
|
509
|
+
if kind == "account":
|
|
510
|
+
return
|
|
511
|
+
if kind == "tick":
|
|
512
|
+
symbol = str(body.get("symbol"))
|
|
513
|
+
kept = self.newest_ticks.get(symbol)
|
|
514
|
+
if kept is None or (body.get("time_msc") or 0) >= (kept.get("time_msc") or 0):
|
|
515
|
+
self.newest_ticks[symbol] = body
|
|
516
|
+
return
|
|
517
|
+
if kind in ("response", "error"):
|
|
518
|
+
return
|
|
519
|
+
self.held.append(body)
|
|
520
|
+
if len(self.held) > self.queue_limit:
|
|
521
|
+
self.overflowed()
|
|
522
|
+
|
|
523
|
+
def host_away(self) -> None:
|
|
524
|
+
self.abandon_resync()
|
|
525
|
+
self.state = State.HOST_AWAY
|
|
526
|
+
self.probe_every = FIRST_PROBE
|
|
527
|
+
self.start_timer("probe", self.probe_every)
|
|
528
|
+
|
|
529
|
+
def overflowed(self) -> None:
|
|
530
|
+
if self.state not in (State.LIVE, State.SYNCING):
|
|
531
|
+
return
|
|
532
|
+
self.held.clear()
|
|
533
|
+
self.newest_ticks.clear()
|
|
534
|
+
self.begin_resync("overflow")
|
|
535
|
+
|
|
536
|
+
def take_back(self, dropped: tuple[Json, ...]) -> None:
|
|
537
|
+
"""Events the caller never got don't count as delivered: the checkpoint goes back to
|
|
538
|
+
before the first dropped account event, and dropped deals aren't remembered."""
|
|
539
|
+
for body in dropped:
|
|
540
|
+
if body.get("type") == "deal":
|
|
541
|
+
forget(self.recent_deals, deal_ticket(body))
|
|
542
|
+
elif body.get("type") == "resync" and isinstance(body.get("history"), dict):
|
|
543
|
+
history = cast(Json, body["history"])
|
|
544
|
+
for deal in history.get("deals", []):
|
|
545
|
+
forget(self.recent_deals, deal_ticket(deal))
|
|
546
|
+
for order in history.get("orders", []):
|
|
547
|
+
forget(self.recent_orders, order_ticket(order))
|
|
548
|
+
dropped_ids = {id(body) for body in dropped if body.get("type") == "account"}
|
|
549
|
+
while self.account_events and id(self.account_events[-1][0]) in dropped_ids:
|
|
550
|
+
_, before = self.account_events.pop()
|
|
551
|
+
self.checkpoint.server_time = before
|
|
552
|
+
|
|
553
|
+
# Delivering.
|
|
554
|
+
|
|
555
|
+
def deliver(self, body: Json) -> None:
|
|
556
|
+
kind = body.get("type")
|
|
557
|
+
if kind == "account":
|
|
558
|
+
server_time = body.get("server_time")
|
|
559
|
+
if isinstance(server_time, int) and not isinstance(server_time, bool):
|
|
560
|
+
before = self.checkpoint.server_time
|
|
561
|
+
self.account_events.append((body, before))
|
|
562
|
+
self.checkpoint.server_time = max(before or 0, server_time)
|
|
563
|
+
elif kind == "deal":
|
|
564
|
+
ticket = deal_ticket(body)
|
|
565
|
+
if ticket is not None:
|
|
566
|
+
self.recent_deals.append(ticket)
|
|
567
|
+
self.emit(body)
|
|
568
|
+
|
|
569
|
+
def emit(self, body: Json) -> None:
|
|
570
|
+
self.out.append(Emit(body))
|
|
571
|
+
|
|
572
|
+
# Ticks.
|
|
573
|
+
|
|
574
|
+
def set_ticks(self, symbols: tuple[str, ...]) -> None:
|
|
575
|
+
"""Remember the set, and send it once the socket is subscribed and no other /ticks
|
|
576
|
+
call is unanswered."""
|
|
577
|
+
self.ticks = symbols
|
|
578
|
+
self.send_ticks_if_due()
|
|
579
|
+
|
|
580
|
+
def send_ticks_if_due(self) -> None:
|
|
581
|
+
if not self.subscribed or self.ticks_call is not None:
|
|
582
|
+
return
|
|
583
|
+
if self.ticks == self.ticks_acknowledged:
|
|
584
|
+
return
|
|
585
|
+
self.calls += 1
|
|
586
|
+
self.ticks_call = f"ticks-{self.calls}"
|
|
587
|
+
self.ticks_sending = self.ticks
|
|
588
|
+
body = {
|
|
589
|
+
"id": self.ticks_call,
|
|
590
|
+
"method": "PUT",
|
|
591
|
+
"path": "/ticks",
|
|
592
|
+
"body": {"symbols": list(self.ticks)},
|
|
593
|
+
}
|
|
594
|
+
self.out.append(Send(json.dumps(body)))
|
|
595
|
+
|
|
596
|
+
def ticks_settled(self) -> bool:
|
|
597
|
+
return self.ticks_call is None and self.ticks == self.ticks_acknowledged
|
|
598
|
+
|
|
599
|
+
def ticks_answered(self, body: Json) -> None:
|
|
600
|
+
self.ticks_call = None
|
|
601
|
+
status = body.get("status")
|
|
602
|
+
if body.get("type") == "response" and status == 200:
|
|
603
|
+
self.ticks_acknowledged = self.ticks_sending
|
|
604
|
+
self.send_ticks_if_due()
|
|
605
|
+
resync = self.resync
|
|
606
|
+
if resync is not None and resync.waiting_ticks and self.ticks_settled():
|
|
607
|
+
resync.waiting_ticks = False
|
|
608
|
+
self.after_ticks()
|
|
609
|
+
return
|
|
610
|
+
if body.get("type") == "response" and isinstance(status, int) and 400 <= status < 500:
|
|
611
|
+
self.fail(ticks_refused(status, body.get("body")))
|
|
612
|
+
return
|
|
613
|
+
# Refused by the stream itself (too_many_calls): try the resync again, or send the
|
|
614
|
+
# set again later.
|
|
615
|
+
if self.resync is not None and self.resync.waiting_ticks:
|
|
616
|
+
self.resync_failed()
|
|
617
|
+
else:
|
|
618
|
+
self.start_timer("ticks_retry", self.backoff())
|
|
619
|
+
|
|
620
|
+
# Resync.
|
|
621
|
+
|
|
622
|
+
def begin_resync(self, reason: Reason) -> None:
|
|
623
|
+
restarting = self.state is State.SYNCING and self.resync is not None
|
|
624
|
+
if restarting and self.resync is not None:
|
|
625
|
+
# A gap during a resync: start again, so the snapshot is read after it. A new
|
|
626
|
+
# socket's resync keeps its reason, and its ticks if they aren't set yet. The 120 s
|
|
627
|
+
# deadline still counts from the first start, so gaps can't keep it syncing.
|
|
628
|
+
if self.resync.reason in ("connected", "reconnected"):
|
|
629
|
+
reason = self.resync.reason
|
|
630
|
+
self.bump("resync_retry")
|
|
631
|
+
else:
|
|
632
|
+
self.abandon_resync()
|
|
633
|
+
self.start_timer("resync_deadline", RESYNC_WITHIN)
|
|
634
|
+
self.state = State.SYNCING
|
|
635
|
+
self.read_generation += 1
|
|
636
|
+
self.resync = Resync(reason, self.read_generation)
|
|
637
|
+
self.begin_resync_reads()
|
|
638
|
+
|
|
639
|
+
def abandon_resync(self) -> None:
|
|
640
|
+
if self.resync is not None:
|
|
641
|
+
self.bump("resync_retry")
|
|
642
|
+
self.bump("resync_deadline")
|
|
643
|
+
self.resync = None
|
|
644
|
+
|
|
645
|
+
def begin_resync_reads(self) -> None:
|
|
646
|
+
resync = self.resync
|
|
647
|
+
assert resync is not None
|
|
648
|
+
self.read_generation += 1
|
|
649
|
+
resync.generation = self.read_generation
|
|
650
|
+
# Every resync reads after the socket's latest tick set is acknowledged: on a new socket
|
|
651
|
+
# that is the set sent again, and a set asked for meanwhile waits its turn too.
|
|
652
|
+
self.send_ticks_if_due()
|
|
653
|
+
if not self.ticks_settled():
|
|
654
|
+
resync.waiting_ticks = True
|
|
655
|
+
return
|
|
656
|
+
self.after_ticks()
|
|
657
|
+
|
|
658
|
+
def after_ticks(self) -> None:
|
|
659
|
+
resync = self.resync
|
|
660
|
+
assert resync is not None
|
|
661
|
+
if self.read_state:
|
|
662
|
+
self.read(resync, Read("account", resync.generation))
|
|
663
|
+
else:
|
|
664
|
+
self.finish_resync()
|
|
665
|
+
|
|
666
|
+
def read(self, resync: Resync, read: Read) -> None:
|
|
667
|
+
resync.waiting = read
|
|
668
|
+
self.out.append(read)
|
|
669
|
+
|
|
670
|
+
def answered(self, answer: Answered) -> None:
|
|
671
|
+
read = answer.read
|
|
672
|
+
if answer.error is not None and fatal(answer.error):
|
|
673
|
+
# The key was rolled or the account is gone: that holds whether or not this read's
|
|
674
|
+
# answer is still wanted.
|
|
675
|
+
self.fail(answer.error)
|
|
676
|
+
return
|
|
677
|
+
if read == self.probe:
|
|
678
|
+
self.probe = None
|
|
679
|
+
if self.state is State.HOST_AWAY:
|
|
680
|
+
self.probe_answered(answer)
|
|
681
|
+
return
|
|
682
|
+
resync = self.resync
|
|
683
|
+
if self.state is not State.SYNCING or resync is None or read != resync.waiting:
|
|
684
|
+
return
|
|
685
|
+
resync.waiting = None
|
|
686
|
+
error = answer.error
|
|
687
|
+
if error is not None:
|
|
688
|
+
if fatal(error):
|
|
689
|
+
self.fail(error)
|
|
690
|
+
elif read.kind in ("positions", "orders") and isinstance(error, AccountUnavailable):
|
|
691
|
+
resync.terminal_away = True
|
|
692
|
+
self.next_after_snapshot(resync)
|
|
693
|
+
elif read.kind in ("deals", "history_orders") and isinstance(error, RangeTooLarge):
|
|
694
|
+
self.split_range(resync, read)
|
|
695
|
+
elif read.kind in ("deals", "history_orders") and isinstance(error, AccountUnavailable):
|
|
696
|
+
self.history_unavailable(resync, "unavailable")
|
|
697
|
+
elif read.kind == "request":
|
|
698
|
+
resync.lookups.discard(read.request_id or "")
|
|
699
|
+
self.next_lookup(resync)
|
|
700
|
+
else:
|
|
701
|
+
self.resync_failed()
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
value = answer.value
|
|
705
|
+
if read.kind == "account":
|
|
706
|
+
resync.account = value
|
|
707
|
+
self.read(resync, Read("positions", resync.generation))
|
|
708
|
+
elif read.kind == "positions":
|
|
709
|
+
resync.positions = value
|
|
710
|
+
self.read(resync, Read("orders", resync.generation))
|
|
711
|
+
elif read.kind == "orders":
|
|
712
|
+
resync.orders = value
|
|
713
|
+
self.next_after_snapshot(resync)
|
|
714
|
+
elif read.kind in ("deals", "history_orders"):
|
|
715
|
+
target = resync.deals if read.kind == "deals" else resync.history_orders
|
|
716
|
+
target.extend(value)
|
|
717
|
+
self.next_range(resync)
|
|
718
|
+
elif read.kind == "request":
|
|
719
|
+
resync.lookups.discard(read.request_id or "")
|
|
720
|
+
if isinstance(value, dict):
|
|
721
|
+
self.out.append(Notify(cast(Json, value)))
|
|
722
|
+
self.next_lookup(resync)
|
|
723
|
+
|
|
724
|
+
def next_after_snapshot(self, resync: Resync) -> None:
|
|
725
|
+
if resync.terminal_away:
|
|
726
|
+
resync.positions = None
|
|
727
|
+
resync.orders = None
|
|
728
|
+
# History needs the terminal too: report it unavailable, keep the checkpoint.
|
|
729
|
+
self.history_unavailable(resync, "unavailable")
|
|
730
|
+
return
|
|
731
|
+
self.plan_history(resync)
|
|
732
|
+
|
|
733
|
+
def plan_history(self, resync: Resync) -> None:
|
|
734
|
+
account = resync.account or {}
|
|
735
|
+
login, server = account.get("login"), account.get("server")
|
|
736
|
+
checkpoint = self.checkpoint
|
|
737
|
+
if checkpoint.login is not None and (login, server) != (
|
|
738
|
+
checkpoint.login,
|
|
739
|
+
checkpoint.server,
|
|
740
|
+
):
|
|
741
|
+
resync.new_identity = (login, server)
|
|
742
|
+
self.history_unavailable(resync, "account_changed")
|
|
743
|
+
return
|
|
744
|
+
if checkpoint.server_time is None:
|
|
745
|
+
self.history_unavailable(resync, "no_checkpoint")
|
|
746
|
+
return
|
|
747
|
+
newest = checkpoint.server_time
|
|
748
|
+
server_time = account.get("server_time")
|
|
749
|
+
if isinstance(server_time, int) and not isinstance(server_time, bool):
|
|
750
|
+
newest = max(newest, server_time)
|
|
751
|
+
start = min(checkpoint.server_time, server_time) - HISTORY_MARGIN
|
|
752
|
+
else:
|
|
753
|
+
start = checkpoint.server_time - HISTORY_MARGIN
|
|
754
|
+
resync.history_from = max(start, 0)
|
|
755
|
+
resync.history_to = newest + HISTORY_AHEAD
|
|
756
|
+
resync.ranges = deque(
|
|
757
|
+
[
|
|
758
|
+
("deals", resync.history_from, resync.history_to),
|
|
759
|
+
("history_orders", resync.history_from, resync.history_to),
|
|
760
|
+
]
|
|
761
|
+
)
|
|
762
|
+
self.next_range(resync)
|
|
763
|
+
|
|
764
|
+
def split_range(self, resync: Resync, read: Read) -> None:
|
|
765
|
+
assert read.from_ is not None
|
|
766
|
+
assert read.to is not None
|
|
767
|
+
if read.to - read.from_ < 2 * SMALLEST_SLICE:
|
|
768
|
+
self.history_unavailable(resync, "range_too_large")
|
|
769
|
+
return
|
|
770
|
+
middle = (read.from_ + read.to) // 2
|
|
771
|
+
resync.ranges.appendleft((read.kind, middle + 1, read.to))
|
|
772
|
+
resync.ranges.appendleft((read.kind, read.from_, middle))
|
|
773
|
+
self.next_range(resync)
|
|
774
|
+
|
|
775
|
+
def next_range(self, resync: Resync) -> None:
|
|
776
|
+
if resync.ranges:
|
|
777
|
+
kind, start, end = resync.ranges.popleft()
|
|
778
|
+
self.read(resync, Read(kind, resync.generation, from_=start, to=end))
|
|
779
|
+
return
|
|
780
|
+
self.begin_lookups(resync)
|
|
781
|
+
|
|
782
|
+
def history_unavailable(self, resync: Resync, reason: str) -> None:
|
|
783
|
+
resync.history_reason = reason
|
|
784
|
+
resync.ranges.clear()
|
|
785
|
+
resync.deals.clear()
|
|
786
|
+
resync.history_orders.clear()
|
|
787
|
+
self.begin_lookups(resync)
|
|
788
|
+
|
|
789
|
+
def begin_lookups(self, resync: Resync) -> None:
|
|
790
|
+
resync.lookups = set(self.watched)
|
|
791
|
+
self.next_lookup(resync)
|
|
792
|
+
|
|
793
|
+
def next_lookup(self, resync: Resync) -> None:
|
|
794
|
+
if resync.lookups:
|
|
795
|
+
request_id = sorted(resync.lookups)[0]
|
|
796
|
+
self.read(resync, Read("request", resync.generation, request_id=request_id))
|
|
797
|
+
return
|
|
798
|
+
self.finish_resync()
|
|
799
|
+
|
|
800
|
+
def resync_failed(self) -> None:
|
|
801
|
+
"""A resync read failed in a way that may mend: try the resync again after a wait."""
|
|
802
|
+
resync = self.resync
|
|
803
|
+
assert resync is not None
|
|
804
|
+
resync.waiting = None
|
|
805
|
+
self.start_timer("resync_retry", self.backoff())
|
|
806
|
+
# Start the next attempt from a clean slate; held events are kept.
|
|
807
|
+
self.resync = Resync(resync.reason, resync.generation)
|
|
808
|
+
|
|
809
|
+
def finish_resync(self) -> None:
|
|
810
|
+
resync = self.resync
|
|
811
|
+
assert resync is not None
|
|
812
|
+
self.bump("resync_deadline")
|
|
813
|
+
self.failures = 0
|
|
814
|
+
if resync.new_identity is not None:
|
|
815
|
+
# A different login: its own checkpoint, and nothing remembered from the old one.
|
|
816
|
+
login, server = resync.new_identity
|
|
817
|
+
self.checkpoint = Checkpoint(None, login, server)
|
|
818
|
+
self.recent_deals.clear()
|
|
819
|
+
self.recent_orders.clear()
|
|
820
|
+
self.account_events.clear()
|
|
821
|
+
history: Json | None = None
|
|
822
|
+
if self.read_state and resync.history_reason is None and resync.history_from is not None:
|
|
823
|
+
deals = self.fresh(resync.deals, self.recent_deals, deal_ticket)
|
|
824
|
+
orders = self.fresh(resync.history_orders, self.recent_orders, order_ticket)
|
|
825
|
+
history = {
|
|
826
|
+
"deals": deals,
|
|
827
|
+
"orders": orders,
|
|
828
|
+
"from": resync.history_from,
|
|
829
|
+
"to": resync.history_to,
|
|
830
|
+
}
|
|
831
|
+
if self.read_state:
|
|
832
|
+
account = resync.account or {}
|
|
833
|
+
if self.checkpoint.login is None:
|
|
834
|
+
self.checkpoint.login = account.get("login")
|
|
835
|
+
self.checkpoint.server = account.get("server")
|
|
836
|
+
self.terminal_away = resync.terminal_away
|
|
837
|
+
event: Json = {
|
|
838
|
+
"type": "resync",
|
|
839
|
+
"reason": resync.reason,
|
|
840
|
+
"account": resync.account,
|
|
841
|
+
"positions": resync.positions,
|
|
842
|
+
"orders": resync.orders,
|
|
843
|
+
"history": history,
|
|
844
|
+
"history_reason": resync.history_reason if self.read_state else None,
|
|
845
|
+
}
|
|
846
|
+
self.resync = None
|
|
847
|
+
self.state = State.LIVE
|
|
848
|
+
self.emit(event)
|
|
849
|
+
waiting = self.held + list(self.newest_ticks.values())
|
|
850
|
+
self.held, self.newest_ticks = [], {}
|
|
851
|
+
for index, body in enumerate(waiting):
|
|
852
|
+
self.while_live(body)
|
|
853
|
+
if self.state is not State.LIVE:
|
|
854
|
+
# That event started another resync (or the host went): the rest wait for it.
|
|
855
|
+
for rest in waiting[index + 1 :]:
|
|
856
|
+
self.hold(rest)
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
def fresh(
|
|
860
|
+
self, entries: list[Json], seen: deque[int], ticket: Callable[[Json], int | None]
|
|
861
|
+
) -> list[Json]:
|
|
862
|
+
"""History entries not already delivered, in order, each ticket once."""
|
|
863
|
+
known = set(seen)
|
|
864
|
+
kept: list[Json] = []
|
|
865
|
+
for entry in entries:
|
|
866
|
+
number = ticket(entry)
|
|
867
|
+
if number is not None and number in known:
|
|
868
|
+
continue
|
|
869
|
+
if number is not None:
|
|
870
|
+
known.add(number)
|
|
871
|
+
seen.append(number)
|
|
872
|
+
kept.append(entry)
|
|
873
|
+
return kept
|
|
874
|
+
|
|
875
|
+
# The host away.
|
|
876
|
+
|
|
877
|
+
def probe_answered(self, answer: Answered) -> None:
|
|
878
|
+
if answer.error is None:
|
|
879
|
+
self.begin_resync("host_back")
|
|
880
|
+
return
|
|
881
|
+
if fatal(answer.error):
|
|
882
|
+
self.fail(answer.error)
|
|
883
|
+
return
|
|
884
|
+
self.probe_every = min(self.probe_every * 2, LONGEST_PROBE)
|
|
885
|
+
self.start_timer("probe", self.probe_every)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def fatal(error: PipsocketError) -> bool:
|
|
889
|
+
"""A failure that won't mend: the key was rolled, or the account is gone."""
|
|
890
|
+
if isinstance(error, AuthenticationError):
|
|
891
|
+
return True
|
|
892
|
+
return isinstance(error, NotFound) and error.code == "no_such_account"
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def forget(remembered: deque[int], ticket: int | None) -> None:
|
|
896
|
+
if ticket is not None and ticket in remembered:
|
|
897
|
+
remembered.remove(ticket)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def deal_ticket(body: Json) -> int | None:
|
|
901
|
+
"""A deal's ticket, from a deal event ({"deal": {...}}) or a history entry."""
|
|
902
|
+
deal: object = body.get("deal", body)
|
|
903
|
+
if not isinstance(deal, dict):
|
|
904
|
+
return None
|
|
905
|
+
value: object = cast(Json, deal).get("ticket")
|
|
906
|
+
return value if isinstance(value, int) else None
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def order_ticket(body: Json) -> int | None:
|
|
910
|
+
value = body.get("ticket")
|
|
911
|
+
return value if isinstance(value, int) else None
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def ticks_refused(status: int, body: Any) -> PipsocketError:
|
|
915
|
+
"""The stream refused the tick set, such as 422 too_many_symbols: raised as it is."""
|
|
916
|
+
return refusal_from_body(status, body)
|