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,587 @@
1
+ """Live order execution.
2
+
3
+ Translates strategy signals into concrete broker orders. It is the only thing
4
+ in live mode that mutates the account, and it speaks exclusively through the
5
+ :class:`Broker` interface - so swapping venues never touches this file.
6
+
7
+ Sizing and stop/take-profit distances come from the strategy's config; placement
8
+ and account/position reads go through the broker.
9
+
10
+ It is also where the kill switch bites: a halt recorded by
11
+ :mod:`quantred.execution.halt` refuses new entries here, and deliberately never
12
+ refuses an exit.
13
+
14
+ It also owns the strategy's **position book** - the strategy's belief about what it
15
+ holds. That belief is what :meth:`Strategy.validate_signal` consults to decide
16
+ whether an exit is legitimate, so a book that is never populated silently converts
17
+ every exit into a HOLD. Hydrating it is therefore part of executing, not a
18
+ convenience: see :meth:`LiveTrader.sync_strategy_book`.
19
+ """
20
+
21
+ import logging
22
+ import time
23
+ from datetime import datetime
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from quantred.brokers.base import AccountSnapshot, Broker, OrderSide, Position
27
+ from quantred.brokers.errors import AuthenticationError, BrokerError, DuplicateOrderError
28
+ from quantred.execution import decision as decisions
29
+ from quantred.execution.decision import Decision
30
+ from quantred.execution.halt import HaltState
31
+ from quantred.execution.order_id import client_order_id
32
+ from quantred.execution.sizing import PositionSizer, RiskBasedSizer, below_min_notional
33
+ from quantred.strategies import signals
34
+ from quantred.strategies.base import Strategy
35
+ from quantred.utils.numeric import round_price, round_quantity
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Signal -> order side for new entries.
40
+ _ENTRY_SIDE = {signals.BUY: OrderSide.BUY, signals.SELL: OrderSide.SELL}
41
+
42
+ # Cache the market clock briefly so we don't query it on every streamed bar.
43
+ _MARKET_STATUS_TTL = 30.0
44
+
45
+
46
+ class LiveTrader:
47
+ """Executes signals against a broker, sizing positions via a PositionSizer."""
48
+
49
+ def __init__(
50
+ self,
51
+ broker: Broker,
52
+ strategy: Strategy,
53
+ sizer: Optional[PositionSizer] = None,
54
+ allow_fractional: bool = False,
55
+ respect_market_hours: bool = True,
56
+ halt_state: Optional[HaltState] = None,
57
+ capital: Optional[float] = None,
58
+ cost_model=None,
59
+ ):
60
+ self._broker = broker
61
+ self._strategy = strategy
62
+ #: Durable "stop trading" state. Consulted per actionable signal rather than
63
+ #: cached: promptness is the entire point of a kill switch, and the read is a
64
+ #: small local file on a path that only runs when a signal is not HOLD.
65
+ self._halts = halt_state if halt_state is not None else HaltState()
66
+ # Default to the strategy's own risk-based sizing; callers can inject a
67
+ # portfolio-weight sizer to let the portfolio manager drive live sizing.
68
+ self._sizer = sizer or RiskBasedSizer(strategy)
69
+ self._allow_fractional = allow_fractional
70
+ #: The same cost model the research clock charges, so a modelled cost in a live
71
+ #: report is comparable with the one a backtest was judged on. ``None`` simply
72
+ #: means no estimate is recorded - never a zero cost.
73
+ self._cost_model = cost_model
74
+ #: How much of the account this strategy may deploy, or ``None`` for all of it.
75
+ #: A paper account arrives with whatever equity the venue gave it - typically far
76
+ #: more than the capital a config was validated at - and sizing against that
77
+ #: trades a different book from the one that was tested, which would invalidate
78
+ #: the execution telemetry the run exists to gather.
79
+ self._capital = capital
80
+ self._respect_market_hours = respect_market_hours
81
+ self._market_status_cache: Optional[tuple] = None # (monotonic_ts, is_open)
82
+
83
+ @property
84
+ def broker(self) -> Broker:
85
+ return self._broker
86
+
87
+ # ------------------------------------------------------------------ #
88
+ # The strategy's position book
89
+ # ------------------------------------------------------------------ #
90
+ def sync_strategy_book(self) -> int:
91
+ """Replace the strategy's position book with what the broker actually holds.
92
+
93
+ The strategy decides whether an exit is legitimate by looking itself up in
94
+ its own book (:meth:`Strategy.validate_signal`). Nothing in live mode used to
95
+ write that book, so it was permanently empty and every ``CLOSE_BUY`` /
96
+ ``CLOSE_SELL`` was rewritten to ``HOLD`` before execution ever saw it -
97
+ positions could be opened but never closed by the strategy, only by the
98
+ broker-side bracket legs.
99
+
100
+ Broker truth wins outright: the book is rebuilt, not merged. A belief that
101
+ disagrees with the account is not evidence of anything except a stale belief.
102
+ This is a *read* - it reports what is, and never places a corrective order.
103
+
104
+ One ``list_positions`` call regardless of universe size, so it is safe to
105
+ call on the trade clock. Returns the number of positions adopted.
106
+ """
107
+ positions = self._broker.list_positions() or []
108
+ book = {}
109
+ for position in positions:
110
+ side = signals.BUY if position.is_long else signals.SELL
111
+ stop_loss, take_profit = self._stop_levels(
112
+ position.avg_entry_price, OrderSide.BUY if position.is_long else OrderSide.SELL
113
+ )
114
+ book[position.symbol] = {
115
+ "side": side,
116
+ "qty": position.qty,
117
+ "entry_price": position.avg_entry_price,
118
+ "stop_loss": stop_loss,
119
+ "take_profit": take_profit,
120
+ }
121
+ self._strategy.positions = book
122
+ return len(book)
123
+
124
+ def _cost_estimate(self, qty: float, price: float) -> Optional[Dict[str, Any]]:
125
+ """What the cost model expects this entry to cost, decomposed.
126
+
127
+ The *same* model the research clock charges, constructed from the same
128
+ parameters a config carries, so the modelled number in a live report is
129
+ comparable with the one a backtest was judged on. A second, live-only cost
130
+ formula would make the comparison meaningless in a way nobody would notice.
131
+
132
+ Impact is deliberately absent: it is a function of how much of a day's volume
133
+ the order demands, and the trade clock has no ADV for a symbol at the moment it
134
+ sizes one. Rather than invent a participation figure, the estimate says which
135
+ components it contains. Recorded beside the broker's fee, never merged with it —
136
+ the model is a prediction, the fee an observation, and a paper venue reports no
137
+ fee at all.
138
+ """
139
+ if self._cost_model is None:
140
+ return None
141
+ from quantred.costs.base import Trade
142
+
143
+ cost = self._cost_model.cost(Trade(symbol="", shares=abs(qty), price=price, adv=0.0, daily_vol=0.0))
144
+ return {
145
+ "total": cost.commission + cost.spread_cost,
146
+ "commission": cost.commission,
147
+ "spread": cost.spread_cost,
148
+ # Named rather than reported as zero: unknown at this point in the loop.
149
+ "excludes": ["impact"],
150
+ }
151
+
152
+ def refresh_position(self, symbol: str) -> bool:
153
+ """Re-read one symbol from the broker and update the book. Returns True if held.
154
+
155
+ The book is a cache of broker state, rebuilt wholesale by
156
+ :meth:`sync_strategy_book` on a timer. Between sweeps it can be wrong in the
157
+ one direction that matters: an entry recorded at submission is erased by a
158
+ sweep that runs before the broker has materialised the position, and the fill
159
+ that follows updates the ledger but not the book. The strategy then believes it
160
+ is flat in a symbol it holds — and a strategy that believes it is flat cannot
161
+ emit an exit, so the position would be closed only by its bracket legs.
162
+
163
+ One call, for one symbol, on a fill. Not a sweep: this runs on the trade clock
164
+ and must not scale with the universe. Broker truth as usual — nothing here
165
+ infers a quantity from arithmetic.
166
+ """
167
+ position = self._broker.get_position(symbol)
168
+ if position is None or not position.qty:
169
+ self._strategy.positions.pop(symbol, None)
170
+ return False
171
+ side = signals.BUY if position.is_long else signals.SELL
172
+ stop_loss, take_profit = self._stop_levels(
173
+ position.avg_entry_price, OrderSide.BUY if position.is_long else OrderSide.SELL
174
+ )
175
+ self._strategy.positions[symbol] = {
176
+ "side": side,
177
+ "qty": position.qty,
178
+ "entry_price": position.avg_entry_price,
179
+ "stop_loss": stop_loss,
180
+ "take_profit": take_profit,
181
+ }
182
+ return True
183
+
184
+ def handle_signal(
185
+ self,
186
+ symbol: str,
187
+ signal: str,
188
+ price: float,
189
+ bar_timestamp: Optional[datetime] = None,
190
+ ) -> Decision:
191
+ """Act on a single signal, and say what was decided and why.
192
+
193
+ Returns a :class:`~quantred.execution.decision.Decision` rather than an
194
+ order: "no order" has many causes, and collapsing them all into ``None`` made
195
+ the only question worth asking afterwards - why nothing happened on that bar -
196
+ answerable only from logs.
197
+
198
+ ``bar_timestamp`` is what distinguishes one decision from a replay of the
199
+ same one; see :mod:`quantred.execution.order_id`.
200
+ """
201
+ guards: List[str] = [decisions.HOLD]
202
+ if signal == signals.HOLD:
203
+ return decisions.decline(symbol, signal, "no signal", tuple(guards))
204
+
205
+ if self._respect_market_hours:
206
+ guards.append(decisions.MARKET_HOURS)
207
+ if not self._market_open():
208
+ logger.info("Market closed; ignoring %s signal for %s", signal, symbol)
209
+ return decisions.decline(symbol, signal, "market closed", tuple(guards))
210
+
211
+ position = self._broker.get_position(symbol)
212
+
213
+ if signal in signals.EXIT_SIGNALS:
214
+ return self._handle_exit(symbol, signal, position, guards)
215
+
216
+ if signal in signals.ENTRY_SIGNALS:
217
+ # Checked here rather than at the top so a halt can never block an exit.
218
+ # A switch that also trapped the book would be one nobody dares pull.
219
+ guards.append(decisions.HALT)
220
+ halt = self._halts.active(type(self._strategy).__name__)
221
+ if halt is not None:
222
+ logger.warning("HALTED — refusing %s entry for %s: %s", signal, symbol, halt)
223
+ return decisions.decline(
224
+ symbol, signal, f"halted — {halt}", tuple(guards), code=decisions.HALTED
225
+ )
226
+ return self._handle_entry(symbol, signal, price, position, bar_timestamp, guards)
227
+
228
+ logger.warning("Ignoring unrecognized signal %r for %s", signal, symbol)
229
+ return decisions.decline(symbol, signal, f"unrecognized signal {signal!r}", tuple(guards))
230
+
231
+ # ------------------------------------------------------------------ #
232
+ # Entries & exits
233
+ # ------------------------------------------------------------------ #
234
+ def _handle_entry(
235
+ self,
236
+ symbol: str,
237
+ signal: str,
238
+ price: float,
239
+ position: Optional[Position],
240
+ bar_timestamp: Optional[datetime] = None,
241
+ guards: Optional[List[str]] = None,
242
+ ) -> Decision:
243
+ guards = list(guards or [])
244
+
245
+ guards.append(decisions.EXISTING_POSITION)
246
+ if position is not None:
247
+ logger.info("Skipping %s entry for %s: position already open", signal, symbol)
248
+ return decisions.decline(symbol, signal, "position already open", tuple(guards))
249
+
250
+ # A cheap local shortcut, not the safety mechanism: it saves a pointless round
251
+ # trip when an order is visibly pending, but it is a check-then-act race and
252
+ # it forgets everything across a restart. The deterministic client order id
253
+ # below is what actually prevents a double submission.
254
+ guards.append(decisions.PENDING_ORDER)
255
+ if self._broker.list_open_orders(symbol):
256
+ logger.info("Skipping %s entry for %s: an order is already pending", signal, symbol)
257
+ return decisions.decline(symbol, signal, "an order is already pending", tuple(guards))
258
+
259
+ guards.append(decisions.ACCOUNT)
260
+ try:
261
+ account = self._broker.get_account()
262
+ except BrokerError as exc:
263
+ logger.error("Cannot size %s entry: %s (%s)", symbol, exc, type(exc).__name__)
264
+ return decisions.decline(symbol, signal, f"account unreadable: {exc}", tuple(guards))
265
+ if account is None:
266
+ logger.error("Cannot size %s entry: account unavailable", symbol)
267
+ return decisions.decline(symbol, signal, "account unavailable", tuple(guards))
268
+
269
+ # Size against the capital this strategy was given, not the account's whole
270
+ # balance. The affordability check below deliberately keeps using the *real*
271
+ # account: capital is how much of it may be deployed, buying power is what the
272
+ # venue will actually let through, and a cap must never make the second look
273
+ # larger than it is.
274
+ sizing_account = self._deployable(account)
275
+ guards.append(decisions.SIZING)
276
+ qty = round_quantity(
277
+ self._sizer.size(symbol, price, sizing_account),
278
+ allow_fractional=self._allow_fractional,
279
+ )
280
+ if qty <= 0:
281
+ logger.warning("Computed position size <= 0 for %s; skipping", symbol)
282
+ return decisions.decline(
283
+ symbol,
284
+ signal,
285
+ f"size rounds to zero at ${price:,.2f}",
286
+ tuple(guards),
287
+ code=decisions.ROUNDS_TO_ZERO,
288
+ )
289
+
290
+ # Placed here, after rounding and before affordability, because that is where
291
+ # the backtest places it. Same book, same rule, same order of questions — see
292
+ # `below_min_notional`, which is the one definition both clocks reach.
293
+ guards.append(decisions.MIN_NOTIONAL)
294
+ min_notional = (self._strategy.position_limits() or {}).get("min_notional")
295
+ notional = qty * price
296
+ if below_min_notional(qty, price, min_notional):
297
+ logger.warning(
298
+ "Refusing %s entry for %s: $%.2f is below the $%.2f minimum notional",
299
+ signal,
300
+ symbol,
301
+ notional,
302
+ min_notional,
303
+ )
304
+ return decisions.decline(
305
+ symbol,
306
+ signal,
307
+ f"below minimum notional: ${notional:,.2f} of ${min_notional:,.2f}",
308
+ tuple(guards),
309
+ code=decisions.BELOW_MIN_NOTIONAL,
310
+ )
311
+
312
+ guards.append(decisions.BUYING_POWER)
313
+ cost = qty * price
314
+ if cost > account.buying_power:
315
+ logger.warning(
316
+ "Insufficient buying power for %s: need $%.2f, have $%.2f", symbol, cost, account.buying_power
317
+ )
318
+ return decisions.decline(
319
+ symbol,
320
+ signal,
321
+ f"insufficient buying power: need ${cost:.2f}, have ${account.buying_power:.2f}",
322
+ tuple(guards),
323
+ )
324
+
325
+ guards.append(decisions.POSITION_LIMITS)
326
+ # The capped account here too: `max_total_risk` and `max_gross_exposure` are
327
+ # fractions *of equity*, and 5% of a paper account's balance is not 5% of the
328
+ # capital the config was validated at.
329
+ breach = self._limit_breach(symbol, qty, price, sizing_account, signal)
330
+ if breach is not None:
331
+ code, detail = breach
332
+ logger.warning("Refusing %s entry for %s: %s", signal, symbol, detail)
333
+ return decisions.decline(symbol, signal, detail, tuple(guards), code=code)
334
+
335
+ side = _ENTRY_SIDE[signal]
336
+ stop_loss, take_profit = self._stop_levels(price, side)
337
+ logger.info(
338
+ "Entering %s %s x%s @ ~$%.2f (stop $%.2f / target $%.2f)",
339
+ side.value,
340
+ symbol,
341
+ qty,
342
+ price,
343
+ stop_loss,
344
+ take_profit,
345
+ )
346
+ guards.append(decisions.BROKER)
347
+ order_id = client_order_id(self._strategy, symbol, signal, bar_timestamp)
348
+ # Built before submission so a refusal still records what would have been sent.
349
+ plan = decisions.OrderPlan(
350
+ side=side.value,
351
+ qty=qty,
352
+ reference_price=price,
353
+ stop_loss=stop_loss,
354
+ take_profit=take_profit,
355
+ client_order_id=order_id,
356
+ cost_estimate=self._cost_estimate(qty, price),
357
+ )
358
+ try:
359
+ order = self._broker.submit_bracket_order(
360
+ symbol, qty, side, stop_loss, take_profit, client_order_id=order_id
361
+ )
362
+ except DuplicateOrderError:
363
+ # Not a failure: the venue already holds this exact order. Resubmitting is
364
+ # the one thing that must not happen, and the book should still reflect it.
365
+ logger.info("Entry for %s already placed (order id %s); leaving it alone", symbol, order_id)
366
+ return decisions.decline(symbol, signal, "already placed at the broker", tuple(guards), plan)
367
+ except BrokerError as exc:
368
+ logger.error("Entry for %s refused: %s (%s)", symbol, exc, type(exc).__name__)
369
+ return decisions.decline(symbol, signal, f"broker refused: {exc}", tuple(guards), plan)
370
+ if order is None:
371
+ return decisions.decline(symbol, signal, "broker returned no order", tuple(guards), plan)
372
+
373
+ # Intent, not truth: the order is submitted, not filled. Recording it now is
374
+ # what lets the strategy recognize its own position on the very next bar and
375
+ # emit an exit for it; the next `sync_strategy_book` replaces this with
376
+ # whatever the broker actually holds.
377
+ self._strategy.positions[symbol] = {
378
+ "side": signal,
379
+ "qty": qty,
380
+ "entry_price": price,
381
+ "stop_loss": stop_loss,
382
+ "take_profit": take_profit,
383
+ }
384
+ return decisions.allow(symbol, signal, f"entered {qty} @ ~${price:.2f}", tuple(guards), order, plan)
385
+
386
+ def _handle_exit(
387
+ self,
388
+ symbol: str,
389
+ signal: str,
390
+ position: Optional[Position],
391
+ guards: Optional[List[str]] = None,
392
+ ) -> Decision:
393
+ guards = list(guards or [])
394
+ guards.append(decisions.EXISTING_POSITION)
395
+ if position is None:
396
+ return decisions.decline(symbol, signal, "no position to close", tuple(guards))
397
+
398
+ guards.append(decisions.POSITION_MATCH)
399
+ matches = (signal == signals.CLOSE_BUY and position.is_long) or (
400
+ signal == signals.CLOSE_SELL and not position.is_long
401
+ )
402
+ if not matches:
403
+ return decisions.decline(
404
+ symbol, signal, f"{signal} does not match a {position.side} position", tuple(guards)
405
+ )
406
+
407
+ guards.append(decisions.BROKER)
408
+ # Cancel any resting bracket legs first so closing the position can't leave an
409
+ # orphaned stop/take order behind (which could oversell). A leg that cannot be
410
+ # cancelled must not stop the close: an un-exited position is the worse of the
411
+ # two outcomes, and the sweep will surface the orphan.
412
+ for order in self._broker.list_open_orders(symbol):
413
+ try:
414
+ self._broker.cancel_order(order.id)
415
+ except BrokerError as exc:
416
+ logger.warning("Could not cancel resting order %s: %s", order.id, exc)
417
+ logger.info("Closing %s position for %s", position.side, symbol)
418
+ try:
419
+ self._broker.close_position(symbol)
420
+ except BrokerError as exc:
421
+ # Deliberately kept in the book: the position is still open, so the
422
+ # strategy must stay entitled to try again.
423
+ logger.error("Could not close %s: %s (%s)", symbol, exc, type(exc).__name__)
424
+ return decisions.decline(symbol, signal, f"broker could not close it: {exc}", tuple(guards))
425
+ self._strategy.positions.pop(symbol, None)
426
+ return decisions.allow(symbol, signal, f"closed {position.side} position", tuple(guards))
427
+
428
+ def _market_open(self) -> bool:
429
+ """Whether the market is open, cached for a short TTL.
430
+
431
+ An unreadable clock used to mean "open". That is right for a transient blip -
432
+ the bar stream only delivers during sessions anyway, so this is a secondary
433
+ guard and freezing trading over one failed request would be worse - but it was
434
+ also being applied to a failure that is *never* transient. Expired or revoked
435
+ credentials produced "the market is open", and the system carried on placing
436
+ orders on the strength of an answer nobody had given it.
437
+
438
+ So the fallback now depends on the cause: an authentication failure fails
439
+ closed, everything else stays permissive and says so.
440
+ """
441
+ now = time.monotonic()
442
+ if self._market_status_cache and now - self._market_status_cache[0] < _MARKET_STATUS_TTL:
443
+ return self._market_status_cache[1]
444
+
445
+ try:
446
+ status = self._broker.get_market_status()
447
+ is_open = status.is_open if status is not None else True
448
+ except AuthenticationError as exc:
449
+ logger.error("Treating the market as closed: credentials rejected (%s)", exc)
450
+ is_open = False
451
+ except BrokerError as exc:
452
+ logger.warning("Market clock unreadable (%s); assuming open", exc)
453
+ is_open = True
454
+ self._market_status_cache = (now, is_open)
455
+ return is_open
456
+
457
+ def _deployable(self, account: AccountSnapshot) -> AccountSnapshot:
458
+ """The account as this strategy may use it, capped at its configured capital.
459
+
460
+ Returns the account untouched when no capital was set, so an unconfigured run
461
+ behaves exactly as before. Caps rather than replaces: a $8,000 config on an
462
+ account holding $3,000 may deploy $3,000, never the number written in the file.
463
+ """
464
+ if not self._capital:
465
+ return account
466
+ return AccountSnapshot(
467
+ cash=min(account.cash, self._capital),
468
+ equity=min(account.equity, self._capital),
469
+ buying_power=min(account.buying_power, self._capital),
470
+ portfolio_value=min(account.portfolio_value, self._capital),
471
+ trading_blocked=account.trading_blocked,
472
+ )
473
+
474
+ def _limit_breach(
475
+ self, symbol: str, qty: float, price: float, account, signal: Optional[str] = None
476
+ ) -> Optional[tuple]:
477
+ """The limit this entry would break as ``(code, detail)``, or ``None`` if it fits.
478
+
479
+ The code is a stable family and the detail carries the numbers. Reporting only
480
+ the detail makes every refusal look unique, because the amounts differ each time.
481
+
482
+ Sizing answers "how big should this position be?" one symbol at a time and
483
+ has no view of the book, so ``max_total_risk`` applied there caps a single
484
+ position against the *whole* budget - N open positions could each consume all
485
+ of it. The backtest enforces these across the book; live did not enforce them
486
+ at all, so a config validated at five positions and a 5% risk budget could run
487
+ unbounded against a margin account whose buying power is a multiple of equity.
488
+
489
+ Counted from the strategy's own position book rather than a fresh broker read.
490
+ That book is broker truth at start-up and at every reconciliation, and this
491
+ path runs inside the bar loop - a ``list_positions`` per entry would put a
492
+ broker round trip on a path several symbols can take on the same bar. Two
493
+ consequences, both deliberate:
494
+
495
+ * Exposure is measured at entry price, not marked. The trade clock has no
496
+ price for a symbol it is not currently handling, and inventing one is worse
497
+ than measuring at cost. A book that has run up is therefore carrying more
498
+ market exposure than this counts.
499
+ * Freshness is bounded by the reconciliation interval, not the bar. A position
500
+ this trader did not open is invisible until the next sync.
501
+
502
+ Reports and rejects; it never resizes an entry to fit.
503
+ """
504
+ limits = self._strategy.position_limits()
505
+ max_positions = limits.get("max_positions")
506
+ max_total_risk = limits.get("max_total_risk")
507
+ max_gross_exposure = limits.get("max_gross_exposure")
508
+ max_net_exposure = limits.get("max_net_exposure")
509
+ if not (max_positions or max_total_risk or max_gross_exposure or max_net_exposure):
510
+ return None
511
+
512
+ others = [p for held, p in self._strategy.positions.items() if held != symbol]
513
+
514
+ if max_positions and len(others) + 1 > max_positions:
515
+ return (
516
+ decisions.BOOK_FULL,
517
+ f"book is full: {len(others)} of {max_positions} positions already open",
518
+ )
519
+
520
+ if not (max_total_risk or max_gross_exposure or max_net_exposure):
521
+ return None
522
+
523
+ equity = account.equity
524
+ if equity <= 0:
525
+ return (
526
+ decisions.EQUITY_UNREADABLE,
527
+ f"cannot check portfolio limits against ${equity:,.2f} of equity",
528
+ )
529
+
530
+ held_notional = sum(abs(p["qty"]) * p["entry_price"] for p in others)
531
+ entry_notional = qty * price
532
+
533
+ if max_total_risk:
534
+ # Same accounting as the engine: notional x stop distance, one stop
535
+ # fraction for the whole strategy.
536
+ stop_pct = self._strategy.config["stop_loss"]
537
+ open_risk = held_notional * stop_pct
538
+ budget = equity * max_total_risk
539
+ if open_risk + entry_notional * stop_pct > budget:
540
+ return (
541
+ decisions.RISK_BUDGET,
542
+ f"risk budget exhausted: ${open_risk + entry_notional * stop_pct:,.2f} "
543
+ f"of ${budget:,.2f} at a {stop_pct:.1%} stop",
544
+ )
545
+
546
+ if max_gross_exposure:
547
+ cap = equity * max_gross_exposure
548
+ if held_notional + entry_notional > cap:
549
+ return (
550
+ decisions.GROSS_EXPOSURE,
551
+ f"gross exposure capped: ${held_notional + entry_notional:,.2f} of ${cap:,.2f}",
552
+ )
553
+
554
+ if max_net_exposure:
555
+ # Gross bounds long + short and cannot see direction: a book inside a 90%
556
+ # gross cap can be entirely long. This bounds |long - short|.
557
+ #
558
+ # Sign comes from the recorded side, never from qty. The broker reports a
559
+ # short's quantity negative while an entry this trader records itself is
560
+ # positive, so reading the sign off qty would count adopted shorts and
561
+ # freshly opened ones with opposite signs.
562
+ held_net = sum(
563
+ (abs(p["qty"]) if p["side"] == signals.BUY else -abs(p["qty"])) * p["entry_price"]
564
+ for p in others
565
+ )
566
+ signed_entry = entry_notional if signal != signals.SELL else -entry_notional
567
+ projected = held_net + signed_entry
568
+ cap = equity * max_net_exposure
569
+ # Judged on the resulting |net|, so an entry that moves the book toward
570
+ # flat is admitted even from over the cap - refusing a hedge for being a
571
+ # trade would leave the tilt it corrects in place.
572
+ if abs(projected) > cap:
573
+ direction = "long" if projected > 0 else "short"
574
+ return (
575
+ decisions.NET_EXPOSURE,
576
+ f"net exposure capped: ${abs(projected):,.2f} net {direction} of ${cap:,.2f}",
577
+ )
578
+
579
+ return None
580
+
581
+ def _stop_levels(self, entry_price: float, side: OrderSide) -> tuple[float, float]:
582
+ """Compute (stop_loss, take_profit) prices from the strategy's config."""
583
+ stop_pct = self._strategy.config["stop_loss"]
584
+ take_pct = self._strategy.config["take_profit"]
585
+ if side == OrderSide.BUY:
586
+ return round_price(entry_price * (1 - stop_pct)), round_price(entry_price * (1 + take_pct))
587
+ return round_price(entry_price * (1 + stop_pct)), round_price(entry_price * (1 - take_pct))
@@ -0,0 +1,83 @@
1
+ """Deterministic order identity.
2
+
3
+ The guard against double-submitting used to be a question asked of the broker -
4
+ "are there open orders for this symbol?" - immediately before placing one. That is a
5
+ check-then-act race with a network round-trip in the middle, and it has no memory:
6
+ a process that restarts between submitting an order and seeing its fill asks the
7
+ question again, gets an answer that no longer reflects what it did, and submits the
8
+ same order a second time.
9
+
10
+ An identity fixes that without needing any memory at all. The same decision - this
11
+ strategy, this configuration, this symbol, this signal, on this bar - always hashes
12
+ to the same id, and a broker that has already accepted that id rejects the duplicate.
13
+ Idempotency becomes a property of the request rather than a property of how carefully
14
+ the caller looked first.
15
+
16
+ Stdlib only, and pure: trade-clock code, no I/O, no clock reads of its own.
17
+ """
18
+
19
+ import hashlib
20
+ import json
21
+ from datetime import datetime
22
+ from typing import Any, Dict, Optional
23
+
24
+ #: Alpaca accepts client order ids up to 128 characters; 32 hex characters is far
25
+ #: inside that and still leaves collisions a non-issue at any plausible order rate.
26
+ _ID_LENGTH = 32
27
+
28
+ #: Config keys derived at construction rather than chosen by the caller. They are not
29
+ #: part of what makes a decision distinct, and including them would let an unrelated
30
+ #: internal change alter every order id.
31
+ _DERIVED_KEYS = frozenset({"required_lookback_periods"})
32
+
33
+
34
+ def strategy_fingerprint(strategy: Any) -> str:
35
+ """A stable short identifier for a strategy *and the parameters it is running*.
36
+
37
+ Two runs of the same class with different parameters are different strategies for
38
+ this purpose: they can legitimately disagree about the same bar, and one must not
39
+ be silently deduplicated against the other.
40
+ """
41
+ config = {
42
+ key: value
43
+ for key, value in sorted(getattr(strategy, "config", {}).items())
44
+ if key not in _DERIVED_KEYS
45
+ }
46
+ # `default=str` so an unserializable value degrades to its repr rather than
47
+ # raising inside the order path.
48
+ payload = json.dumps(config, sort_keys=True, default=str)
49
+ digest = hashlib.sha256(payload.encode()).hexdigest()[:8]
50
+ return f"{type(strategy).__name__}-{digest}"
51
+
52
+
53
+ def client_order_id(
54
+ strategy: Any,
55
+ symbol: str,
56
+ signal: str,
57
+ bar_timestamp: Optional[datetime] = None,
58
+ ) -> str:
59
+ """The id identifying one trading decision.
60
+
61
+ ``bar_timestamp`` is what makes two otherwise identical decisions distinct: the
62
+ same strategy re-entering the same symbol tomorrow is a genuinely new order, while
63
+ the same bar replayed after a reconnect is not. When it is absent the id covers
64
+ only the decision's content, which still collapses an immediate duplicate.
65
+ """
66
+ parts = [
67
+ strategy_fingerprint(strategy),
68
+ symbol,
69
+ signal,
70
+ bar_timestamp.isoformat() if bar_timestamp is not None else "",
71
+ ]
72
+ return hashlib.sha256("|".join(parts).encode()).hexdigest()[:_ID_LENGTH]
73
+
74
+
75
+ def describe(strategy: Any, symbol: str, signal: str, bar_timestamp: Optional[datetime]) -> Dict[str, str]:
76
+ """The inputs behind an id, for logging - so a duplicate can be explained."""
77
+ return {
78
+ "client_order_id": client_order_id(strategy, symbol, signal, bar_timestamp),
79
+ "strategy": strategy_fingerprint(strategy),
80
+ "symbol": symbol,
81
+ "signal": signal,
82
+ "bar": bar_timestamp.isoformat() if bar_timestamp is not None else "",
83
+ }