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,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
|