quantred 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
}
|