quantred 2.4.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.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
1
+ """Reconnecting async stream runner.
2
+
3
+ Live WebSockets drop. Both the market-data bar stream and the trade-update
4
+ stream want the same behavior: run until the connection errors, then reconnect
5
+ with capped exponential backoff, while letting cancellation (Ctrl-C) through
6
+ cleanly. That loop lives here so both share one implementation.
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import inspect
12
+ import logging
13
+ import os
14
+ import signal
15
+ import sys
16
+ import threading
17
+ from dataclasses import dataclass
18
+ from typing import Awaitable, Callable, Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ #: How long to wait for a websocket to close before moving on.
23
+ STREAM_CLOSE_TIMEOUT = 2.0
24
+
25
+
26
+ async def close_stream(stream) -> None:
27
+ """Best-effort websocket shutdown; never raises, never blocks the loop.
28
+
29
+ An SDK's synchronous ``stop()`` must not be called from inside the event loop.
30
+ alpaca-py's does::
31
+
32
+ asyncio.run_coroutine_threadsafe(self.stop_ws(), self._loop).result(timeout=5)
33
+
34
+ which submits work to the loop and then blocks the calling thread waiting for it.
35
+ Called from a coroutine, the caller *is* that loop, so the work can never run and
36
+ the loop stays frozen for the whole timeout. Nothing scheduled on the loop happens
37
+ meanwhile — including the signal callbacks that stop the process, which is how a
38
+ shutdown ends up ignoring a second SIGTERM and a Ctrl-C, and why the only bound
39
+ that survives a blocked loop is the watchdog thread below.
40
+
41
+ ``stop_ws()`` is the coroutine that wrapper wraps, so awaiting it directly is the
42
+ same shutdown without the deadlock.
43
+ """
44
+ closer = getattr(stream, "stop_ws", None) or getattr(stream, "close", None)
45
+ if closer is None:
46
+ return
47
+ try:
48
+ result = closer()
49
+ if not inspect.isawaitable(result):
50
+ return
51
+ # Bounded, and via wait(): wait_for cancels what it waits on and then awaits
52
+ # it, which cannot bound a close slow to answer cancellation.
53
+ task = asyncio.ensure_future(result)
54
+ done, _ = await asyncio.wait({task}, timeout=STREAM_CLOSE_TIMEOUT)
55
+ if task not in done:
56
+ logger.warning("Stream close did not finish within %gs", STREAM_CLOSE_TIMEOUT)
57
+ task.cancel()
58
+ else:
59
+ task.exception() # collect, so nothing is reported as never retrieved
60
+ except Exception: # noqa: BLE001 - cleanup must not mask the real error
61
+ logger.debug("Stream close raised during shutdown", exc_info=True)
62
+
63
+
64
+ async def run_with_reconnect(
65
+ name: str,
66
+ connect: Callable[[], Awaitable[None]],
67
+ *,
68
+ base_delay: float = 5.0,
69
+ max_delay: float = 60.0,
70
+ ) -> None:
71
+ """Run ``connect()`` repeatedly, reconnecting on error with backoff.
72
+
73
+ Args:
74
+ name: Label for log messages (e.g. "market-data").
75
+ connect: Zero-arg coroutine function that establishes the stream and
76
+ runs until it ends or raises. It is responsible for its own cleanup
77
+ (e.g. in a ``finally``).
78
+ base_delay / max_delay: Backoff bounds in seconds.
79
+ """
80
+ delay = base_delay
81
+ while True:
82
+ try:
83
+ await connect()
84
+ logger.info("%s stream ended normally.", name)
85
+ return
86
+ except asyncio.CancelledError:
87
+ logger.info("%s stream canceled; shutting down.", name)
88
+ raise
89
+ except Exception as exc: # noqa: BLE001 - reconnect on any stream error
90
+ logger.error("%s stream error (%s); reconnecting in %.0fs", name, exc, delay)
91
+ await asyncio.sleep(delay)
92
+ delay = min(delay * 2, max_delay)
93
+
94
+
95
+ #: Signals that mean "stop this session". SIGTERM matters as much as SIGINT: it is
96
+ #: what a supervisor, a container runtime, and plain ``kill`` send, and a shutdown
97
+ #: reachable only by a human at a keyboard does not exist in any of those.
98
+ STOP_SIGNALS = (signal.SIGINT, signal.SIGTERM)
99
+
100
+ #: Indirection so tests can observe a forced exit instead of dying. Nothing else
101
+ #: should call ``os._exit`` directly.
102
+ _exit_process = os._exit
103
+
104
+
105
+ def exit_now(code: int) -> None:
106
+ """Leave the process now: flush the log and both std streams, skip finalization.
107
+
108
+ For a process whose main thread is done but which cannot finalize: interpreter
109
+ exit joins non-daemon threads, and one blocked in a read on stdin can never be
110
+ woken (closing the stream needs the very lock that read holds), so a normal exit
111
+ hangs and a second interrupt during it aborts the interpreter. Everything worth
112
+ keeping must already be on disk - this flushes what is buffered *here*, not what a
113
+ caller forgot to write.
114
+ """
115
+ _flush_best_effort()
116
+ _exit_process(code)
117
+
118
+
119
+ #: How long :func:`exit_now` waits for buffered output before leaving anyway.
120
+ EXIT_FLUSH_TIMEOUT = 2.0
121
+
122
+
123
+ def _flush_best_effort() -> None:
124
+ """Flush every log handler and the std streams; never raise, never wait forever.
125
+
126
+ Each flush is guarded on its own, so one handler that raises (a stream caught
127
+ mid-write by the very signal that started the exit raises ``RuntimeError:
128
+ reentrant call``) cannot stop the rest or the exit that follows. It runs on a
129
+ daemon thread with a bounded join because a handler's flush takes that handler's
130
+ lock, and the stopped main thread may be holding it: waiting for it would turn
131
+ "a stop signal always ends the process" into "usually". Handlers are flushed, not
132
+ closed - the process is about to end, and closing is what ``logging.shutdown``
133
+ would re-raise from.
134
+ """
135
+
136
+ def work() -> None:
137
+ loggers = [logging.getLogger()]
138
+ with contextlib.suppress(Exception):
139
+ loggers += [
140
+ lg
141
+ for lg in list(logging.Logger.manager.loggerDict.values())
142
+ if isinstance(lg, logging.Logger)
143
+ ]
144
+ seen = set()
145
+ for lg in loggers:
146
+ for handler in list(lg.handlers):
147
+ if id(handler) in seen:
148
+ continue
149
+ seen.add(id(handler))
150
+ with contextlib.suppress(Exception):
151
+ handler.flush()
152
+ for stream in (sys.stdout, sys.stderr):
153
+ with contextlib.suppress(Exception):
154
+ stream.flush()
155
+
156
+ with contextlib.suppress(Exception):
157
+ flusher = threading.Thread(target=work, name="exit-flush", daemon=True)
158
+ flusher.start()
159
+ flusher.join(EXIT_FLUSH_TIMEOUT)
160
+
161
+
162
+ class _Abandoned(Exception):
163
+ """The stopped session outlasted its teardown budget and was left behind."""
164
+
165
+
166
+ @dataclass
167
+ class _Stop:
168
+ """The stop sequence for one :func:`run_until_stopped` call.
169
+
170
+ Holds the state the signal handler, the watchdog and the caller all need, so none
171
+ of them has to be threaded through as a growing list of positional arguments.
172
+ """
173
+
174
+ loop: asyncio.AbstractEventLoop
175
+ task: "asyncio.Task"
176
+ abandoned: asyncio.Event
177
+ teardown_timeout: float
178
+ hard_exit_grace: float
179
+ signalled: bool = False
180
+ watchdog: Optional[threading.Timer] = None
181
+ installed: tuple = ()
182
+
183
+ # -- the three ways a session can end -------------------------------------
184
+ def request(self, sig) -> None:
185
+ """First stop signal cancels gracefully; a second leaves immediately."""
186
+ if self.signalled:
187
+ self.force(f"Second {sig.name} — exiting immediately.")
188
+ return
189
+ self.signalled = True
190
+ logger.info("%s received — stopping.", sig.name)
191
+ self.task.cancel()
192
+ # Bounds the session's own unwinding, not just the tasks it owns.
193
+ self.loop.call_later(self.teardown_timeout, self.abandoned.set)
194
+ # Armed here, at the moment the stop begins — not after the loop returns.
195
+ # If the loop blocks during teardown it never returns, which is exactly the
196
+ # case the watchdog exists for.
197
+ self.arm_watchdog()
198
+ self.hand_back_signals()
199
+
200
+ def force(self, reason: str, code: int = 130) -> None:
201
+ """Leave now, without running interpreter shutdown.
202
+
203
+ ``sys.exit`` is not enough: interpreter exit joins the non-daemon worker
204
+ threads ``asyncio.to_thread`` uses, and a worker blocked in a broker call
205
+ keeps the process alive with no way left to interrupt it.
206
+ """
207
+ logger.error("%s", reason)
208
+ exit_now(code)
209
+
210
+ def arm_watchdog(self) -> None:
211
+ """A last resort that does not depend on the event loop being alive.
212
+
213
+ Every other bound here is scheduled on the loop, so all of them fail together
214
+ the moment third-party code blocks it — which a synchronous SDK call does. A
215
+ daemon timer thread is the only one that still fires, and so the only thing
216
+ that makes "a stop signal always ends the process" true rather than usually
217
+ true.
218
+ """
219
+ if self.watchdog is not None:
220
+ return
221
+ grace = self.teardown_timeout + self.hard_exit_grace
222
+ self.watchdog = threading.Timer(
223
+ grace,
224
+ self.force,
225
+ args=(
226
+ f"Shutdown did not complete within {grace:g}s — forcing exit. Any open "
227
+ "positions are untouched at the broker; nothing was flattened.",
228
+ ),
229
+ )
230
+ self.watchdog.daemon = True
231
+ self.watchdog.start()
232
+
233
+ def disarm(self) -> None:
234
+ if self.watchdog is not None:
235
+ self.watchdog.cancel()
236
+ self.watchdog = None
237
+
238
+ # -- signal plumbing ------------------------------------------------------
239
+ def install_signals(self, stop_signals) -> None:
240
+ """Route stop signals to :meth:`request`.
241
+
242
+ ``add_signal_handler`` is Unix-only and unavailable off the main thread; where
243
+ it is missing the process keeps whatever behaviour it had rather than losing
244
+ the ability to stop at all.
245
+ """
246
+ installed = []
247
+ for sig in stop_signals:
248
+ try:
249
+ self.loop.add_signal_handler(sig, self.request, sig)
250
+ except (NotImplementedError, RuntimeError, ValueError, AttributeError, OSError):
251
+ logger.debug("No loop signal handler available for %s", sig)
252
+ continue
253
+ installed.append(sig)
254
+ self.installed = tuple(installed)
255
+
256
+ def hand_back_signals(self) -> None:
257
+ """Swap the loop's handlers for plain ones that force an exit.
258
+
259
+ Deliberately ``signal.signal`` rather than ``loop.add_signal_handler``: the
260
+ loop delivers its signal callbacks as loop callbacks, so once something blocks
261
+ the loop a second interrupt routed through it is never seen — which is exactly
262
+ the moment an operator is sending one.
263
+ """
264
+ self.remove_signals()
265
+ for sig in STOP_SIGNALS:
266
+ with contextlib.suppress(Exception):
267
+ signal.signal(sig, self._forced)
268
+
269
+ def _forced(self, signum, _frame) -> None:
270
+ self.force(f"Second {signal.Signals(signum).name} — exiting immediately.")
271
+
272
+ def remove_signals(self) -> None:
273
+ for sig in self.installed:
274
+ with contextlib.suppress(NotImplementedError, RuntimeError, ValueError, OSError):
275
+ self.loop.remove_signal_handler(sig)
276
+ self.installed = ()
277
+
278
+
279
+ async def _await_bounded(task, abandoned: asyncio.Event):
280
+ """Await ``task``, unless it is abandoned first.
281
+
282
+ The budget has to cover the session's *own* unwinding, not just the tasks it
283
+ started. ``run_until_complete`` waits for a cancelled coroutine to finish its
284
+ ``finally`` with no bound — so a cleanup that blocks holds the process open
285
+ exactly as an unbounded gather did, one level further in.
286
+ """
287
+ waiter = asyncio.ensure_future(abandoned.wait())
288
+ try:
289
+ done, _ = await asyncio.wait({task, waiter}, return_when=asyncio.FIRST_COMPLETED)
290
+ if task in done:
291
+ return task.result()
292
+ raise _Abandoned
293
+ finally:
294
+ waiter.cancel()
295
+
296
+
297
+ def run_until_stopped(
298
+ coro, *, teardown_timeout: float = 5.0, stop_signals=STOP_SIGNALS, hard_exit_grace: float = 10.0
299
+ ):
300
+ """Run ``coro`` on a private loop, guaranteeing the process can exit afterwards.
301
+
302
+ ``asyncio.run`` cannot be used for a streaming session. On the way out it cancels
303
+ whatever is still running and then *awaits all of it with no bound*, so a stream
304
+ slow to close its socket holds the process open until a second interrupt — and the
305
+ second one lands during cleanup, where it can interrupt anything.
306
+
307
+ This drains instead: cancel, wait a bounded moment, and leave. Stragglers have
308
+ their results collected either way, because a task carrying an unretrieved
309
+ exception prints one at exit — which is how a clean shutdown ends up looking like
310
+ a crash.
311
+
312
+ **Stopping is signal-driven, not exception-driven.** A bare ``KeyboardInterrupt``
313
+ unwinds from wherever the interpreter happened to be, which is not necessarily a
314
+ point where the running coroutine's cleanup can complete; a loop signal handler
315
+ delivers cancellation at an await instead, so every ``finally`` on the way out
316
+ actually runs.
317
+
318
+ **Nothing here trusts the loop to stay alive.** Third-party code called from a
319
+ coroutine can block it, and every loop-scheduled bound fails together when that
320
+ happens. A daemon watchdog thread, armed the moment a stop begins, is what makes
321
+ the guarantee hold anyway.
322
+
323
+ The cancellation is re-raised as ``KeyboardInterrupt`` so callers keep one thing to
324
+ catch however the stop arrived.
325
+
326
+ Trade-clock safe: stdlib only, no unbounded waits.
327
+ """
328
+ loop = asyncio.new_event_loop()
329
+ stopping = [] # non-empty once teardown has begun
330
+ loop.set_exception_handler(_make_exception_handler(stopping))
331
+ stop = None
332
+ try:
333
+ asyncio.set_event_loop(loop)
334
+ task = loop.create_task(coro)
335
+ stop = _Stop(
336
+ loop=loop,
337
+ task=task,
338
+ abandoned=asyncio.Event(),
339
+ teardown_timeout=teardown_timeout,
340
+ hard_exit_grace=hard_exit_grace,
341
+ )
342
+ stop.install_signals(stop_signals)
343
+ try:
344
+ return loop.run_until_complete(_await_bounded(task, stop.abandoned))
345
+ except asyncio.CancelledError:
346
+ # Only translate a stop we asked for. A CancelledError from anywhere else
347
+ # is a real outcome and must not be disguised as an interrupt.
348
+ if stop.signalled:
349
+ raise KeyboardInterrupt from None
350
+ raise
351
+ except _Abandoned:
352
+ logger.warning(
353
+ "The session did not finish unwinding within %gs; abandoning it.",
354
+ teardown_timeout,
355
+ )
356
+ raise KeyboardInterrupt from None
357
+ finally:
358
+ stop.remove_signals()
359
+ finally:
360
+ stopping.append(True)
361
+ try:
362
+ _drain(loop, teardown_timeout)
363
+ finally:
364
+ _silence_finalizer_noise()
365
+ with contextlib.suppress(Exception):
366
+ loop.run_until_complete(loop.shutdown_asyncgens())
367
+ # Deliberately not shutdown_default_executor(): it waits for the worker
368
+ # threads, and the one case that matters here is a worker blocked in a
369
+ # broker call. Waiting for it is the hang this whole path exists to avoid.
370
+ asyncio.set_event_loop(None)
371
+ loop.close()
372
+ if stop is not None:
373
+ # Everything that had to happen has happened; the watchdog would only
374
+ # fire on a process that is already leaving.
375
+ stop.disarm()
376
+
377
+
378
+ def _make_exception_handler(stopping: list):
379
+ """Quiet the two exceptions that only ever mean "we are stopping".
380
+
381
+ A task that finished before the drain began is already gone from
382
+ ``asyncio.all_tasks``, so its result can no longer be collected by hand - and an
383
+ unretrieved one is dumped to stderr with a full traceback at interpreter exit.
384
+ After a Ctrl-C that is pure noise, and it reads as a crash on the way out of a
385
+ clean shutdown.
386
+
387
+ A task still pending when the loop closes is reported a second time by its
388
+ destructor. That one is genuinely worth knowing, which is why :func:`_drain` logs
389
+ a plain line counting them; repeating it as a traceback adds nothing.
390
+
391
+ Narrow on purpose. Only ``KeyboardInterrupt`` and ``CancelledError``, and only the
392
+ destructor notice, and only once teardown has begun. Everything else reaches the
393
+ default handler, because a shutdown that hides real failures is worse than one
394
+ that prints something ugly.
395
+ """
396
+
397
+ def handler(loop, context) -> None:
398
+ exception = context.get("exception")
399
+ if stopping and isinstance(exception, (KeyboardInterrupt, asyncio.CancelledError)):
400
+ logger.debug("Ignoring %s surfaced during shutdown", type(exception).__name__)
401
+ return
402
+ if stopping and "was destroyed but it is pending" in str(context.get("message", "")):
403
+ logger.debug("Pending task destroyed during shutdown: %s", context.get("task"))
404
+ return
405
+ loop.default_exception_handler(context)
406
+
407
+ return handler
408
+
409
+
410
+ def _retrieve(task) -> None:
411
+ """Collect a finished task's outcome so nothing is reported as never retrieved."""
412
+ if task.done() and not task.cancelled():
413
+ task.exception()
414
+
415
+
416
+ #: Finalizer errors that only ever mean "the loop this object belonged to is gone".
417
+ #: Abandoned websockets are collected after the loop closes, and their cleanup asks
418
+ #: for a loop that no longer exists.
419
+ _FINALIZER_NOISE = ("no running event loop", "Event loop is closed")
420
+
421
+
422
+ def _silence_finalizer_noise() -> None:
423
+ """Drop finalizer errors from objects whose loop has been closed.
424
+
425
+ A stream we gave up on is still a live object, and whatever collects it later —
426
+ possibly at interpreter exit, after everything has been reported — tries to touch
427
+ its loop and fails. Python prints that as ``Exception ignored in: ...``, which is
428
+ alarming punctuation at the end of an orderly shutdown.
429
+
430
+ Installed only on the way out, and narrow: anything that is not one of these two
431
+ RuntimeErrors still reaches the default hook. The hook is deliberately left in
432
+ place rather than restored, because the collections that produce this noise happen
433
+ after every restore point there is.
434
+ """
435
+ previous = sys.unraisablehook
436
+
437
+ def hook(unraisable) -> None:
438
+ exception = unraisable.exc_value
439
+ if isinstance(exception, RuntimeError) and any(
440
+ phrase in str(exception) for phrase in _FINALIZER_NOISE
441
+ ):
442
+ logger.debug("Ignoring finalizer noise from a closed loop: %s", exception)
443
+ return
444
+ previous(unraisable)
445
+
446
+ sys.unraisablehook = hook
447
+
448
+
449
+ def _drain(loop, timeout: float) -> None:
450
+ """Cancel everything still running and give it a bounded moment to unwind."""
451
+ pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
452
+ if not pending:
453
+ return
454
+ for task in pending:
455
+ task.cancel()
456
+ try:
457
+ loop.run_until_complete(asyncio.wait(pending, timeout=timeout))
458
+ except Exception: # noqa: BLE001 - shutdown must not raise over the real outcome
459
+ logger.debug("Exception while draining pending tasks", exc_info=True)
460
+ except KeyboardInterrupt:
461
+ # Deliberately not caught: a second interrupt during the drain means "now",
462
+ # and skipping straggler retrieval to honour that is the right trade. The
463
+ # loop is still closed by the caller's finally.
464
+ raise
465
+ stuck = 0
466
+ for task in pending:
467
+ if task.done():
468
+ _retrieve(task)
469
+ else:
470
+ stuck += 1
471
+ # It will never be awaited, so claim its outcome in advance.
472
+ task.add_done_callback(_retrieve)
473
+ if stuck:
474
+ logger.warning("%d background task(s) did not stop within %gs; exiting anyway.", stuck, timeout)
@@ -0,0 +1,148 @@
1
+ """Timezone helpers.
2
+
3
+ US equity sessions are reasoned about in America/New_York, so all market-data
4
+ timestamps are normalized to that zone in one place.
5
+ """
6
+
7
+ from datetime import datetime, timedelta
8
+ from typing import Optional
9
+
10
+ import pandas as pd
11
+ import pytz
12
+
13
+ #: The exchange timezone used throughout the project.
14
+ NEW_YORK = pytz.timezone("America/New_York")
15
+
16
+
17
+ def localize_index_to_new_york(data: pd.DataFrame) -> pd.DataFrame:
18
+ """Return ``data`` with a tz-aware DatetimeIndex in New York time.
19
+
20
+ Naive indexes are assumed to be UTC (Alpaca returns UTC), then converted.
21
+ """
22
+ if data.empty:
23
+ return data
24
+
25
+ index = data.index
26
+ if index.tz is None:
27
+ index = pd.to_datetime(index, utc=True)
28
+ data = data.copy()
29
+ data.index = index.tz_convert(NEW_YORK)
30
+ return data
31
+
32
+
33
+ def match_index_tz(timestamp, index: pd.Index):
34
+ """Return ``timestamp`` made comparable with ``index``.
35
+
36
+ A live feed is free to serialize one bar naive and the next tz-aware, and the
37
+ warm-up history is always localized. Mixing the two in a single index makes every
38
+ later comparison raise ``TypeError`` — which, inside a callback that swallows
39
+ exceptions, presents as a strategy that has simply stopped emitting signals.
40
+
41
+ Naive input is assumed to be UTC, matching how bars are read everywhere else, and
42
+ then converted to whatever the index already uses. An empty or non-datetime index
43
+ imposes nothing, so the timestamp is returned untouched.
44
+ """
45
+ ts = pd.Timestamp(timestamp)
46
+ index_tz = getattr(index, "tz", None)
47
+ if index_tz is None:
48
+ # A naive index: strip any zone rather than poison it with an aware value.
49
+ return ts.tz_convert(None) if ts.tz is not None else ts
50
+ ts = ts.tz_localize("UTC") if ts.tz is None else ts
51
+ return ts.tz_convert(index_tz)
52
+
53
+
54
+ def parse_market_date(value: str) -> datetime:
55
+ """Parse a date or ISO datetime into a New York instant.
56
+
57
+ **One date contract.** ``2026-08-22`` means that market date in the exchange's own
58
+ zone, everywhere in this program and over every surface. That is a domain decision:
59
+ someone typing a date at a trading tool means the session, not UTC midnight.
60
+
61
+ Leaving the value naive and letting each consumer localize it is what produced the
62
+ bug this replaced. ``cache warm --end 2026-08-22`` recorded coverage through
63
+ 00:00Z (the store reads a naive datetime as UTC) while ``scan --as-of 2026-08-22``
64
+ asked for 04:00Z (the scanner reads one as New York), so a cache holding exactly
65
+ the right daily bar reported a four-hour hole and refused an offline run whose
66
+ data was complete. Two readings of one string is a semantic fork; it surfaced in
67
+ the cache first and would have surfaced next in reports, or in live/backtest
68
+ parity.
69
+
70
+ Returning an *aware* value is what stops the contract re-forking: a naive datetime
71
+ carries no zone to disagree about because it carries no zone at all.
72
+
73
+ The CLI and the MCP server both call this. They used to parse separately — the MCP
74
+ tool left the value naive — and the window's text is part of a trial's dedup
75
+ identity, so one request over the two surfaces journaled two trials and never found
76
+ the other's memoized answer.
77
+ """
78
+ try:
79
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
80
+ except ValueError:
81
+ try:
82
+ parsed = datetime.strptime(value, "%Y-%m-%d")
83
+ except ValueError:
84
+ # strptime's own text names only "%Y-%m-%d" and never the value's role; this
85
+ # is the message every surface shows, the MCP server's included.
86
+ raise ValueError(
87
+ f"expected a date as YYYY-MM-DD (or an ISO time such as 2024-01-02T09:30), got {value!r}"
88
+ ) from None
89
+ if parsed.tzinfo is None:
90
+ return NEW_YORK.localize(parsed)
91
+ return parsed.astimezone(NEW_YORK)
92
+
93
+
94
+ def _day(moment: datetime) -> str:
95
+ """A bound as the reader typed it: the date alone when it is a midnight."""
96
+ if (moment.hour, moment.minute, moment.second, moment.microsecond) == (0, 0, 0, 0):
97
+ return f"{moment:%Y-%m-%d}"
98
+ return moment.isoformat(timespec="minutes")
99
+
100
+
101
+ def window_refusal(start: datetime, end: datetime, now: Optional[datetime] = None) -> Optional[str]:
102
+ """Why ``[start, end]`` cannot be a research window, or ``None`` when it can.
103
+
104
+ Neither case was refused anywhere. The bar store read a reversed window as an
105
+ ordinary cache miss and printed a warm command that then reported the empty range
106
+ "already cached"; a window starting in the future printed one that could never
107
+ succeed. And a verdict over a reversed window was journaled as a trial, raising the
108
+ multiple-testing count for a run that measured nothing.
109
+ """
110
+ if end == start:
111
+ return f"the window is empty: it starts and ends at {_day(start)}; the end must come after the start."
112
+ if end < start:
113
+ return f"the window ends ({_day(end)}) before it starts ({_day(start)}); the end must come after the start."
114
+ if now is None:
115
+ # Library callers still pass naive values; compare like with like.
116
+ now = datetime.now(NEW_YORK) if start.tzinfo else datetime.now()
117
+ if start >= now:
118
+ return f"the window starts in the future ({_day(start)}); there are no bars for it yet."
119
+ return None
120
+
121
+
122
+ def shift_days(moment, days: int):
123
+ """``moment`` moved by whole calendar days, keeping its wall-clock time in its zone.
124
+
125
+ ``aware + timedelta`` keeps a pytz value's original UTC offset, so New York midnight
126
+ in January plus 150 days came out as ``00:00-05:00`` in June - 01:00 New York, an
127
+ hour past midnight. Every walk-forward boundary from March to November sat there,
128
+ and a window starting on a day then skipped that day's bar (daily bars are stamped
129
+ at New York midnight). pandas shifts the instant instead, with the same hour of
130
+ drift. A boundary is a market date, so the shift is done on the wall clock and the
131
+ zone reapplied. ``zoneinfo`` values already behave this way and are left to it.
132
+ """
133
+ if isinstance(moment, pd.Timestamp):
134
+ if moment.tzinfo is None:
135
+ return moment + pd.Timedelta(days=days)
136
+ # Resolve a wall time that does not exist or occurs twice the way pytz's
137
+ # localize does for a datetime (read as standard time), so both kinds agree
138
+ # instead of one raising on the transition hour. A skipped wall time read as
139
+ # standard time is the instant one hour on.
140
+ return (moment.tz_localize(None) + pd.Timedelta(days=days)).tz_localize(
141
+ moment.tz, nonexistent=pd.Timedelta(hours=1), ambiguous=False
142
+ )
143
+ if moment.tzinfo is None:
144
+ return moment + timedelta(days=days)
145
+ zone = getattr(moment.tzinfo, "zone", None)
146
+ if zone:
147
+ return pytz.timezone(zone).localize(moment.replace(tzinfo=None) + timedelta(days=days))
148
+ return moment + timedelta(days=days)