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
quantred/engine/live.py
ADDED
|
@@ -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)
|