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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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)
|