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,539 @@
1
+ """Live trading engine - same pipeline as the backtest, but now the money is real
2
+ and the bugs are expensive.
3
+
4
+ Mirrors the backtest pipeline for real time:
5
+
6
+ warm-up history (marketdata) -> stream bars -> signals (strategy)
7
+ -> orders (execution)
8
+
9
+ The engine wires the layers and owns the bar->signal->order loop; it contains no
10
+ indicator math, no order-placement detail, and no vendor specifics.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import math
16
+ import time
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timedelta
19
+ from typing import List, Optional
20
+
21
+ from quantred.brokers.errors import BrokerError
22
+ from quantred.engine.barcheck import BarQualityFilter
23
+ from quantred.execution.ledger import CUMULATIVE, PositionLedger
24
+ from quantred.execution.live_trader import LiveTrader
25
+ from quantred.marketdata.base import BarEvent
26
+ from quantred.marketdata.client import MarketDataClient
27
+ from quantred.marketdata.timeframe import Timeframe
28
+ from quantred.strategies import signals
29
+ from quantred.strategies.base import Strategy
30
+ from quantred.utils.timeutils import NEW_YORK
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ #: How long to wait for streams to close on interrupt before exiting regardless. A
35
+ #: shutdown that can hang is a shutdown people learn to send a second interrupt to.
36
+ SHUTDOWN_TIMEOUT = 5.0
37
+
38
+ # Fetch this multiple of the bare lookback, so a few missing bars still leave enough.
39
+ _WARMUP_BUFFER = 2
40
+
41
+ #: Five trading days per seven calendar days.
42
+ _CALENDAR_DAYS_PER_TRADING_WEEK = 7 / 5
43
+
44
+ #: Slack for market holidays, which the ratio above does not model. Cheap insurance:
45
+ #: extra history is discarded by the buffer, missing history is silent.
46
+ _HOLIDAY_PADDING_DAYS = 5
47
+
48
+
49
+ class BlindStartError(RuntimeError):
50
+ """Raised when no symbol has warm-up history, so every indicator starts blind.
51
+
52
+ A guard, not a repair: nothing here can invent the history, and starting anyway
53
+ produces signals that look exactly like valid ones.
54
+ """
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class WarmUpCoverage:
59
+ """What the warm-up actually achieved, and whether it was able to ask.
60
+
61
+ ``failure`` is the broker's reason when the history request itself did not
62
+ complete. It is deliberately separate from ``warmed == 0``: a feed that could not
63
+ be reached and a window that genuinely held no bars are different facts, and
64
+ collapsing them is what made an unreachable provider look like an empty market.
65
+ """
66
+
67
+ warmed: int
68
+ sufficient: int
69
+ asked: int
70
+ failure: Optional[str] = None
71
+
72
+
73
+ class LiveEngine:
74
+ """Streams live bars into a strategy and routes signals to execution."""
75
+
76
+ def __init__(
77
+ self,
78
+ strategy: Strategy,
79
+ data_client: MarketDataClient,
80
+ live_trader: LiveTrader,
81
+ *,
82
+ bar_filter: Optional[BarQualityFilter] = None,
83
+ ledger: Optional[PositionLedger] = None,
84
+ reconcile_every: float = 300.0,
85
+ allow_blind_start: bool = False,
86
+ ):
87
+ self.strategy = strategy
88
+ self.data_client = data_client
89
+ self.live_trader = live_trader
90
+ #: Vetoes bars before the strategy sees them. Rejects, never repairs — a
91
+ #: live path that "fixes" its inputs stops being the thing the backtest
92
+ #: validated. Pass ``None`` to run unguarded (what this did before).
93
+ self.bar_filter = bar_filter
94
+ #: Records intent and observed fills so divergence from the broker's actual
95
+ #: account state is detectable. Never authoritative, never remediating.
96
+ self.ledger = ledger
97
+ self.reconcile_every = reconcile_every
98
+ #: Start even when warm-up returned nothing. Off by default: the failure it
99
+ #: guards is silent from inside the loop.
100
+ self._allow_blind_start = allow_blind_start
101
+ #: Serializes everything that reads-then-changes the position book. One slot,
102
+ #: deliberately: the trade clock's determinism comes from doing one thing at a
103
+ #: time, and the threads below exist only to keep blocking I/O off the loop.
104
+ self._order_lock = asyncio.Semaphore(1)
105
+ #: ``None`` means "never swept", which is deliberately not the same as
106
+ #: "swept at time zero". ``time.monotonic()`` counts from an arbitrary origin
107
+ #: (boot, on Linux), so seeding this with 0.0 made the first sweep depend on
108
+ #: how long the machine had been up — on a freshly booted host it was skipped
109
+ #: entirely for a full interval, which is exactly when reconciling matters
110
+ #: most.
111
+ self._last_reconcile: Optional[float] = None
112
+
113
+ async def start(self, symbols: List[str]) -> None:
114
+ """Warm up indicators with history, then stream live bars until canceled.
115
+
116
+ When the broker supports it, the account/trade-update stream runs
117
+ concurrently with the market-data stream so fills are logged.
118
+ """
119
+ self.strategy.initialize()
120
+ coverage = self._warm_up(symbols)
121
+ if coverage.failure is not None and not self._allow_blind_start:
122
+ # A different refusal from the one below, and it has to say so. The
123
+ # request did not complete, so whether history exists is unknown - and
124
+ # "the feed is down" points somewhere entirely different from "your key
125
+ # is not entitled to this data", which is what the message below guesses.
126
+ raise BlindStartError(
127
+ f"Warm-up history could not be fetched: {coverage.failure}\n"
128
+ f" The request failed, so this is not the same as a window that held "
129
+ f"no bars - nothing here knows whether the history exists.\n"
130
+ f" Retry once the feed is reachable, or pass --allow-blind-start to "
131
+ f"trade without history anyway."
132
+ )
133
+ if coverage.failure is not None:
134
+ logger.warning(
135
+ "Starting blind: warm-up history could not be fetched (%s). Every "
136
+ "indicator begins with no history.",
137
+ coverage.failure,
138
+ )
139
+ warmed = coverage.warmed
140
+ if not warmed and symbols and not self._allow_blind_start:
141
+ # Every indicator would start from nothing. The stream still connects and
142
+ # bars still arrive, so the run looks healthy while every signal it emits
143
+ # is computed from history it never had - indistinguishable, from inside
144
+ # the loop, from a strategy that simply is not triggering.
145
+ raise BlindStartError(
146
+ f"Warm-up returned no history for any of the {len(symbols)} symbols, so "
147
+ f"every indicator would start blind.\n"
148
+ f" The usual cause is a market-data feed the account is not entitled to: "
149
+ f"historical requests resolve to the full consolidated tape by default, "
150
+ f"while the live stream defaults to a single venue, so an unentitled key "
151
+ f"warms up on nothing and streams normally.\n"
152
+ f" Pin both halves to a feed the account can read (--feed iex), or pass "
153
+ f"--allow-blind-start to trade without history anyway."
154
+ )
155
+ self._cold_start()
156
+
157
+ if self.ledger is not None:
158
+ # Here, and not at construction: every warm-up refusal above this line is a
159
+ # run that committed to a contract and never traded it. Recorded so a report
160
+ # can tell that apart from a run that began and found nothing — two facts
161
+ # that look identical from a header alone, and are opposite.
162
+ self.ledger.record_session_outcome(self.ledger.SESSION_STARTED)
163
+
164
+ broker = self.live_trader.broker
165
+ tasks = [self.data_client.stream(symbols, self._on_bar)]
166
+ if broker.supports_trade_updates():
167
+ logger.info("Also streaming trade updates for fill/account feedback")
168
+ tasks.append(broker.stream_trade_updates(self._on_trade_update))
169
+
170
+ logger.info("Starting live stream for %d symbols", len(symbols))
171
+ running = [asyncio.ensure_future(task) for task in tasks]
172
+ try:
173
+ # `wait`, not `gather`. On cancellation `gather` propagates to its children
174
+ # and then waits for every one of them to finish unwinding, with no bound -
175
+ # so a stream slow to close its socket held the process open until a second
176
+ # Ctrl-C, and the cleanup below was never even reached. `wait` hands
177
+ # cancellation straight back, leaving the teardown to the bounded wait.
178
+ done, _ = await asyncio.wait(running, return_when=asyncio.FIRST_EXCEPTION)
179
+ for task in done:
180
+ task.result() # re-raise the first stream failure, as gather did
181
+ except asyncio.CancelledError:
182
+ # Ctrl-C cancels this coroutine, not the streams it started. Leaving them
183
+ # to be garbage-collected is what produced the websocket teardown noise:
184
+ # cancel each one and let it finish unwinding before returning.
185
+ logger.info("Shutting down live streams.")
186
+ raise
187
+ finally:
188
+ for task in running:
189
+ task.cancel()
190
+ # Bounded. A stream whose socket teardown blocks used to hold the process
191
+ # open until a second Ctrl-C, which trains people to kill it twice - and
192
+ # the second one arrives during cleanup, when it can interrupt anything.
193
+ #
194
+ # `asyncio.wait`, not `wait_for`: on timeout `wait_for` cancels the thing
195
+ # it was waiting on and then *awaits* it, so a teardown that is slow to
196
+ # answer cancellation hangs the very call meant to bound it. `wait` leaves
197
+ # stragglers alone and returns, which is the whole point here.
198
+ _, pending = await asyncio.wait(running, timeout=SHUTDOWN_TIMEOUT)
199
+ if pending:
200
+ logger.warning(
201
+ "%d stream(s) did not close within %gs; exiting anyway. Nothing "
202
+ "was sent to the broker during shutdown, and open positions are "
203
+ "untouched.",
204
+ len(pending),
205
+ SHUTDOWN_TIMEOUT,
206
+ )
207
+
208
+ async def _on_trade_update(self, update) -> None:
209
+ """Log account/order events (fills, cancels, rejects), and record fills.
210
+
211
+ This is the only place the ledger learns what actually happened, as opposed
212
+ to what was intended — which is precisely the gap it exists to close.
213
+
214
+ Recording and refreshing are separate and both unconditional: a fill the
215
+ ledger declines to record is still a fill the strategy's book has to learn
216
+ about, and the two failures used to be one ``return``. The refresh needs no
217
+ ledger and no side, so a missing either must not cost it.
218
+ """
219
+ logger.info(
220
+ "Trade update: %s %s (order %s, status %s, filled %s)",
221
+ update.event,
222
+ update.symbol,
223
+ update.order_id,
224
+ update.status,
225
+ update.filled_qty,
226
+ )
227
+ try:
228
+ self._record_fill(update)
229
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
230
+ logger.warning("Could not record a fill in the position ledger", exc_info=True)
231
+ await self._refresh_after_fill(update)
232
+
233
+ def _record_fill(self, update) -> None:
234
+ """Append this update to the ledger, if it is a fill we can sign."""
235
+ if self.ledger is None:
236
+ return
237
+ if str(update.event).lower() not in {"fill", "partial_fill"} or not update.filled_qty:
238
+ return
239
+ side = update.side
240
+ if not side:
241
+ # Never default it. A missing side used to resolve to "buy", which
242
+ # recorded every short as a long and made the ledger disagree with
243
+ # the broker by twice the position.
244
+ logger.error(
245
+ "Trade update for %s (order %s) carried no side; not recording "
246
+ "it, because guessing one would put the wrong sign in the ledger",
247
+ update.symbol,
248
+ update.order_id,
249
+ )
250
+ return
251
+ self.ledger.record_fill(
252
+ update.symbol,
253
+ side,
254
+ float(update.filled_qty),
255
+ order_id=update.order_id,
256
+ status=str(update.status),
257
+ basis=CUMULATIVE,
258
+ # The average across the order, to pair with the cumulative
259
+ # quantity beside it; this event's own print is the fallback.
260
+ fill_price=getattr(update, "filled_avg_price", None) or getattr(update, "price", None),
261
+ filled_at=getattr(update, "filled_at", None),
262
+ broker_fee=getattr(update, "fee", None),
263
+ )
264
+
265
+ async def _refresh_after_fill(self, update) -> None:
266
+ """Teach the strategy about a position it may not know it holds.
267
+
268
+ A sweep that lands between an entry's submission and its fill replaces the book
269
+ with broker state that does not include the new position yet, erasing what the
270
+ entry recorded. The fill then updates the ledger and nothing else, so the
271
+ strategy is flat in a symbol it holds until the next sweep - and cannot exit it.
272
+
273
+ Under the order lock, because it changes the same book entries read and act on.
274
+ """
275
+ if str(update.event).lower() not in {"fill", "partial_fill"} or not update.filled_qty:
276
+ return
277
+ try:
278
+ async with self._order_lock:
279
+ await asyncio.to_thread(self.live_trader.refresh_position, update.symbol)
280
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
281
+ logger.warning(
282
+ "Could not refresh %s after its fill; the next sweep will correct it",
283
+ update.symbol,
284
+ exc_info=True,
285
+ )
286
+
287
+ def _cold_start(self) -> None:
288
+ """Teach the strategy what it already holds, before the first bar arrives.
289
+
290
+ Warm-up seeds *indicators* from history; it says nothing about the book. A
291
+ process that restarts while holding a position would otherwise believe it was
292
+ flat — and a strategy that believes it is flat cannot emit an exit, because
293
+ its own signal validation rejects one. The position would then be closed only
294
+ by its broker-side bracket legs, if it had any.
295
+
296
+ Unconditional, and not governed by ``reconcile_every``: that setting paces the
297
+ ledger sweep, whereas an unhydrated book is a correctness bug rather than a
298
+ cadence choice. Failure here is logged, not fatal — starting flat is wrong but
299
+ recoverable on the next sweep, while refusing to start is not obviously better.
300
+ """
301
+ try:
302
+ adopted = self.live_trader.sync_strategy_book()
303
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
304
+ logger.warning("Could not read the broker's positions at start-up", exc_info=True)
305
+ return
306
+ # Count the sweep: it just happened, so the first bar need not repeat it.
307
+ self._last_reconcile = time.monotonic()
308
+ if adopted:
309
+ logger.info("Resuming with %d open position(s) adopted from the broker", adopted)
310
+ self._record_adoptions()
311
+
312
+ def _record_adoptions(self) -> None:
313
+ """Write what was adopted into the durable ledger, not only the in-memory book.
314
+
315
+ The two must agree about a resumed session. Without this the engine resumes
316
+ holding six positions while the ledger has never heard of them, and the next
317
+ reconciliation reports all six as positions nobody ordered - noise precisely
318
+ where a real divergence needs to stand out.
319
+ """
320
+ if self.ledger is None:
321
+ return
322
+ try:
323
+ for symbol, position in self.strategy.positions.items():
324
+ self.ledger.record_adoption(symbol, position["side"], position["qty"])
325
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
326
+ logger.warning("Could not record adopted positions in the ledger", exc_info=True)
327
+
328
+ def warm_up_coverage(self, symbols: List[str]) -> WarmUpCoverage:
329
+ """Run the real warm-up and report what it covered.
330
+
331
+ Two counts, not one: a symbol can have bars and still have too few for the
332
+ indicators to be valid. Reporting only presence would let a short warm-up read
333
+ as a pass, and "has history" is not the question a preflight is asked. And a
334
+ third fact beside them: whether the request completed at all.
335
+
336
+ Deliberately the same call the live path makes, not a lighter probe: a
337
+ preflight that fetches differently from the run it precedes confirms nothing
338
+ about that run. Placing no orders, it is safe to call and then exit.
339
+ """
340
+ return self._warm_up(symbols)
341
+
342
+ def _warm_up(self, symbols: List[str]) -> WarmUpCoverage:
343
+ """Seed each symbol's rolling buffer so indicators are valid on bar one.
344
+
345
+ Reports how many symbols have any history, how many have the full lookback,
346
+ and - separately - whether the fetch itself failed. The caller refuses on the
347
+ first and reports all three.
348
+ """
349
+ timeframe = Timeframe.parse(self.strategy.config["timeframe"])
350
+ periods = self.strategy.config.get("required_lookback_periods", 50)
351
+ start = self.lookback_start(timeframe, periods)
352
+ end = datetime.now(NEW_YORK)
353
+
354
+ try:
355
+ history = self.data_client.get_bars(symbols, timeframe, start, end)
356
+ except BrokerError as exc:
357
+ # Typed and returned rather than allowed to propagate. Historical fetches
358
+ # began raising instead of returning {} so that "unreachable" would stop
359
+ # reading as "empty", and this path had no handler - so a transient data
360
+ # failure during warm-up ended a live session with a raw traceback,
361
+ # replacing the refusal written for exactly this situation.
362
+ logger.error("Warm-up history request failed: %s (%s)", exc, type(exc).__name__)
363
+ return WarmUpCoverage(warmed=0, sufficient=0, asked=len(symbols), failure=str(exc))
364
+ warmed = sufficient = 0
365
+ for symbol in symbols:
366
+ bars = history.get(symbol)
367
+ # A short warm-up is the failure worth shouting about: the strategy runs
368
+ # anyway, on indicators computed from too little history, and produces
369
+ # confident-looking signals that the backtest never validated. Nothing
370
+ # else in the loop can tell that apart from a quiet market.
371
+ if bars is None or bars.empty:
372
+ logger.error("No warm-up history for %s — its indicators start blind", symbol)
373
+ continue
374
+ warmed += 1
375
+ if len(bars) < periods:
376
+ logger.warning(
377
+ "Warmed up %s with only %d of the %d bars its indicators need",
378
+ symbol,
379
+ len(bars),
380
+ periods,
381
+ )
382
+ else:
383
+ sufficient += 1
384
+ logger.info("Warmed up %s with %d bars", symbol, len(bars))
385
+ self.strategy.warm_up(symbol, self.strategy.process_data(bars))
386
+ logger.info(
387
+ "Warm-up covered %d of %d symbols; %d have the full %d-bar lookback",
388
+ warmed,
389
+ len(symbols),
390
+ sufficient,
391
+ periods,
392
+ )
393
+ return WarmUpCoverage(warmed=warmed, sufficient=sufficient, asked=len(symbols))
394
+
395
+ async def _on_bar(self, event: BarEvent) -> None:
396
+ """Per-bar callback: validate, update the strategy, act on any signal.
397
+
398
+ Async because the broker SDK is synchronous. Every entry makes several blocking
399
+ HTTP round trips, and running those on the loop stalls everything else it is
400
+ carrying: other symbols that signalled on the same bar, the trade-update stream
401
+ that delivers fills, and the reconciliation sweep. Under a universe of any size
402
+ that is enough for the socket to fall behind.
403
+
404
+ The blocking work is handed to a thread and the order path is serialized behind
405
+ one semaphore, so submissions keep their order and the read-then-act sequence
406
+ inside the trader stays atomic. Concurrency here would be a correctness bug, not
407
+ a speed-up: two entries checking the book at once can both pass a gross-exposure
408
+ limit that only one of them fits inside.
409
+ """
410
+ bar = {
411
+ "open": event.open,
412
+ "high": event.high,
413
+ "low": event.low,
414
+ "close": event.close,
415
+ "volume": event.volume,
416
+ }
417
+ if self.bar_filter is not None:
418
+ verdict = self.bar_filter.check(event.symbol, bar, event.timestamp)
419
+ if not verdict.accepted:
420
+ # Skipped, not repaired: the strategy simply never sees this bar, so
421
+ # its state stays exactly what a backtest over clean data would give.
422
+ return
423
+
424
+ signal = self.strategy.process_bar(event.symbol, bar, event.timestamp)
425
+ if signal and signal != signals.HOLD:
426
+ logger.info("Signal %s for %s @ $%.4f", signal, event.symbol, event.close)
427
+ async with self._order_lock:
428
+ decision = await asyncio.to_thread(
429
+ self.live_trader.handle_signal,
430
+ event.symbol,
431
+ signal,
432
+ event.close,
433
+ bar_timestamp=event.timestamp,
434
+ )
435
+ if not decision:
436
+ logger.info("%s", decision)
437
+ self._record_decision(decision)
438
+ self._record_intent(event.symbol, signal, decision.order, decision)
439
+ await self._maybe_reconcile()
440
+
441
+ def _record_decision(self, decision) -> None:
442
+ """Record why execution acted or declined.
443
+
444
+ Without this, "nothing happened on that bar" is answerable only from logs,
445
+ and only while they still exist. The ledger is already the append-only record
446
+ of what the live path did, so a declined signal belongs in it beside the
447
+ orders it did place.
448
+ """
449
+ if self.ledger is None:
450
+ return
451
+ try:
452
+ self.ledger.record_decision(decision)
453
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
454
+ logger.warning("Could not record the execution decision", exc_info=True)
455
+
456
+ def _record_intent(self, symbol: str, signal: str, order, decision=None) -> None:
457
+ """Note what we asked for, so a fill that never arrives is detectable.
458
+
459
+ Carries the decision's id and the plan it built, which is what lets the ledger
460
+ join a decision to the order it produced and the fills that followed.
461
+ """
462
+ if self.ledger is None:
463
+ return
464
+ try:
465
+ if order is not None:
466
+ plan = getattr(decision, "plan", None)
467
+ self.ledger.record_intent(
468
+ symbol,
469
+ order.side.value,
470
+ order.qty,
471
+ order_id=order.id,
472
+ decision_id=getattr(decision, "decision_id", None),
473
+ plan=plan.as_dict() if plan is not None else None,
474
+ )
475
+ elif signal in signals.EXIT_SIGNALS:
476
+ self.ledger.record_close(symbol)
477
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
478
+ logger.warning("Could not record intent in the position ledger", exc_info=True)
479
+
480
+ async def _maybe_reconcile(self) -> None:
481
+ """Sweep on a timer, from inside the loop.
482
+
483
+ Bounded by construction — one ``list_positions`` call per sweep, never one
484
+ per symbol — because this runs on the trade clock and must not make bar
485
+ processing depend on the size of the universe.
486
+ """
487
+ if self.reconcile_every <= 0:
488
+ return
489
+ now = time.monotonic()
490
+ # The first bar always sweeps. A process that just started is the case most
491
+ # likely to have drifted — it may have missed fills while it was down — so
492
+ # waiting a full interval to find out is backwards.
493
+ if self._last_reconcile is not None and now - self._last_reconcile < self.reconcile_every:
494
+ return
495
+ self._last_reconcile = now
496
+ # Re-read the book first: a bracket leg that filled, or a position closed by
497
+ # hand in the broker's UI, changes what the strategy is entitled to exit.
498
+ try:
499
+ # Under the same lock as the order path: this replaces the position book
500
+ # wholesale, and doing that underneath an entry that has already checked it
501
+ # would let the entry act on a book that no longer exists.
502
+ async with self._order_lock:
503
+ await asyncio.to_thread(self.live_trader.sync_strategy_book)
504
+ except Exception: # noqa: BLE001 - bookkeeping never breaks the order path
505
+ logger.warning("Could not refresh the strategy's position book", exc_info=True)
506
+ if self.ledger is None:
507
+ return
508
+ try:
509
+ await asyncio.to_thread(self.ledger.reconcile, self.live_trader.broker)
510
+ except Exception: # noqa: BLE001
511
+ logger.warning("Scheduled reconciliation failed", exc_info=True)
512
+
513
+ @staticmethod
514
+ def lookback_start(timeframe: Timeframe, periods: int) -> datetime:
515
+ """How far back to fetch so warm-up actually yields ``periods`` bars.
516
+
517
+ Public because a rehearsal of this path has to ask for the same window. A
518
+ second derivation had already been written in the CLI, in exactly the shape
519
+ described below as the bug this one fixed — bars converted straight to calendar
520
+ days — so a dry run asked for a window the run it rehearses would not have used.
521
+
522
+ This used to convert bars to wall-clock time directly — 50 one-minute bars
523
+ became 100 minutes ago — which silently treats the overnight gap, the weekend,
524
+ and every holiday as tradeable. At 09:35 on a Monday that window reached back
525
+ to 07:55 the same morning and returned five bars for a fifty-bar indicator,
526
+ leaving the strategy warmed up with an eighth of the history it asked for and
527
+ nothing to say so. Daily bars under-fetched too, just less visibly: 100
528
+ calendar days is about 70 sessions.
529
+
530
+ So the conversion goes through sessions. Over-fetching is deliberately
531
+ cheap — the buffer keeps only its tail — while under-fetching is invisible,
532
+ so the estimate is padded for holidays rather than made exact. That is also
533
+ why no market calendar is pulled in: precision here buys nothing that a few
534
+ spare days do not.
535
+ """
536
+ bars_needed = max(periods, 1) * _WARMUP_BUFFER
537
+ sessions = math.ceil(bars_needed / timeframe.bars_per_trading_day())
538
+ calendar_days = math.ceil(sessions * _CALENDAR_DAYS_PER_TRADING_WEEK) + _HOLIDAY_PADDING_DAYS
539
+ return datetime.now(NEW_YORK) - timedelta(days=calendar_days)