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/stream.py ADDED
@@ -0,0 +1,713 @@
1
+ """The stream helper's shells: they do the I/O for the state machine in _stream_core.
2
+
3
+ One engine, on asyncio and websockets, runs the machine: it opens and reads the socket, keeps
4
+ the timers, makes the REST reads and queues events for the caller. `AsyncStream` runs it in the
5
+ caller's event loop; `Stream` runs it on a background thread with its own loop, so a synchronous
6
+ caller can take events at its own pace while the socket is still read promptly (pings and call
7
+ answers need that).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import json
15
+ import logging
16
+ import threading
17
+ from collections import OrderedDict, deque
18
+ from collections.abc import Awaitable, Callable, Iterable
19
+ from types import TracebackType
20
+ from typing import TYPE_CHECKING, Any, Protocol
21
+
22
+ from websockets.asyncio.client import ClientConnection, connect
23
+ from websockets.exceptions import ConnectionClosed, InvalidStatus, InvalidURI
24
+
25
+ from . import _stream_core as core
26
+ from ._core import refusal_from_body, segment
27
+ from ._http import USER_AGENT, Options
28
+ from .errors import (
29
+ AuthenticationError,
30
+ NotFound,
31
+ PipsocketError,
32
+ StreamError,
33
+ StreamHandshakeError,
34
+ )
35
+ from .models import FINAL_STATES
36
+
37
+ __all__ = ["AsyncStream", "Stream"]
38
+
39
+ # Events the caller hasn't taken yet. When this many wait, the queue is emptied and the stream
40
+ # resyncs (reason "overflow").
41
+ DEFAULT_QUEUE_SIZE = 1000
42
+ # How long one upgrade may take.
43
+ OPEN_TIMEOUT = 10.0
44
+
45
+ Event = dict[str, Any]
46
+ Reader = Callable[[core.Read], Awaitable[Any]]
47
+ Watcher = Callable[[Event], None]
48
+
49
+
50
+ class _End:
51
+ """Marks the end of the events: closed by the caller, or stopped with an error."""
52
+
53
+ def __init__(self, error: PipsocketError | None) -> None:
54
+ self.error = error
55
+
56
+
57
+ class _Crashed:
58
+ """A spawned task's unexpected failure, queued for the engine's loop to end the stream."""
59
+
60
+ def __init__(self, error: Exception) -> None:
61
+ self.error = error
62
+
63
+
64
+ class Sink(Protocol):
65
+ """Where the engine puts events for the caller."""
66
+
67
+ def put(self, item: Event | _End) -> list[Event]:
68
+ """Queue an item. If the queue was full it is emptied, and everything in it, with this
69
+ item, is returned: the events the caller will never get."""
70
+ ...
71
+
72
+
73
+ class Settled:
74
+ """Recorded requests the stream has seen settle, and the wait() calls watching for them.
75
+ Shared with the caller's thread, hence the lock."""
76
+
77
+ REMEMBERED = 1000
78
+
79
+ def __init__(self) -> None:
80
+ self.lock = threading.Lock()
81
+ self.finals: OrderedDict[str, Event] = OrderedDict()
82
+ self.watchers: dict[str, set[Watcher]] = {}
83
+
84
+ def saw(self, body: Event) -> None:
85
+ request_id = body.get("request")
86
+ if not isinstance(request_id, str):
87
+ return
88
+ with self.lock:
89
+ if body.get("state") in FINAL_STATES:
90
+ self.finals[request_id] = body
91
+ self.finals.move_to_end(request_id)
92
+ while len(self.finals) > self.REMEMBERED:
93
+ self.finals.popitem(last=False)
94
+ watchers = list(self.watchers.get(request_id, ()))
95
+ for watcher in watchers:
96
+ watcher(body)
97
+
98
+ def final(self, request_id: str) -> Event | None:
99
+ with self.lock:
100
+ return self.finals.get(request_id)
101
+
102
+ def watch(self, request_id: str, watcher: Watcher) -> bool:
103
+ """Add a watcher, and give it at once a settlement already seen (registering and
104
+ reading the cache under one lock, so none slips between). True if it is the first
105
+ watcher for this id."""
106
+ with self.lock:
107
+ watchers = self.watchers.setdefault(request_id, set())
108
+ first = not watchers
109
+ watchers.add(watcher)
110
+ known = self.finals.get(request_id)
111
+ if known is not None:
112
+ watcher(known)
113
+ return first
114
+
115
+ def unwatch(self, request_id: str, watcher: Watcher) -> bool:
116
+ """Remove a watcher. True if it was the last for this id."""
117
+ with self.lock:
118
+ watchers = self.watchers.get(request_id, set())
119
+ watchers.discard(watcher)
120
+ if watchers:
121
+ return False
122
+ self.watchers.pop(request_id, None)
123
+ return True
124
+
125
+
126
+ if TYPE_CHECKING:
127
+ _LoggerAdapter = logging.LoggerAdapter[logging.Logger]
128
+ else: # not subscriptable at run time before Python 3.11
129
+ _LoggerAdapter = logging.LoggerAdapter
130
+
131
+
132
+ class _Redacted(_LoggerAdapter):
133
+ """websockets' logger, with the API key taken out of everything it logs (it logs the
134
+ upgrade's headers at debug level)."""
135
+
136
+ def __init__(self, logger: logging.Logger, secret: str) -> None:
137
+ super().__init__(logger, {})
138
+ self.secret = secret
139
+
140
+ def log(self, level: int, msg: object, *args: object, **kwargs: Any) -> None:
141
+ if self.isEnabledFor(level):
142
+ msg = self.redact(msg)
143
+ args = tuple(self.redact(arg) for arg in args)
144
+ super().log(level, msg, *args, **kwargs)
145
+
146
+ def redact(self, value: object) -> object:
147
+ if isinstance(value, (str, bytes)) and self.secret in str(value):
148
+ return str(value).replace(self.secret, "[api key]")
149
+ return value
150
+
151
+
152
+ class Engine:
153
+ """Runs the machine: feeds it inputs and carries out its actions. One per stream."""
154
+
155
+ def __init__(
156
+ self,
157
+ *,
158
+ url: str,
159
+ api_key: str,
160
+ machine: core.StreamMachine,
161
+ reader: Reader,
162
+ sink: Sink,
163
+ settled: Settled,
164
+ ) -> None:
165
+ self.url = url
166
+ self.api_key = api_key
167
+ self.machine = machine
168
+ self.reader = reader
169
+ self.sink = sink
170
+ self.settled = settled
171
+ self.inputs: asyncio.Queue[core.Input | _Crashed] = asyncio.Queue()
172
+ self.socket: ClientConnection | None = None
173
+ self.socket_generation = 0
174
+ self.tasks: set[asyncio.Task[Any]] = set()
175
+ # One handle per timer name: starting a timer replaces (cancels) the one before.
176
+ self.timers: dict[str, asyncio.TimerHandle] = {}
177
+ self.dropped: list[Event] | None = None
178
+ self.finished = False
179
+ self.logger = _Redacted(logging.getLogger("websockets.client"), api_key)
180
+
181
+ def feed(self, event: core.Input) -> None:
182
+ self.inputs.put_nowait(event)
183
+
184
+ async def run(self) -> None:
185
+ self.feed(core.Start())
186
+ try:
187
+ next_event: core.Input | None = None
188
+ while not self.finished:
189
+ event = next_event or await self.inputs.get()
190
+ next_event = None
191
+ if isinstance(event, _Crashed):
192
+ raise event.error
193
+ self.dropped = None
194
+ for action in self.machine.handle(event):
195
+ await self.do(action)
196
+ if self.dropped is not None:
197
+ # The queue overflowed: the rest of this batch is dropped with it, and the
198
+ # machine hears of it before any other input, so no event reaches the
199
+ # caller (or moves the checkpoint) ahead of the resync that replaces them.
200
+ next_event = core.Overflowed(tuple(self.dropped))
201
+ if isinstance(event, core.Close):
202
+ self.finish(None)
203
+ except Exception as error:
204
+ # A failure of the SDK's own: end the stream with it, so the caller hears of it.
205
+ self.finish(unexpected(error))
206
+ finally:
207
+ await self.shut()
208
+
209
+ def finish(self, error: PipsocketError | None) -> None:
210
+ if not self.finished:
211
+ self.finished = True
212
+ self.sink.put(_End(error))
213
+
214
+ async def shut(self) -> None:
215
+ for handle in self.timers.values():
216
+ handle.cancel()
217
+ for task in list(self.tasks):
218
+ task.cancel()
219
+ await self.drop_socket()
220
+
221
+ def spawn(self, work: Awaitable[Any]) -> None:
222
+ task = asyncio.ensure_future(work)
223
+ self.tasks.add(task)
224
+ task.add_done_callback(self.supervise)
225
+
226
+ def supervise(self, task: asyncio.Task[Any]) -> None:
227
+ """A spawned task (opening, listening, a read) that fails unexpectedly ends the stream:
228
+ its own expected failures are already inputs to the machine."""
229
+ self.tasks.discard(task)
230
+ if task.cancelled():
231
+ return
232
+ error = task.exception()
233
+ if isinstance(error, Exception):
234
+ self.inputs.put_nowait(_Crashed(error))
235
+
236
+ async def do(self, action: core.Action) -> None:
237
+ match action:
238
+ case core.Connect():
239
+ self.socket_generation += 1
240
+ self.spawn(self.open(self.socket_generation))
241
+ case core.Disconnect():
242
+ await self.drop_socket()
243
+ case core.Send(text=text):
244
+ if self.socket is not None:
245
+ with contextlib.suppress(ConnectionClosed):
246
+ await self.socket.send(text)
247
+ case core.StartTimer(name=name, seconds=seconds, generation=generation):
248
+ previous = self.timers.get(name)
249
+ if previous is not None:
250
+ previous.cancel()
251
+ timer = core.Timer(name, generation)
252
+ self.timers[name] = asyncio.get_running_loop().call_later(seconds, self.feed, timer)
253
+ case core.Read():
254
+ self.spawn(self.read(action))
255
+ case core.Emit(event=event):
256
+ if self.dropped is not None:
257
+ self.dropped.append(event)
258
+ else:
259
+ dropped = self.sink.put(event)
260
+ if dropped:
261
+ self.dropped = dropped
262
+ case core.Notify(body=body):
263
+ self.settled.saw(body)
264
+ case core.Fail(error=error):
265
+ self.finish(error)
266
+
267
+ async def open(self, generation: int) -> None:
268
+ try:
269
+ socket = await connect(
270
+ self.url,
271
+ additional_headers={"Authorization": f"Bearer {self.api_key}"},
272
+ user_agent_header=USER_AGENT,
273
+ ping_interval=None,
274
+ open_timeout=OPEN_TIMEOUT,
275
+ logger=self.logger,
276
+ )
277
+ except InvalidStatus as refused:
278
+ status = refused.response.status_code
279
+ self.feed(core.Refused(status, handshake_error(status, bytes(refused.response.body))))
280
+ return
281
+ except InvalidURI as error:
282
+ self.feed(core.Refused(0, StreamHandshakeError(str(error), status=None)))
283
+ return
284
+ except (OSError, TimeoutError, ConnectionClosed, Exception) as error:
285
+ self.feed(core.Unreachable(error))
286
+ return
287
+ if generation != self.socket_generation or self.finished:
288
+ await socket.close()
289
+ return
290
+ self.socket = socket
291
+ self.feed(core.Opened())
292
+ self.spawn(self.listen(socket, generation))
293
+
294
+ async def listen(self, socket: ClientConnection, generation: int) -> None:
295
+ try:
296
+ async for raw in socket:
297
+ text = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
298
+ if generation != self.socket_generation:
299
+ return
300
+ self.feed(parse(text))
301
+ except ConnectionClosed:
302
+ pass
303
+ if generation == self.socket_generation:
304
+ self.feed(core.Closed())
305
+
306
+ async def drop_socket(self) -> None:
307
+ socket, self.socket = self.socket, None
308
+ self.socket_generation += 1
309
+ if socket is not None:
310
+ with contextlib.suppress(Exception):
311
+ await socket.close()
312
+
313
+ async def read(self, read: core.Read) -> None:
314
+ try:
315
+ value = await self.reader(read)
316
+ except PipsocketError as error:
317
+ self.feed(core.Answered(read, error=error))
318
+ except Exception as error:
319
+ failure = StreamError(f"A read the stream made failed: {error}")
320
+ failure.__cause__ = error
321
+ self.feed(core.Answered(read, error=failure))
322
+ else:
323
+ self.feed(core.Answered(read, value=value))
324
+
325
+
326
+ def unexpected(error: Exception) -> StreamError:
327
+ failure = StreamError(f"The stream stopped unexpectedly: {error!r}")
328
+ failure.__cause__ = error
329
+ return failure
330
+
331
+
332
+ def parse(text: str) -> core.Input:
333
+ try:
334
+ body = json.loads(text)
335
+ except ValueError:
336
+ return core.Text(text)
337
+ if isinstance(body, dict):
338
+ return core.Message(body) # pyright: ignore[reportUnknownArgumentType]
339
+ return core.Text(text)
340
+
341
+
342
+ def handshake_error(status: int, raw: bytes) -> PipsocketError:
343
+ """The error for a refused upgrade. A 401 or 404 is the API's refusal, as on REST (the key
344
+ and account checks are the same); anything else is StreamHandshakeError with the status."""
345
+ try:
346
+ body = json.loads(raw)
347
+ except (ValueError, RecursionError):
348
+ body = None
349
+ if status in (401, 404):
350
+ if isinstance(body, dict) and isinstance(body.get("error"), str): # pyright: ignore[reportUnknownMemberType]
351
+ return refusal_from_body(status, body, raw)
352
+ kind = AuthenticationError if status == 401 else NotFound
353
+ message = f"The stream's upgrade was refused ({status})."
354
+ return kind(message, status=status, code=None, body=body, raw=raw)
355
+ return StreamHandshakeError(f"The stream's upgrade was refused ({status}).", status=status)
356
+
357
+
358
+ def stream_url(options: Options, account: str) -> str:
359
+ return f"{options.stream_url}/v1/accounts/{segment(account)}/stream"
360
+
361
+
362
+ def new_machine(
363
+ options: Options, ticks: Iterable[str], snapshot: bool, queue_size: int
364
+ ) -> core.StreamMachine:
365
+ if isinstance(ticks, str):
366
+ raise ValueError("ticks is a list of symbols, not one string.")
367
+ if queue_size < 1:
368
+ raise ValueError("queue_size is 1 or more.")
369
+ return core.StreamMachine(
370
+ ticks=ticks, read_state=snapshot, queue_limit=queue_size, random=options.random
371
+ )
372
+
373
+
374
+ def sync_reader(account: Any) -> Callable[[core.Read], Any]:
375
+ """The REST reads the machine asks for, made with a sync account handle."""
376
+
377
+ def read(request: core.Read) -> Any:
378
+ if request.kind == "request":
379
+ return account.requests.get(request.request_id).raw
380
+ return read_with(account, request)
381
+
382
+ return read
383
+
384
+
385
+ def async_reader(account: Any) -> Reader:
386
+ """The REST reads the machine asks for, made with an async account handle."""
387
+
388
+ async def read(request: core.Read) -> Any:
389
+ if request.kind == "request":
390
+ return (await account.requests.get(request.request_id)).raw
391
+ return await read_with(account, request)
392
+
393
+ return read
394
+
395
+
396
+ def read_with(account: Any, request: core.Read) -> Any:
397
+ """Makes a snapshot or history read on the handle; an async handle returns an awaitable."""
398
+ if request.kind == "account":
399
+ return account.get()
400
+ if request.kind == "positions":
401
+ return account.positions.list()
402
+ if request.kind == "orders":
403
+ return account.orders.list()
404
+ if request.kind == "deals":
405
+ return account.history.deals(request.from_, request.to)
406
+ if request.kind == "history_orders":
407
+ return account.history.orders(request.from_, request.to)
408
+ raise ValueError(f"Unknown read: {request.kind}")
409
+
410
+
411
+ # The async shell.
412
+
413
+
414
+ class _AsyncSink:
415
+ def __init__(self, limit: int) -> None:
416
+ self.items: deque[Event | _End] = deque()
417
+ self.limit = limit
418
+ self.closed = False
419
+ self.ready = asyncio.Event()
420
+
421
+ def put(self, item: Event | _End) -> list[Event]:
422
+ if self.closed:
423
+ return [] # the caller closed the stream: nothing more is delivered
424
+ if not isinstance(item, _End) and len(self.items) >= self.limit:
425
+ dropped = [event for event in self.items if not isinstance(event, _End)] + [item]
426
+ self.items.clear()
427
+ return dropped
428
+ self.items.append(item)
429
+ self.ready.set()
430
+ return []
431
+
432
+ def close(self) -> None:
433
+ """The caller closed the stream: nothing still queued, or put later, is delivered."""
434
+ self.closed = True
435
+ self.items.clear()
436
+ self.items.append(_End(None))
437
+ self.ready.set()
438
+
439
+ async def get(self) -> Event | _End:
440
+ while not self.items:
441
+ self.ready.clear()
442
+ await self.ready.wait()
443
+ return self.items.popleft()
444
+
445
+
446
+ class AsyncStream:
447
+ """The account's events, as `async with acct.stream(ticks=[...]) as events`, then
448
+ `async for event in events`. Events are the API's messages as they are, plus `resync`."""
449
+
450
+ def __init__(
451
+ self,
452
+ *,
453
+ url: str,
454
+ options: Options,
455
+ machine: core.StreamMachine,
456
+ reader: Reader,
457
+ streams: list[Any],
458
+ ) -> None:
459
+ self._sink = _AsyncSink(machine.queue_limit)
460
+ self._settled = Settled()
461
+ self._engine = Engine(
462
+ url=url,
463
+ api_key=options.api_key,
464
+ machine=machine,
465
+ reader=reader,
466
+ sink=self._sink,
467
+ settled=self._settled,
468
+ )
469
+ self._streams = streams
470
+ self._task: asyncio.Task[None] | None = None
471
+ self._ended: _End | None = None
472
+
473
+ async def __aenter__(self) -> AsyncStream:
474
+ self._task = asyncio.ensure_future(self._engine.run())
475
+ self._streams.append(self)
476
+ return self
477
+
478
+ async def __aexit__(
479
+ self,
480
+ exc_type: type[BaseException] | None,
481
+ exc: BaseException | None,
482
+ traceback: TracebackType | None,
483
+ ) -> None:
484
+ try:
485
+ await self.close()
486
+ except Exception:
487
+ if exc is None:
488
+ raise
489
+ # An exception is already unwinding the caller's block: it isn't replaced.
490
+
491
+ def __aiter__(self) -> AsyncStream:
492
+ return self
493
+
494
+ async def __anext__(self) -> Event:
495
+ if self._ended is not None:
496
+ return self._end()
497
+ item = await self._sink.get()
498
+ if isinstance(item, _End):
499
+ self._ended = item
500
+ return self._end()
501
+ return item
502
+
503
+ def _end(self) -> Event:
504
+ assert self._ended is not None
505
+ if self._ended.error is not None:
506
+ raise self._ended.error
507
+ raise StopAsyncIteration
508
+
509
+ async def set_ticks(self, symbols: Iterable[str]) -> None:
510
+ """Ask for ticks of these symbols (the whole set, up to 100; [] stops them)."""
511
+ if isinstance(symbols, str):
512
+ raise ValueError("symbols is a list of symbols, not one string.")
513
+ self._engine.feed(core.SetTicks(tuple(symbols)))
514
+
515
+ async def close(self) -> None:
516
+ """Close the stream. Iteration ends at once: events still queued are not delivered.
517
+ Waiting wait() calls carry on polling over REST."""
518
+ if self in self._streams:
519
+ self._streams.remove(self)
520
+ if self._ended is None:
521
+ self._sink.close()
522
+ if self._task is None:
523
+ return
524
+ self._engine.feed(core.Close())
525
+ # Shielded: if the caller is cancelled meanwhile, that cancellation is the caller's to
526
+ # see, and the engine, already told to close, finishes shutting down on its own.
527
+ await asyncio.shield(self._task)
528
+
529
+ def _feed(self, event: core.Input) -> None:
530
+ self._engine.feed(event)
531
+
532
+
533
+ # The sync shell.
534
+
535
+
536
+ class _ThreadSink:
537
+ def __init__(self, limit: int) -> None:
538
+ self.items: deque[Event | _End] = deque()
539
+ self.limit = limit
540
+ self.closed = False
541
+ self.ready = threading.Condition()
542
+
543
+ def put(self, item: Event | _End) -> list[Event]:
544
+ with self.ready:
545
+ if self.closed:
546
+ return [] # the caller closed the stream: nothing more is delivered
547
+ if not isinstance(item, _End) and len(self.items) >= self.limit:
548
+ dropped = [event for event in self.items if not isinstance(event, _End)] + [item]
549
+ self.items.clear()
550
+ return dropped
551
+ self.items.append(item)
552
+ self.ready.notify_all()
553
+ return []
554
+
555
+ def close(self) -> None:
556
+ """The caller closed the stream: nothing still queued, or put later, is delivered."""
557
+ with self.ready:
558
+ self.closed = True
559
+ self.items.clear()
560
+ self.items.append(_End(None))
561
+ self.ready.notify_all()
562
+
563
+ def get(self) -> Event | _End:
564
+ with self.ready:
565
+ while not self.items:
566
+ self.ready.wait()
567
+ return self.items.popleft()
568
+
569
+
570
+ class Stream:
571
+ """The account's events, as `with acct.stream(ticks=[...]) as events`, then
572
+ `for event in events`. The socket is read on a background thread."""
573
+
574
+ def __init__(
575
+ self,
576
+ *,
577
+ url: str,
578
+ options: Options,
579
+ machine: core.StreamMachine,
580
+ reader: Callable[[core.Read], Any],
581
+ streams: list[Any],
582
+ ) -> None:
583
+ self._sink = _ThreadSink(machine.queue_limit)
584
+ self._settled = Settled()
585
+ self._url = url
586
+ self._options = options
587
+ self._machine = machine
588
+ self._reader = reader
589
+ self._streams = streams
590
+ self._loop: asyncio.AbstractEventLoop | None = None
591
+ self._engine: Engine | None = None
592
+ self._thread: threading.Thread | None = None
593
+ self._ended: _End | None = None
594
+
595
+ def __enter__(self) -> Stream:
596
+ started = threading.Event()
597
+ self._thread = threading.Thread(target=self._run, args=(started,), daemon=True)
598
+ self._thread.start()
599
+ started.wait()
600
+ self._streams.append(self)
601
+ return self
602
+
603
+ def __exit__(
604
+ self,
605
+ exc_type: type[BaseException] | None,
606
+ exc: BaseException | None,
607
+ traceback: TracebackType | None,
608
+ ) -> None:
609
+ self.close()
610
+
611
+ def _run(self, started: threading.Event) -> None:
612
+ try:
613
+ loop = asyncio.new_event_loop()
614
+ except Exception as error: # out of file descriptors, say
615
+ self._sink.put(_End(unexpected(error)))
616
+ started.set()
617
+ return
618
+ self._loop = loop
619
+
620
+ async def read(request: core.Read) -> Any:
621
+ return await asyncio.to_thread(self._reader, request)
622
+
623
+ async def main() -> None:
624
+ self._engine = Engine(
625
+ url=self._url,
626
+ api_key=self._options.api_key,
627
+ machine=self._machine,
628
+ reader=read,
629
+ sink=self._sink,
630
+ settled=self._settled,
631
+ )
632
+ started.set()
633
+ await self._engine.run()
634
+
635
+ try:
636
+ loop.run_until_complete(main())
637
+ except Exception as error:
638
+ # The engine ends itself on its own failures; this is anything around it.
639
+ self._sink.put(_End(unexpected(error)))
640
+ finally:
641
+ started.set()
642
+ loop.close()
643
+
644
+ def _feed(self, event: core.Input) -> None:
645
+ loop, engine = self._loop, self._engine
646
+ if loop is None or engine is None or loop.is_closed():
647
+ return
648
+ with contextlib.suppress(RuntimeError):
649
+ loop.call_soon_threadsafe(engine.feed, event)
650
+
651
+ def __iter__(self) -> Stream:
652
+ return self
653
+
654
+ def __next__(self) -> Event:
655
+ if self._ended is None:
656
+ item = self._sink.get()
657
+ if isinstance(item, _End):
658
+ self._ended = item
659
+ else:
660
+ return item
661
+ if self._ended.error is not None:
662
+ raise self._ended.error
663
+ raise StopIteration
664
+
665
+ def set_ticks(self, symbols: Iterable[str]) -> None:
666
+ """Ask for ticks of these symbols (the whole set, up to 100; [] stops them)."""
667
+ if isinstance(symbols, str):
668
+ raise ValueError("symbols is a list of symbols, not one string.")
669
+ self._feed(core.SetTicks(tuple(symbols)))
670
+
671
+ def close(self) -> None:
672
+ """Close the stream. Iteration ends at once, from any thread (a `for` loop blocked
673
+ waiting for an event wakes and stops): events still queued are not delivered. Waiting
674
+ wait() calls carry on polling over REST."""
675
+ if self in self._streams:
676
+ self._streams.remove(self)
677
+ if self._ended is None:
678
+ self._sink.close()
679
+ self._feed(core.Close())
680
+ if self._thread is not None and self._thread is not threading.current_thread():
681
+ self._thread.join()
682
+
683
+
684
+ # What requests.wait() and the trade calls use from the streams open on their account handle.
685
+
686
+
687
+ def watch(stream: Stream | AsyncStream, request_id: str, watcher: Watcher) -> Callable[[], None]:
688
+ """Call `watcher` with each recorded-request body the stream sees for this id (on the
689
+ stream's thread, for a sync stream), and look the id up on every resync. Returns the undo."""
690
+ if stream._settled.watch(request_id, watcher): # pyright: ignore[reportPrivateUsage]
691
+ stream._feed(core.Watch(request_id)) # pyright: ignore[reportPrivateUsage]
692
+
693
+ def unwatch() -> None:
694
+ if stream._settled.unwatch(request_id, watcher): # pyright: ignore[reportPrivateUsage]
695
+ stream._feed(core.Unwatch(request_id)) # pyright: ignore[reportPrivateUsage]
696
+
697
+ return unwatch
698
+
699
+
700
+ def settled(streams: Iterable[Stream | AsyncStream], request_id: str) -> Event | None:
701
+ """The request in a final state, if an open stream has seen it settle."""
702
+ for stream in list(streams):
703
+ body = stream._settled.final(request_id) # pyright: ignore[reportPrivateUsage]
704
+ if body is not None:
705
+ return body
706
+ return None
707
+
708
+
709
+ def report(streams: Iterable[Stream | AsyncStream], error: PipsocketError) -> None:
710
+ """A read outside the streams failed: a 401 or a gone account stops them too."""
711
+ if core.fatal(error):
712
+ for stream in list(streams):
713
+ stream._feed(core.Fatal(error)) # pyright: ignore[reportPrivateUsage]