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,415 @@
1
+ """Alpaca implementation of the :class:`Broker` interface.
2
+
3
+ Along with :mod:`quantred.brokers.alpaca.factory`, this is the only place where
4
+ ``alpaca-py`` trading types are imported. It maps
5
+ Alpaca SDK objects to the broker-agnostic domain types in
6
+ :mod:`quantred.brokers.base` so the rest of the system stays vendor-neutral.
7
+ """
8
+
9
+ import inspect
10
+ import logging
11
+ from typing import List, Optional
12
+
13
+ from alpaca.common.exceptions import APIError, RetryException
14
+ from alpaca.trading.client import TradingClient
15
+ from alpaca.trading.enums import OrderClass, OrderType, QueryOrderStatus, TimeInForce
16
+ from alpaca.trading.enums import OrderSide as AlpacaOrderSide
17
+ from alpaca.trading.requests import (
18
+ GetOrdersRequest,
19
+ MarketOrderRequest,
20
+ OrderRequest,
21
+ StopLossRequest,
22
+ TakeProfitRequest,
23
+ )
24
+
25
+ from quantred.brokers.base import (
26
+ AccountSnapshot,
27
+ Broker,
28
+ MarketStatus,
29
+ OrderResult,
30
+ OrderSide,
31
+ Position,
32
+ TradeUpdate,
33
+ TradeUpdateHandler,
34
+ )
35
+ from quantred.brokers.errors import (
36
+ AuthenticationError,
37
+ BrokerError,
38
+ BrokerUnavailableError,
39
+ DuplicateOrderError,
40
+ InsufficientFundsError,
41
+ MarketClosedError,
42
+ NotTradableError,
43
+ OrderRejectedError,
44
+ RateLimitedError,
45
+ )
46
+ from quantred.utils.numeric import safe_float
47
+ from quantred.utils.streaming import close_stream, run_with_reconnect
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ # Map our side enum onto Alpaca's.
52
+ _SIDE_TO_ALPACA = {OrderSide.BUY: AlpacaOrderSide.BUY, OrderSide.SELL: AlpacaOrderSide.SELL}
53
+
54
+
55
+ #: Substrings that identify a duplicate-client-order-id rejection. Alpaca reports it
56
+ #: as a 422 whose message names the field; matching on the text is a heuristic, so an
57
+ #: unrecognized phrasing degrades to a plain rejection rather than being misread as a
58
+ #: success.
59
+ _DUPLICATE_MARKERS = ("already exist", "must be unique", "duplicate")
60
+
61
+
62
+ def _rejected_credentials(status: int, message: str) -> str:
63
+ """What a user should read when the venue refuses their keys.
64
+
65
+ The market-data gateway answers a bad key with a bare HTML page, and that page was
66
+ the whole of the error a verdict printed - three times - without ever saying the
67
+ keys were the problem. The vendor's text is kept only when it is plain text.
68
+ """
69
+ from quantred.settings import invocation, paper_trade_mode
70
+
71
+ detail = message.strip()
72
+ detail = f": {detail}" if detail and not detail.startswith("<") and len(detail) <= 200 else ""
73
+ # The section that matches the account this run is configured for: telling a live
74
+ # user to fetch paper keys would send them to the wrong pair.
75
+ section = "Paper Account" if paper_trade_mode() else "Live Account"
76
+ return (
77
+ f"Alpaca rejected these credentials (HTTP {status}{detail}). Check that the key and "
78
+ f"secret came from the {section} section - paper and live keys differ - "
79
+ f"then run `{invocation('init')}`."
80
+ )
81
+
82
+
83
+ def classify_error(exc: Exception) -> BrokerError:
84
+ """Map a vendor exception onto the vendor-neutral hierarchy.
85
+
86
+ Defensive by construction: ``APIError`` exposes ``status_code`` and ``message`` as
87
+ properties that parse the underlying payload, and either can fail on a malformed
88
+ or non-JSON error body. A classifier that raises while classifying would replace a
89
+ diagnosable broker failure with an undiagnosable one.
90
+ """
91
+ if isinstance(exc, BrokerError):
92
+ return exc
93
+
94
+ status = None
95
+ message = str(exc)
96
+ if isinstance(exc, APIError):
97
+ try:
98
+ status = exc.status_code
99
+ except Exception: # noqa: BLE001 - never fail while classifying a failure
100
+ status = None
101
+ try:
102
+ message = exc.message or message
103
+ except Exception: # noqa: BLE001
104
+ pass
105
+ elif isinstance(exc, RetryException):
106
+ return RateLimitedError(message)
107
+ elif isinstance(exc, (ConnectionError, TimeoutError, OSError)):
108
+ return BrokerUnavailableError(message)
109
+
110
+ lowered = message.lower()
111
+ if "client_order_id" in lowered and any(m in lowered for m in _DUPLICATE_MARKERS):
112
+ return DuplicateOrderError(message)
113
+ if status in (401, 403):
114
+ return AuthenticationError(_rejected_credentials(status, message))
115
+ if status == 429:
116
+ return RateLimitedError(message)
117
+ if status is not None and status >= 500:
118
+ return BrokerUnavailableError(message)
119
+ if "insufficient" in lowered or "buying power" in lowered:
120
+ return InsufficientFundsError(message)
121
+ if "market is closed" in lowered or "outside of market hours" in lowered:
122
+ return MarketClosedError(message)
123
+ if "not tradable" in lowered or "not active" in lowered:
124
+ return NotTradableError(message)
125
+ if status is not None and 400 <= status < 500:
126
+ return OrderRejectedError(message)
127
+ return BrokerError(message)
128
+
129
+
130
+ def _enum_value(obj, name: str) -> str:
131
+ """Read an SDK field that may be an enum, a bare string, or absent."""
132
+ raw = getattr(obj, name, "")
133
+ return str(getattr(raw, "value", raw) or "")
134
+
135
+
136
+ def _timestamp(value) -> Optional[str]:
137
+ """An ISO timestamp from whatever shape the SDK hands over, or None."""
138
+ if value is None:
139
+ return None
140
+ return value.isoformat() if hasattr(value, "isoformat") else str(value)
141
+
142
+
143
+ def _optional_float(value) -> Optional[float]:
144
+ """A float, or ``None`` when the venue did not report the field at all.
145
+
146
+ ``safe_float`` defaults to ``0.0``, which is right for a quantity and wrong for
147
+ any field whose contract says ``None`` means "not reported" - it turns a paper
148
+ account's silence about fees into an *observed* zero fee, which ``cost_summary``
149
+ then counts and reports as measured. The ledger it lands in is append-only, so
150
+ there is nothing behind it to correct the record from.
151
+ """
152
+ if value is None or value == "":
153
+ return None
154
+ try:
155
+ return float(value)
156
+ except (TypeError, ValueError):
157
+ return None
158
+
159
+
160
+ def to_trade_update(data) -> TradeUpdate:
161
+ """Convert one Alpaca trade-update payload into the project's TradeUpdate.
162
+
163
+ Split out of the stream callback so the mapping can be tested without a socket.
164
+ It was previously a closure, and the field it silently failed to carry - ``side`` -
165
+ was therefore unreachable from any test: every fill downstream defaulted to buy,
166
+ and the ledger recorded every short as a long.
167
+
168
+ ``filled_qty`` is Alpaca's *cumulative* fill for the order, not this event's
169
+ increment, and is passed on as such.
170
+ """
171
+ order = getattr(data, "order", None)
172
+ return TradeUpdate(
173
+ event=str(getattr(data, "event", "")),
174
+ symbol=getattr(order, "symbol", ""),
175
+ order_id=str(getattr(order, "id", "")),
176
+ status=_enum_value(order, "status"),
177
+ filled_qty=safe_float(getattr(order, "filled_qty", 0)),
178
+ price=safe_float(getattr(data, "price", None)) if getattr(data, "price", None) else None,
179
+ side=_enum_value(order, "side") or None,
180
+ filled_avg_price=_optional_float(getattr(order, "filled_avg_price", None)),
181
+ filled_at=_timestamp(getattr(data, "timestamp", None))
182
+ or _timestamp(getattr(order, "filled_at", None)),
183
+ # Not ``safe_float``: a venue that reports no fee has not reported a fee of
184
+ # zero, and the two must stay distinguishable all the way to the ledger.
185
+ fee=_optional_float(getattr(data, "fee", None)),
186
+ )
187
+
188
+
189
+ class AlpacaBroker(Broker):
190
+ """Trade and account operations backed by an Alpaca ``TradingClient``."""
191
+
192
+ def __init__(
193
+ self,
194
+ trading_client: TradingClient,
195
+ api_key: Optional[str] = None,
196
+ api_secret: Optional[str] = None,
197
+ paper: bool = True,
198
+ ):
199
+ self._client = trading_client
200
+ self._tradable_cache: dict[str, bool] = {}
201
+ # Credentials are only needed for the trade-update WebSocket.
202
+ self._api_key = api_key
203
+ self._api_secret = api_secret
204
+ self._paper = paper
205
+
206
+ # ------------------------------------------------------------------ #
207
+ # Account & positions
208
+ # ------------------------------------------------------------------ #
209
+ def get_account(self) -> Optional[AccountSnapshot]:
210
+ try:
211
+ account = self._client.get_account()
212
+ return AccountSnapshot(
213
+ cash=safe_float(account.cash),
214
+ equity=safe_float(account.equity),
215
+ buying_power=safe_float(account.buying_power),
216
+ portfolio_value=safe_float(account.portfolio_value),
217
+ trading_blocked=bool(account.trading_blocked),
218
+ )
219
+ except Exception as exc:
220
+ raise classify_error(exc) from exc
221
+
222
+ def list_positions(self) -> List[Position]:
223
+ # Raises rather than returning [] on failure: an empty list is a factual claim
224
+ # that the account holds nothing, and callers act on it — the strategy's book
225
+ # is rebuilt from this, and the ledger reads it as broker truth. Turning an
226
+ # unreachable broker into "you are flat" is how a real position stops being
227
+ # exitable.
228
+ try:
229
+ return [self._to_position(p) for p in self._client.get_all_positions()]
230
+ except Exception as exc:
231
+ raise classify_error(exc) from exc
232
+
233
+ def get_position(self, symbol: str) -> Optional[Position]:
234
+ try:
235
+ return self._to_position(self._client.get_open_position(symbol))
236
+ except Exception: # noqa: BLE001 - "no position" is an expected 404
237
+ return None
238
+
239
+ def is_tradable(self, symbol: str) -> bool:
240
+ if symbol not in self._tradable_cache:
241
+ try:
242
+ self._tradable_cache[symbol] = bool(self._client.get_asset(symbol).tradable)
243
+ except Exception as exc: # noqa: BLE001
244
+ logger.info("Could not determine tradability for %s: %s", symbol, exc)
245
+ self._tradable_cache[symbol] = False
246
+ return self._tradable_cache[symbol]
247
+
248
+ # ------------------------------------------------------------------ #
249
+ # Orders
250
+ # ------------------------------------------------------------------ #
251
+ def submit_market_order(
252
+ self, symbol: str, qty: float, side: OrderSide, client_order_id: Optional[str] = None
253
+ ) -> Optional[OrderResult]:
254
+ try:
255
+ order = self._client.submit_order(
256
+ MarketOrderRequest(
257
+ symbol=symbol,
258
+ qty=qty,
259
+ side=_SIDE_TO_ALPACA[side],
260
+ time_in_force=TimeInForce.GTC,
261
+ client_order_id=client_order_id,
262
+ )
263
+ )
264
+ return self._to_order_result(order, side)
265
+ except Exception as exc:
266
+ raise classify_error(exc) from exc
267
+
268
+ def submit_bracket_order(
269
+ self,
270
+ symbol: str,
271
+ qty: float,
272
+ side: OrderSide,
273
+ stop_loss: float,
274
+ take_profit: float,
275
+ client_order_id: Optional[str] = None,
276
+ ) -> Optional[OrderResult]:
277
+ try:
278
+ order = self._client.submit_order(
279
+ OrderRequest(
280
+ symbol=symbol,
281
+ qty=qty,
282
+ side=_SIDE_TO_ALPACA[side],
283
+ type=OrderType.MARKET,
284
+ time_in_force=TimeInForce.GTC,
285
+ order_class=OrderClass.BRACKET,
286
+ take_profit=TakeProfitRequest(limit_price=take_profit),
287
+ stop_loss=StopLossRequest(stop_price=stop_loss),
288
+ client_order_id=client_order_id,
289
+ )
290
+ )
291
+ return self._to_order_result(order, side)
292
+ except Exception as exc:
293
+ raise classify_error(exc) from exc
294
+
295
+ def list_open_orders(self, symbol: Optional[str] = None) -> List[OrderResult]:
296
+ try:
297
+ request = GetOrdersRequest(
298
+ status=QueryOrderStatus.OPEN,
299
+ symbols=[symbol] if symbol else None,
300
+ )
301
+ orders = self._client.get_orders(filter=request)
302
+ return [
303
+ OrderResult(
304
+ id=str(o.id),
305
+ symbol=o.symbol,
306
+ side=OrderSide(o.side.value),
307
+ qty=safe_float(o.qty),
308
+ status=str(getattr(o.status, "value", o.status)),
309
+ raw=o,
310
+ )
311
+ for o in orders
312
+ ]
313
+ except Exception as exc: # noqa: BLE001
314
+ logger.error("Failed to list open orders: %s", exc)
315
+ return []
316
+
317
+ def cancel_order(self, order_id: str) -> bool:
318
+ try:
319
+ self._client.cancel_order_by_id(order_id)
320
+ return True
321
+ except Exception as exc:
322
+ raise classify_error(exc) from exc
323
+
324
+ def cancel_all_orders(self) -> bool:
325
+ try:
326
+ self._client.cancel_orders()
327
+ return True
328
+ except Exception as exc:
329
+ raise classify_error(exc) from exc
330
+
331
+ # ------------------------------------------------------------------ #
332
+ # Position lifecycle
333
+ # ------------------------------------------------------------------ #
334
+ def close_position(self, symbol: str) -> bool:
335
+ try:
336
+ self._client.close_position(symbol)
337
+ return True
338
+ except Exception as exc:
339
+ raise classify_error(exc) from exc
340
+
341
+ def close_all_positions(self, cancel_orders: bool = True) -> bool:
342
+ try:
343
+ self._client.close_all_positions(cancel_orders=cancel_orders)
344
+ return True
345
+ except Exception as exc:
346
+ raise classify_error(exc) from exc
347
+
348
+ # ------------------------------------------------------------------ #
349
+ # Market clock
350
+ # ------------------------------------------------------------------ #
351
+ def get_market_status(self) -> Optional[MarketStatus]:
352
+ try:
353
+ clock = self._client.get_clock()
354
+ return MarketStatus(
355
+ timestamp=clock.timestamp,
356
+ is_open=clock.is_open,
357
+ next_open=clock.next_open,
358
+ next_close=clock.next_close,
359
+ )
360
+ except Exception as exc:
361
+ raise classify_error(exc) from exc
362
+
363
+ # ------------------------------------------------------------------ #
364
+ # Trade-update streaming
365
+ # ------------------------------------------------------------------ #
366
+ def supports_trade_updates(self) -> bool:
367
+ return bool(self._api_key and self._api_secret)
368
+
369
+ async def stream_trade_updates(self, handler: TradeUpdateHandler) -> None:
370
+ from alpaca.trading.stream import TradingStream
371
+
372
+ async def on_alpaca_update(data) -> None:
373
+ update = to_trade_update(data)
374
+ result = handler(update)
375
+ if inspect.isawaitable(result):
376
+ await result
377
+
378
+ async def connect() -> None:
379
+ stream = TradingStream(self._api_key, self._api_secret, paper=self._paper)
380
+ try:
381
+ stream.subscribe_trade_updates(on_alpaca_update)
382
+ logger.info("Subscribed to trade updates")
383
+ await stream._run_forever()
384
+ finally:
385
+ # Never the SDK's synchronous stop() from inside the loop; it blocks
386
+ # the very loop that would have to run its close. See close_stream.
387
+ await close_stream(stream)
388
+
389
+ await run_with_reconnect("trade-updates", connect)
390
+
391
+ # ------------------------------------------------------------------ #
392
+ # Mapping helpers
393
+ # ------------------------------------------------------------------ #
394
+ @staticmethod
395
+ def _to_position(pos) -> Position:
396
+ return Position(
397
+ symbol=pos.symbol,
398
+ qty=abs(safe_float(pos.qty)),
399
+ side=pos.side.value, # "long" / "short"
400
+ avg_entry_price=safe_float(pos.avg_entry_price),
401
+ current_price=safe_float(pos.current_price),
402
+ market_value=safe_float(pos.market_value),
403
+ unrealized_pl=safe_float(pos.unrealized_pl),
404
+ )
405
+
406
+ @staticmethod
407
+ def _to_order_result(order, side: OrderSide) -> OrderResult:
408
+ return OrderResult(
409
+ id=str(order.id),
410
+ symbol=order.symbol,
411
+ side=side,
412
+ qty=safe_float(order.qty),
413
+ status=str(getattr(order.status, "value", order.status)),
414
+ raw=order,
415
+ )
@@ -0,0 +1,33 @@
1
+ """Factories that build Alpaca-backed brokers and market-data providers.
2
+
3
+ Callers above the broker layer (the CLI, services, account scripts) construct
4
+ Alpaca objects exclusively through these functions, so the ``alpaca`` SDK is
5
+ never imported outside :mod:`quantred.brokers.alpaca`.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from alpaca.data.historical import StockHistoricalDataClient
11
+ from alpaca.trading.client import TradingClient
12
+
13
+ from quantred.brokers.alpaca.broker import AlpacaBroker
14
+ from quantred.brokers.alpaca.market_data import AlpacaMarketData
15
+
16
+
17
+ def build_market_data(api_key: str, api_secret: str, feed: Optional[str] = None) -> AlpacaMarketData:
18
+ """Build the historical/live market-data provider.
19
+
20
+ Constructs no trading client: a process holding only this object is
21
+ structurally incapable of placing orders.
22
+
23
+ ``feed`` pins both the historical and streaming halves to one Alpaca feed;
24
+ ``None`` leaves the SDK's defaults, which is the historical behaviour.
25
+ """
26
+ historical = StockHistoricalDataClient(api_key, api_secret)
27
+ return AlpacaMarketData(historical, api_key, api_secret, feed=feed)
28
+
29
+
30
+ def build_broker(api_key: str, api_secret: str, paper: bool = True) -> AlpacaBroker:
31
+ """Build the trading broker (account, orders, positions, trade updates)."""
32
+ trading_client = TradingClient(api_key=api_key, secret_key=api_secret, paper=paper)
33
+ return AlpacaBroker(trading_client, api_key, api_secret, paper)
@@ -0,0 +1,166 @@
1
+ """Alpaca implementation of the :class:`MarketDataProvider` interface.
2
+
3
+ Along with :mod:`quantred.brokers.alpaca.factory`, the only place where ``alpaca-py``
4
+ market-data types are imported. Converts
5
+ the project's :class:`Timeframe` into Alpaca's ``TimeFrame`` and normalizes the
6
+ returned bars into per-symbol, NY-localized OHLCV frames.
7
+ """
8
+
9
+ import inspect
10
+ import logging
11
+ from datetime import datetime
12
+ from typing import Dict, List, Optional
13
+
14
+ import pandas as pd
15
+ from alpaca.data.enums import DataFeed
16
+ from alpaca.data.historical import StockHistoricalDataClient
17
+ from alpaca.data.live import StockDataStream
18
+ from alpaca.data.requests import StockBarsRequest
19
+ from alpaca.data.timeframe import TimeFrame, TimeFrameUnit
20
+
21
+ from quantred.brokers.alpaca.broker import classify_error
22
+ from quantred.marketdata.base import BarEvent, BarHandler, MarketDataProvider
23
+ from quantred.marketdata.timeframe import DAY, HOUR, MINUTE, WEEK, Timeframe
24
+ from quantred.utils.streaming import close_stream, run_with_reconnect
25
+ from quantred.utils.timeutils import localize_index_to_new_york
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Map our canonical timeframe units onto Alpaca's TimeFrameUnit.
30
+ _UNIT_TO_ALPACA = {
31
+ MINUTE: TimeFrameUnit.Minute,
32
+ HOUR: TimeFrameUnit.Hour,
33
+ DAY: TimeFrameUnit.Day,
34
+ WEEK: TimeFrameUnit.Week,
35
+ }
36
+
37
+
38
+ class AlpacaMarketData(MarketDataProvider):
39
+ """Historical + live equity bars from Alpaca."""
40
+
41
+ def __init__(
42
+ self,
43
+ historical_client: StockHistoricalDataClient,
44
+ api_key: str,
45
+ api_secret: str,
46
+ base_reconnect_delay: float = 5.0,
47
+ max_reconnect_delay: float = 60.0,
48
+ feed: Optional[str] = None,
49
+ ):
50
+ self._historical = historical_client
51
+ self._api_key = api_key
52
+ self._api_secret = api_secret
53
+ # One feed for both halves when pinned. Left unset the SDK's own defaults
54
+ # apply, and those disagree with each other: historical resolves to the full
55
+ # consolidated tape while the stream defaults to a single venue. An account
56
+ # entitled to one and not the other then warms up on nothing and streams
57
+ # fine, which reads as an empty market rather than as a wrong feed.
58
+ self._feed = feed
59
+ self._base_reconnect_delay = base_reconnect_delay
60
+ self._max_reconnect_delay = max_reconnect_delay
61
+
62
+ def get_bars(
63
+ self, symbols: List[str], timeframe: Timeframe, start: datetime, end: datetime
64
+ ) -> Dict[str, pd.DataFrame]:
65
+ if not symbols:
66
+ return {}
67
+
68
+ request = StockBarsRequest(
69
+ symbol_or_symbols=list(symbols),
70
+ timeframe=self._to_alpaca_timeframe(timeframe),
71
+ start=start,
72
+ end=end,
73
+ adjustment="split",
74
+ **({"feed": DataFeed(self._feed)} if self._feed else {}),
75
+ )
76
+
77
+ try:
78
+ combined = self._historical.get_stock_bars(request).df
79
+ except Exception as exc: # noqa: BLE001 - re-raised as a typed failure below
80
+ # Raised, not swallowed into an empty result. A fetch that could not happen
81
+ # and a window that genuinely held no bars are different facts, and
82
+ # returning {} for both made them the same one: an unreachable provider
83
+ # produced a backtest with no trades, which was journaled as an evaluated
84
+ # trial and then served from cache to the next identical run. Absent is not
85
+ # zero, and unreachable is not absent.
86
+ raise classify_error(exc) from exc
87
+
88
+ if combined is None or combined.empty:
89
+ return {}
90
+
91
+ return self._split_by_symbol(combined)
92
+
93
+ async def stream_bars(self, symbols: List[str], handler: BarHandler) -> None:
94
+ """Stream live bars for ``symbols``, reconnecting on failure.
95
+
96
+ Live WebSockets drop. Each attempt creates a *fresh* stream (the SDK can
97
+ be left in a bad state after an error), subscribes the monitored symbols,
98
+ and runs until it errors; on error we back off (capped exponential) and
99
+ reconnect. Cancellation (e.g. Ctrl-C) breaks out cleanly.
100
+ """
101
+ on_bar = self._make_bar_callback(handler)
102
+
103
+ async def connect() -> None:
104
+ stream = self._new_stream()
105
+ try:
106
+ for symbol in symbols:
107
+ stream.subscribe_bars(on_bar, symbol)
108
+ logger.info("Subscribed to live bars for %d symbols: %s", len(symbols), symbols)
109
+ # _run_forever is the awaitable entry point (run() wraps it in
110
+ # asyncio.run, which we can't use inside an existing loop).
111
+ await stream._run_forever()
112
+ finally:
113
+ await self._safe_stop(stream)
114
+
115
+ await run_with_reconnect(
116
+ "market-data",
117
+ connect,
118
+ base_delay=self._base_reconnect_delay,
119
+ max_delay=self._max_reconnect_delay,
120
+ )
121
+
122
+ def _make_bar_callback(self, handler: BarHandler):
123
+ """Wrap a project BarHandler as an async Alpaca bar callback."""
124
+
125
+ async def _on_alpaca_bar(bar) -> None:
126
+ event = BarEvent(
127
+ symbol=bar.symbol,
128
+ timestamp=bar.timestamp,
129
+ open=bar.open,
130
+ high=bar.high,
131
+ low=bar.low,
132
+ close=bar.close,
133
+ volume=bar.volume,
134
+ )
135
+ result = handler(event)
136
+ if inspect.isawaitable(result):
137
+ await result
138
+
139
+ return _on_alpaca_bar
140
+
141
+ def _new_stream(self) -> StockDataStream:
142
+ """Create a fresh stream (isolated for testability)."""
143
+ if self._feed:
144
+ return StockDataStream(self._api_key, self._api_secret, feed=DataFeed(self._feed))
145
+ return StockDataStream(self._api_key, self._api_secret)
146
+
147
+ @staticmethod
148
+ async def _safe_stop(stream) -> None:
149
+ """Best-effort stream shutdown. See :func:`close_stream` for why not ``stop()``."""
150
+ await close_stream(stream)
151
+
152
+ # ------------------------------------------------------------------ #
153
+ # Helpers
154
+ # ------------------------------------------------------------------ #
155
+ @staticmethod
156
+ def _to_alpaca_timeframe(timeframe: Timeframe) -> TimeFrame:
157
+ return TimeFrame(timeframe.amount, _UNIT_TO_ALPACA[timeframe.unit])
158
+
159
+ @staticmethod
160
+ def _split_by_symbol(combined: pd.DataFrame) -> Dict[str, pd.DataFrame]:
161
+ """Split Alpaca's (symbol, timestamp) MultiIndex frame into per-symbol frames."""
162
+ result: Dict[str, pd.DataFrame] = {}
163
+ for symbol in combined.index.get_level_values(0).unique():
164
+ frame = combined.xs(symbol, level=0).copy()
165
+ result[symbol] = localize_index_to_new_york(frame)
166
+ return result