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,192 @@
|
|
|
1
|
+
"""What execution decided, and why.
|
|
2
|
+
|
|
3
|
+
A signal that produced no order used to leave a log line and nothing else. The engine
|
|
4
|
+
saw ``None``, which is the same answer for "the market is closed", "we are halted",
|
|
5
|
+
"you already hold this", "the size rounded to zero", and "the broker refused" - so
|
|
6
|
+
the one question worth asking afterwards, *why did nothing happen on that bar*, could
|
|
7
|
+
only be answered by reading logs, if they still existed.
|
|
8
|
+
|
|
9
|
+
A :class:`Decision` makes the answer a value instead. It records the outcome, the
|
|
10
|
+
reason in words, and - importantly - **which guards were consulted**, not merely which
|
|
11
|
+
one fired. A veto list that only ever names the guard that tripped cannot distinguish
|
|
12
|
+
a guard that passed from a guard that never ran, which is exactly how a check silently
|
|
13
|
+
stops being applied and nobody notices.
|
|
14
|
+
|
|
15
|
+
Trade-clock code: plain stdlib, no research imports, nothing that can block.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Dict, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from quantred.brokers.base import OrderResult
|
|
23
|
+
|
|
24
|
+
# The guards, named once so a reason string and a consulted-list entry cannot drift.
|
|
25
|
+
HOLD = "hold"
|
|
26
|
+
MARKET_HOURS = "market_hours"
|
|
27
|
+
HALT = "halt"
|
|
28
|
+
EXISTING_POSITION = "existing_position"
|
|
29
|
+
PENDING_ORDER = "pending_order"
|
|
30
|
+
ACCOUNT = "account"
|
|
31
|
+
SIZING = "sizing"
|
|
32
|
+
MIN_NOTIONAL = "min_notional"
|
|
33
|
+
BUYING_POWER = "buying_power"
|
|
34
|
+
POSITION_LIMITS = "position_limits"
|
|
35
|
+
BROKER = "broker"
|
|
36
|
+
POSITION_MATCH = "position_match"
|
|
37
|
+
|
|
38
|
+
# Why a refusal happened, as a stable family. The reason *message* embeds the numbers
|
|
39
|
+
# that caused it - "gross exposure capped: $7,617.12 of $7,200.00" - so counting
|
|
40
|
+
# messages turns sixteen refusals of one kind into sixteen rows of one, which hides a
|
|
41
|
+
# throttle rather than showing it. The code groups; the message still explains.
|
|
42
|
+
BOOK_FULL = "book_full"
|
|
43
|
+
GROSS_EXPOSURE = "gross_exposure_capped"
|
|
44
|
+
NET_EXPOSURE = "net_exposure_capped"
|
|
45
|
+
RISK_BUDGET = "risk_budget_exhausted"
|
|
46
|
+
EQUITY_UNREADABLE = "equity_unreadable"
|
|
47
|
+
#: The two ways a book can be too small to express a position in a name at all. Coded
|
|
48
|
+
#: rather than left as bare text because they are the signature of a book traded below
|
|
49
|
+
#: the size it was validated at, and counting them is the only way that shows up as a
|
|
50
|
+
#: number instead of as a strategy that mysteriously stopped trading the expensive
|
|
51
|
+
#: names. The messages carry the amounts, so grouping by message would scatter one
|
|
52
|
+
#: cause across as many rows as there were symbols.
|
|
53
|
+
BELOW_MIN_NOTIONAL = "below_min_notional"
|
|
54
|
+
ROUNDS_TO_ZERO = "size_rounds_to_zero"
|
|
55
|
+
#: The kill switch refusing an entry. Distinct from the ``HALT`` guard name above: that
|
|
56
|
+
#: says the check ran, this says it fired. Coded for the same reason the caps are — the
|
|
57
|
+
#: message carries the halt's own reason, actor and *timestamp*, so re-halting for a new
|
|
58
|
+
#: reason, or halting a second strategy, split one throttle into a row per halt. A
|
|
59
|
+
#: reader counting refusal kinds would see several one-off families and no halt.
|
|
60
|
+
HALTED = "halted"
|
|
61
|
+
|
|
62
|
+
#: Message prefixes written before decisions carried a code, mapped to the family they
|
|
63
|
+
#: belong to. A read-path concern only: rows written from here on carry a code and never
|
|
64
|
+
#: consult this. It exists because an append-only ledger keeps its history, so a report
|
|
65
|
+
#: that groups only new rows shows one throttle as two — some tidy families beside a
|
|
66
|
+
#: scatter of one-off messages saying the same thing.
|
|
67
|
+
#:
|
|
68
|
+
#: Matched by prefix because the numbers follow the colon. A message not listed keeps
|
|
69
|
+
#: its own text rather than being forced into a family it may not belong to.
|
|
70
|
+
_LEGACY_REASON_PREFIXES = (
|
|
71
|
+
("book is full", BOOK_FULL),
|
|
72
|
+
("gross exposure capped", GROSS_EXPOSURE),
|
|
73
|
+
("net exposure capped", NET_EXPOSURE),
|
|
74
|
+
("risk budget exhausted", RISK_BUDGET),
|
|
75
|
+
("cannot check portfolio limits", EQUITY_UNREADABLE),
|
|
76
|
+
# Every halted refusal already in a ledger was written without a code, and the words
|
|
77
|
+
# before the em dash are the only stable part of the message.
|
|
78
|
+
("halted", HALTED),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def reason_family(record: Dict[str, Any]) -> str:
|
|
83
|
+
"""The family a recorded decision belongs to, whenever it was written.
|
|
84
|
+
|
|
85
|
+
Prefers the code the decision carries. Falls back to recognising the message a
|
|
86
|
+
pre-code row was written with, and finally to the message itself — which is the
|
|
87
|
+
right answer for a fixed phrase like "market is closed" that has no numbers in it
|
|
88
|
+
and therefore never fragmented in the first place.
|
|
89
|
+
"""
|
|
90
|
+
code = record.get("reason_code")
|
|
91
|
+
reason = str(record.get("reason") or "")
|
|
92
|
+
# A pre-code row has no `reason_code` at all; a coded row whose value equals its own
|
|
93
|
+
# message is one that never had a family, and both are worth normalising.
|
|
94
|
+
if code and code != reason:
|
|
95
|
+
return str(code)
|
|
96
|
+
for prefix, family in _LEGACY_REASON_PREFIXES:
|
|
97
|
+
if reason.startswith(prefix):
|
|
98
|
+
return family
|
|
99
|
+
return reason or "unknown"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class OrderPlan:
|
|
104
|
+
"""What execution intended to send, recorded whether or not the venue took it.
|
|
105
|
+
|
|
106
|
+
Separate from the :class:`~quantred.brokers.base.OrderResult` the broker returns,
|
|
107
|
+
because the interesting comparison is between the two. A result alone cannot say
|
|
108
|
+
what price the decision was made at, and so cannot say what the fill cost relative
|
|
109
|
+
to it.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
side: str
|
|
113
|
+
qty: float
|
|
114
|
+
#: The price the signal fired at — the bar close execution decided on. Everything
|
|
115
|
+
#: called "slippage" downstream is measured against this and nothing else.
|
|
116
|
+
reference_price: float
|
|
117
|
+
order_type: str = "bracket"
|
|
118
|
+
time_in_force: str = "day"
|
|
119
|
+
stop_loss: Optional[float] = None
|
|
120
|
+
take_profit: Optional[float] = None
|
|
121
|
+
client_order_id: Optional[str] = None
|
|
122
|
+
#: What the cost model expected this to cost. Deliberately not stored in the same
|
|
123
|
+
#: field as a broker fee: a paper venue reports no fees, and collapsing the two
|
|
124
|
+
#: would make a modelled number look like an observed one.
|
|
125
|
+
cost_estimate: Optional[float] = None
|
|
126
|
+
|
|
127
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
128
|
+
return {
|
|
129
|
+
"side": self.side,
|
|
130
|
+
"qty": self.qty,
|
|
131
|
+
"reference_price": self.reference_price,
|
|
132
|
+
"order_type": self.order_type,
|
|
133
|
+
"time_in_force": self.time_in_force,
|
|
134
|
+
"stop_loss": self.stop_loss,
|
|
135
|
+
"take_profit": self.take_profit,
|
|
136
|
+
"client_order_id": self.client_order_id,
|
|
137
|
+
"cost_estimate": self.cost_estimate,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Decision:
|
|
143
|
+
"""The outcome of handing one signal to execution."""
|
|
144
|
+
|
|
145
|
+
symbol: str
|
|
146
|
+
signal: str
|
|
147
|
+
allowed: bool
|
|
148
|
+
reason: str
|
|
149
|
+
guards_consulted: Tuple[str, ...] = field(default_factory=tuple)
|
|
150
|
+
order: Optional[OrderResult] = None
|
|
151
|
+
#: Joins this decision to the intent and the fills that follow it. Generated for
|
|
152
|
+
#: declines too — "why did nothing happen" is the question the audit trail exists
|
|
153
|
+
#: for, and a decline with no id cannot be referred to.
|
|
154
|
+
decision_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
155
|
+
plan: Optional[OrderPlan] = None
|
|
156
|
+
#: Stable family for this outcome, for grouping. ``None`` where the reason text is
|
|
157
|
+
#: already a fixed phrase with no numbers in it.
|
|
158
|
+
reason_code: Optional[str] = None
|
|
159
|
+
|
|
160
|
+
def __bool__(self) -> bool:
|
|
161
|
+
"""Truthy when execution acted, so callers can read as `if decision:`."""
|
|
162
|
+
return self.allowed
|
|
163
|
+
|
|
164
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
165
|
+
return {
|
|
166
|
+
"symbol": self.symbol,
|
|
167
|
+
"signal": self.signal,
|
|
168
|
+
"allowed": self.allowed,
|
|
169
|
+
"reason": self.reason,
|
|
170
|
+
"guards_consulted": list(self.guards_consulted),
|
|
171
|
+
"order_id": self.order.id if self.order is not None else None,
|
|
172
|
+
"decision_id": self.decision_id,
|
|
173
|
+
"reason_code": self.reason_code or self.reason,
|
|
174
|
+
"plan": self.plan.as_dict() if self.plan is not None else None,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def __str__(self) -> str:
|
|
178
|
+
verdict = "acted" if self.allowed else "declined"
|
|
179
|
+
return f"{self.signal} {self.symbol}: {verdict} — {self.reason}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def allow(symbol, signal, reason, guards, order=None, plan: Optional[OrderPlan] = None) -> Decision:
|
|
183
|
+
return Decision(symbol, signal, True, reason, guards, order, plan=plan)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def decline(symbol, signal, reason, guards, plan=None, code: Optional[str] = None) -> Decision:
|
|
187
|
+
"""A refusal. ``plan`` is carried when there was one, so a declined entry still
|
|
188
|
+
records what it would have sent — otherwise the size a limit rejected is lost.
|
|
189
|
+
|
|
190
|
+
``code`` groups refusals of the same kind whose messages differ only in numbers.
|
|
191
|
+
"""
|
|
192
|
+
return Decision(symbol, signal, False, reason, guards, None, plan=plan, reason_code=code)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Flatten: stop, cancel everything, close everything.
|
|
2
|
+
|
|
3
|
+
The most drastic thing this system can be asked to do, and the one most likely to be
|
|
4
|
+
asked for by someone who does not trust it any more. Two properties follow from that.
|
|
5
|
+
|
|
6
|
+
**It goes through the broker, not the engine.** Nothing here asks a running
|
|
7
|
+
:class:`~quantred.engine.live.LiveEngine` to do anything, so it works when the engine
|
|
8
|
+
is wedged, mid-restart, or holding state that is exactly what you no longer believe.
|
|
9
|
+
|
|
10
|
+
**It halts first.** Cancelling and closing while an engine is still streaming bars is
|
|
11
|
+
a race the engine can win - it re-enters on the next bar, and the account refills
|
|
12
|
+
behind you. Recording the halt before touching anything closes that window, and is
|
|
13
|
+
also the step most worth having if a later one fails.
|
|
14
|
+
|
|
15
|
+
Every step is attempted even if an earlier one failed. A partial flatten is a bad
|
|
16
|
+
outcome, but stopping halfway through because the cancel call errored - and leaving
|
|
17
|
+
the positions open - is a worse one. What actually happened comes back in the report,
|
|
18
|
+
and the exit code reflects it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from quantred.brokers.base import Broker
|
|
27
|
+
from quantred.brokers.errors import BrokerError
|
|
28
|
+
from quantred.execution.halt import ALL, HaltState
|
|
29
|
+
from quantred.settings import invocation
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: What a broker read afterwards actually established about the positions.
|
|
35
|
+
#:
|
|
36
|
+
#: Four values, not a boolean, because submitting a close and observing it is not the
|
|
37
|
+
#: same fact and the gap between them is where an operator gets hurt. Closes queue
|
|
38
|
+
#: outside market hours and fill piecemeal at the open: a real flatten reported every
|
|
39
|
+
#: position closed and the account still held its whole book minutes later.
|
|
40
|
+
CLOSED = "yes" # a broker read came back with no positions
|
|
41
|
+
PENDING = "pending" # closes were submitted and positions are still open
|
|
42
|
+
NOT_CLOSED = "no" # the close request itself failed, and positions are still open
|
|
43
|
+
UNKNOWN = "unknown" # the confirming read failed; nothing here knows what is open
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FlattenReport:
|
|
48
|
+
"""What each step of the flatten did, and what a broker read afterwards observed.
|
|
49
|
+
|
|
50
|
+
The distinction this type exists to keep is between a request that was *accepted*
|
|
51
|
+
and a state that was *observed*. ``close_submitted`` is the first; ``observed`` is
|
|
52
|
+
the second, and only the second can be called flat.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
started_at: str
|
|
56
|
+
halted: bool = False
|
|
57
|
+
orders_cancelled: bool = False
|
|
58
|
+
close_submitted: bool = False
|
|
59
|
+
observed: str = UNKNOWN
|
|
60
|
+
checked_at: Optional[str] = None
|
|
61
|
+
remaining: List[str] = field(default_factory=list)
|
|
62
|
+
#: Resting orders the confirming read still saw. ``None`` means it could not be
|
|
63
|
+
#: asked — absent, not zero, in the field that decides whether the book can refill.
|
|
64
|
+
open_orders: Optional[int] = None
|
|
65
|
+
failures: List[str] = field(default_factory=list)
|
|
66
|
+
#: Symbols recorded in the ledger as closed by this flatten. ``None`` means no
|
|
67
|
+
#: ledger was given, which is different from an empty list: the first says nobody
|
|
68
|
+
#: was told, the second says there was nothing this ledger still expected.
|
|
69
|
+
recorded_closed: Optional[List[str]] = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def complete(self) -> bool:
|
|
73
|
+
"""Flat, and known to be. Deliberately requires the *observation*.
|
|
74
|
+
|
|
75
|
+
This used to be satisfied by the close call returning, so a flatten whose orders
|
|
76
|
+
merely queued reported success and printed a reassuring next step. Submission is
|
|
77
|
+
not a terminal state; nothing may claim one without a read that saw it.
|
|
78
|
+
|
|
79
|
+
Both legs are held to that. ``open_orders == 0`` rather than ``orders_cancelled``
|
|
80
|
+
because the cancel is a submitted fact too, and a resting order the cancel missed
|
|
81
|
+
can refill the book after the instant the read was taken — so a verifiably empty
|
|
82
|
+
account with an unknown order book is not a terminal state either. ``None``
|
|
83
|
+
(unreadable) fails this, as it should.
|
|
84
|
+
"""
|
|
85
|
+
return self.halted and self.observed == CLOSED and self.open_orders == 0
|
|
86
|
+
|
|
87
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"started_at": self.started_at,
|
|
90
|
+
"complete": self.complete,
|
|
91
|
+
"halted": self.halted,
|
|
92
|
+
"orders_cancelled": self.orders_cancelled,
|
|
93
|
+
"close_submitted": self.close_submitted,
|
|
94
|
+
"observed": self.observed,
|
|
95
|
+
"checked_at": self.checked_at,
|
|
96
|
+
"remaining": self.remaining,
|
|
97
|
+
"open_orders": self.open_orders,
|
|
98
|
+
"failures": self.failures,
|
|
99
|
+
"recorded_closed": self.recorded_closed,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def summary(self) -> str:
|
|
103
|
+
remaining = (
|
|
104
|
+
f"{len(self.remaining)} ({', '.join(sorted(self.remaining))})"
|
|
105
|
+
if self.remaining
|
|
106
|
+
else ("none" if self.observed == CLOSED else "unknown")
|
|
107
|
+
)
|
|
108
|
+
lines = [
|
|
109
|
+
"FLATTEN",
|
|
110
|
+
f" halt set : {'yes' if self.halted else 'NO'}",
|
|
111
|
+
f" orders cancelled : {'yes' if self.orders_cancelled else 'NO'}",
|
|
112
|
+
f" close orders submitted : {'yes' if self.close_submitted else 'NO'}",
|
|
113
|
+
f" positions observed closed : {self.observed}",
|
|
114
|
+
f" last broker position check: {self.checked_at or 'never'}",
|
|
115
|
+
f" remaining positions : {remaining}",
|
|
116
|
+
f" resting orders observed : {'unknown' if self.open_orders is None else self.open_orders}",
|
|
117
|
+
]
|
|
118
|
+
if self.recorded_closed is None:
|
|
119
|
+
lines.append(" recorded in the ledger : no ledger given")
|
|
120
|
+
elif self.recorded_closed:
|
|
121
|
+
lines.append(
|
|
122
|
+
f" recorded in the ledger : {len(self.recorded_closed)} "
|
|
123
|
+
f"({', '.join(self.recorded_closed)}) closed by operator"
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
lines.append(" recorded in the ledger : nothing was expected there")
|
|
127
|
+
for failure in self.failures:
|
|
128
|
+
lines.append(f" ! {failure}")
|
|
129
|
+
|
|
130
|
+
if self.complete:
|
|
131
|
+
lines.append("\nFlat, and confirmed by a broker read at the time above: no")
|
|
132
|
+
lines.append("positions and no resting orders. The engine cannot re-enter while")
|
|
133
|
+
lines.append(f"the halt stands; `{invocation('resume all')}` when you are ready.")
|
|
134
|
+
elif self.observed == PENDING:
|
|
135
|
+
# The case that used to read as success. Say what is true and no more. It
|
|
136
|
+
# must NOT promise a fill: a symbol refused for being halted, non-tradable
|
|
137
|
+
# or restricted never will, and this cannot tell that apart from a queued
|
|
138
|
+
# close, because the venue's per-symbol answer is not surfaced here.
|
|
139
|
+
lines.append("\nNOT FLAT — the close orders were accepted and these positions are")
|
|
140
|
+
lines.append("still open. A queued close fills at the next open; a *refused* one")
|
|
141
|
+
lines.append("never will, and this cannot tell them apart. Re-run to re-read, and")
|
|
142
|
+
lines.append("check the symbols above at the broker if they persist.")
|
|
143
|
+
elif self.observed == UNKNOWN:
|
|
144
|
+
lines.append("\nUNCONFIRMED — the position read failed, so nothing here knows what")
|
|
145
|
+
lines.append("is open. Check the broker directly.")
|
|
146
|
+
elif self.observed == CLOSED:
|
|
147
|
+
# Positions verifiably gone, but something else is unfinished. The old
|
|
148
|
+
# catch-all said "finish by hand" while pointing at the positions, which are
|
|
149
|
+
# the one thing that is done.
|
|
150
|
+
lines.append("\nPOSITIONS FLAT, BUT NOT FINISHED:")
|
|
151
|
+
unfinished = []
|
|
152
|
+
if not self.halted:
|
|
153
|
+
unfinished.append("the halt was not recorded, so the engine may re-enter")
|
|
154
|
+
if self.open_orders is None:
|
|
155
|
+
unfinished.append("the resting orders could not be read")
|
|
156
|
+
elif self.open_orders:
|
|
157
|
+
unfinished.append(f"{self.open_orders} order(s) still resting, which can refill the book")
|
|
158
|
+
for item in unfinished or ["something above did not complete"]:
|
|
159
|
+
lines.append(f" - {item}")
|
|
160
|
+
else:
|
|
161
|
+
lines.append("\nNOT FLAT — the close request was not accepted and these positions")
|
|
162
|
+
lines.append("are still open. Check the broker directly and finish by hand.")
|
|
163
|
+
return "\n".join(lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def flatten(
|
|
167
|
+
broker: Broker,
|
|
168
|
+
*,
|
|
169
|
+
reason: str,
|
|
170
|
+
actor: str = "cli",
|
|
171
|
+
halt_state: Optional[HaltState] = None,
|
|
172
|
+
ledger: Optional[Any] = None,
|
|
173
|
+
) -> FlattenReport:
|
|
174
|
+
"""Halt, cancel all open orders, and close all positions. Returns what happened.
|
|
175
|
+
|
|
176
|
+
``ledger`` is optional and the flatten works without one, deliberately: this path
|
|
177
|
+
has to run when the engine is wedged, and a ledger that cannot be written must never
|
|
178
|
+
be the reason a book stays open. Given one, the positions it was still expecting are
|
|
179
|
+
recorded as closed by an operator — see :meth:`PositionLedger.record_flatten` — so a
|
|
180
|
+
reconciliation afterwards does not report every one of them as a dropped fill.
|
|
181
|
+
"""
|
|
182
|
+
report = FlattenReport(started_at=datetime.now(timezone.utc).isoformat())
|
|
183
|
+
halts = halt_state or HaltState()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
halts.set(f"flatten: {reason}", actor=actor, scope=ALL)
|
|
187
|
+
report.halted = True
|
|
188
|
+
except OSError as exc:
|
|
189
|
+
# Keep going. An un-halted flatten is worth far more than no flatten, and the
|
|
190
|
+
# report says plainly that re-entry is not blocked.
|
|
191
|
+
report.failures.append(f"could not record the halt: {exc}")
|
|
192
|
+
logger.error("Could not record the halt; continuing to cancel and close", exc_info=True)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
broker.cancel_all_orders()
|
|
196
|
+
report.orders_cancelled = True
|
|
197
|
+
except BrokerError as exc:
|
|
198
|
+
report.failures.append(f"could not cancel open orders: {exc}")
|
|
199
|
+
logger.error("Could not cancel open orders; still attempting to close positions", exc_info=True)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
# Orders were cancelled above; asking again is harmless and covers the case
|
|
203
|
+
# where that call failed but the close path can still clear them.
|
|
204
|
+
# The return value is consulted, not discarded. A broker that answers False has
|
|
205
|
+
# said it did not take the request, and recording that as "submitted" would be
|
|
206
|
+
# the same lie this change exists to remove, one line above the fix.
|
|
207
|
+
report.close_submitted = bool(broker.close_all_positions(cancel_orders=True))
|
|
208
|
+
if not report.close_submitted:
|
|
209
|
+
report.failures.append("the broker did not accept the close request")
|
|
210
|
+
except BrokerError as exc:
|
|
211
|
+
report.failures.append(f"could not close positions: {exc}")
|
|
212
|
+
logger.error("Could not close positions", exc_info=True)
|
|
213
|
+
|
|
214
|
+
_confirm(broker, report)
|
|
215
|
+
|
|
216
|
+
# Only on an observed-flat book, and only for what this ledger was expecting.
|
|
217
|
+
# `pending` means the closes were accepted and nothing has confirmed they filled;
|
|
218
|
+
# recording a terminal fact there would assert an exit nobody has seen, which is
|
|
219
|
+
# the submitted-versus-observed confusion this module exists to end.
|
|
220
|
+
if ledger is not None and report.observed == CLOSED:
|
|
221
|
+
try:
|
|
222
|
+
report.recorded_closed = ledger.record_flatten(
|
|
223
|
+
sorted(ledger.expected_positions()),
|
|
224
|
+
reason=reason,
|
|
225
|
+
actor=actor,
|
|
226
|
+
checked_at=report.checked_at,
|
|
227
|
+
)
|
|
228
|
+
except Exception as exc: # noqa: BLE001 - a bookkeeping failure must not fail the flatten
|
|
229
|
+
report.failures.append(f"could not record the flatten in the ledger: {exc}")
|
|
230
|
+
logger.error("Could not record the flatten in the ledger", exc_info=True)
|
|
231
|
+
|
|
232
|
+
if report.complete:
|
|
233
|
+
logger.warning("Flatten complete and confirmed flat: %s", reason)
|
|
234
|
+
elif report.observed == PENDING:
|
|
235
|
+
logger.error(
|
|
236
|
+
"Flatten submitted but NOT FLAT: %d position(s) still open at %s (%s)",
|
|
237
|
+
len(report.remaining),
|
|
238
|
+
report.checked_at,
|
|
239
|
+
", ".join(sorted(report.remaining)),
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
logger.error("Flatten INCOMPLETE: %s", report.failures)
|
|
243
|
+
return report
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _confirm(broker: Broker, report: FlattenReport) -> None:
|
|
247
|
+
"""Ask the broker what is actually open, and record it.
|
|
248
|
+
|
|
249
|
+
The step that makes the difference between "we asked" and "it happened". A close is
|
|
250
|
+
a *request*: outside market hours it queues, and at the open it fills piecemeal, so
|
|
251
|
+
the interval where the account still holds everything is minutes wide at best.
|
|
252
|
+
|
|
253
|
+
One read, and no waiting. This is the command someone runs when they have stopped
|
|
254
|
+
trusting the system, so it must answer now rather than block on a fill that may be
|
|
255
|
+
hours away — and it must never poll a broker in a loop. Reporting a truthful
|
|
256
|
+
``pending`` is worth more than a confident answer arrived at late.
|
|
257
|
+
|
|
258
|
+
A read that fails leaves ``UNKNOWN``, never ``CLOSED``: this is exactly the place
|
|
259
|
+
where an absent answer must not be rendered as a clean one.
|
|
260
|
+
"""
|
|
261
|
+
report.checked_at = datetime.now(timezone.utc).isoformat()
|
|
262
|
+
try:
|
|
263
|
+
positions = broker.list_positions()
|
|
264
|
+
if positions is None:
|
|
265
|
+
# A broker that answers "I don't know" must not be read as "nothing is
|
|
266
|
+
# open". `or []` did exactly that, in the one function whose whole purpose
|
|
267
|
+
# is refusing to call an unobserved book flat.
|
|
268
|
+
raise BrokerError("broker returned no position list")
|
|
269
|
+
# Inside the guard: a lazy or paged result raises while being consumed, not
|
|
270
|
+
# when it is returned.
|
|
271
|
+
report.remaining = [position.symbol for position in positions]
|
|
272
|
+
except Exception as exc: # noqa: BLE001 - see below
|
|
273
|
+
# Deliberately bare, matching `PositionLedger.reconcile`. This is a *reporting*
|
|
274
|
+
# step running after the halt, the cancel and the closes have already happened,
|
|
275
|
+
# and a reporting step that raises destroys the report of the order path — the
|
|
276
|
+
# operator loses not just the confirmation but the fact that the halt was set
|
|
277
|
+
# and the closes were sent. Catching only `BrokerError` was a promise about
|
|
278
|
+
# brokers this module does not control: a `TimeoutError` from a socket, or an
|
|
279
|
+
# `AttributeError` from a partial implementation, escaped and left a traceback
|
|
280
|
+
# where the report should be. `UNKNOWN` already means precisely this.
|
|
281
|
+
report.failures.append(f"could not confirm positions: {exc}")
|
|
282
|
+
report.observed = UNKNOWN
|
|
283
|
+
logger.error("Could not confirm the flatten against the broker", exc_info=True)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if report.remaining:
|
|
287
|
+
report.observed = PENDING if report.close_submitted else NOT_CLOSED
|
|
288
|
+
else:
|
|
289
|
+
report.observed = CLOSED
|
|
290
|
+
|
|
291
|
+
# The same question asked of the other leg. `orders_cancelled` is a *submitted*
|
|
292
|
+
# fact, and this change exists because submitted is not observed — applying that to
|
|
293
|
+
# the positions and not to the orders would leave `complete` meaning "one observed
|
|
294
|
+
# fact and one hopeful one". A resting order the cancel missed can refill the book
|
|
295
|
+
# after the instant this read was taken.
|
|
296
|
+
try:
|
|
297
|
+
resting = broker.list_open_orders()
|
|
298
|
+
report.open_orders = len(resting or [])
|
|
299
|
+
except Exception as exc: # noqa: BLE001 - as above; never break the report
|
|
300
|
+
report.failures.append(f"could not confirm open orders: {exc}")
|
|
301
|
+
report.open_orders = None
|
|
302
|
+
logger.error("Could not confirm resting orders against the broker", exc_info=True)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The kill switch.
|
|
2
|
+
|
|
3
|
+
There was previously no way to say "stop trading" and have it stick. Orders could be
|
|
4
|
+
cancelled and positions closed, but nothing recorded the *decision*, so a running
|
|
5
|
+
engine would open a new position on the next bar and an operator who had just
|
|
6
|
+
flattened the account would watch it refill. Stopping needs durable state, not a
|
|
7
|
+
one-off action.
|
|
8
|
+
|
|
9
|
+
That state is a file, deliberately:
|
|
10
|
+
|
|
11
|
+
**No database on the trade clock.** The order path holds no connection to anything
|
|
12
|
+
that can be down. A file under the state root is readable by a hung engine, by the
|
|
13
|
+
CLI, and by a human with `cat`, which is exactly the population that needs it during
|
|
14
|
+
an incident.
|
|
15
|
+
|
|
16
|
+
**A halt blocks entries, never exits.** A switch that also blocked closing orders
|
|
17
|
+
would trap the book at precisely the moment someone had decided to stop - and would
|
|
18
|
+
deadlock any flatten against its own gate. Getting out is always allowed.
|
|
19
|
+
|
|
20
|
+
**It reports; it does not remediate.** Setting a halt places no orders and closes
|
|
21
|
+
nothing. Flattening is a separate, explicit act (:mod:`quantred.execution.flatten`).
|
|
22
|
+
|
|
23
|
+
**Absent is not halted.** A missing or unreadable file means "no halt recorded", so a
|
|
24
|
+
corrupt state file cannot silently freeze trading. The reverse default would make an
|
|
25
|
+
unrelated disk problem look like a deliberate stop.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import threading
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List, Optional
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
#: The scope that halts everything, whatever strategy is running.
|
|
40
|
+
ALL = "all"
|
|
41
|
+
|
|
42
|
+
_LOCK = threading.Lock()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_halt_path() -> Path:
|
|
46
|
+
"""Where halt state lives - beside the ledger, under the state root."""
|
|
47
|
+
from quantred.settings import state_path
|
|
48
|
+
|
|
49
|
+
return state_path("logs", "halts.json")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Halt:
|
|
54
|
+
"""One recorded decision to stop trading."""
|
|
55
|
+
|
|
56
|
+
scope: str
|
|
57
|
+
reason: str
|
|
58
|
+
actor: str
|
|
59
|
+
set_at: str
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
62
|
+
return {"scope": self.scope, "reason": self.reason, "actor": self.actor, "set_at": self.set_at}
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return f"[{self.scope}] {self.reason} (set by {self.actor} at {self.set_at})"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HaltState:
|
|
69
|
+
"""Durable halt state, scoped globally or to a single strategy."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, path: Optional[Any] = None):
|
|
72
|
+
self.path = Path(path) if path else default_halt_path()
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ #
|
|
75
|
+
# Reading
|
|
76
|
+
# ------------------------------------------------------------------ #
|
|
77
|
+
def active(self, scope: Optional[str] = None) -> Optional[Halt]:
|
|
78
|
+
"""The halt in force for ``scope``, if any. A global halt covers every scope.
|
|
79
|
+
|
|
80
|
+
Returns the global halt in preference to a scoped one, since that is the more
|
|
81
|
+
severe of the two and the more useful thing to show an operator.
|
|
82
|
+
"""
|
|
83
|
+
halts = self._read()
|
|
84
|
+
if ALL in halts:
|
|
85
|
+
return halts[ALL]
|
|
86
|
+
if scope is not None and scope in halts:
|
|
87
|
+
return halts[scope]
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def is_halted(self, scope: Optional[str] = None) -> bool:
|
|
91
|
+
return self.active(scope) is not None
|
|
92
|
+
|
|
93
|
+
def list(self) -> List[Halt]:
|
|
94
|
+
return sorted(self._read().values(), key=lambda h: h.set_at, reverse=True)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ #
|
|
97
|
+
# Writing
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
def set(self, reason: str, *, actor: str, scope: str = ALL) -> Halt:
|
|
100
|
+
"""Record a halt. Re-halting an already-halted scope replaces the reason."""
|
|
101
|
+
halt = Halt(
|
|
102
|
+
scope=scope,
|
|
103
|
+
reason=reason,
|
|
104
|
+
actor=actor,
|
|
105
|
+
set_at=datetime.now(timezone.utc).isoformat(),
|
|
106
|
+
)
|
|
107
|
+
halts = self._read()
|
|
108
|
+
halts[scope] = halt
|
|
109
|
+
self._write(halts)
|
|
110
|
+
logger.warning("HALT SET %s", halt)
|
|
111
|
+
return halt
|
|
112
|
+
|
|
113
|
+
def clear(self, scope: str = ALL) -> bool:
|
|
114
|
+
"""Lift a halt. Returns True if one was actually in force."""
|
|
115
|
+
halts = self._read()
|
|
116
|
+
if scope not in halts:
|
|
117
|
+
return False
|
|
118
|
+
removed = halts.pop(scope)
|
|
119
|
+
self._write(halts)
|
|
120
|
+
logger.warning("Halt cleared: %s", removed)
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ #
|
|
124
|
+
# Storage
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
def _read(self) -> Dict[str, Halt]:
|
|
127
|
+
if not self.path.exists():
|
|
128
|
+
return {}
|
|
129
|
+
try:
|
|
130
|
+
raw = json.loads(self.path.read_text() or "{}")
|
|
131
|
+
except (OSError, json.JSONDecodeError):
|
|
132
|
+
# Absent is not halted: an unreadable file must not look like a decision
|
|
133
|
+
# nobody made. Loud, because the switch is now not working.
|
|
134
|
+
logger.error("Halt state at %s is unreadable; treating as NO halt", self.path, exc_info=True)
|
|
135
|
+
return {}
|
|
136
|
+
if not isinstance(raw, dict):
|
|
137
|
+
# Valid JSON that is not an object - `null`, a list, a bare number. Only a
|
|
138
|
+
# hand edit produces one, which is exactly when this is read: during an
|
|
139
|
+
# incident. Without this the scope loop raises AttributeError straight out
|
|
140
|
+
# of the entry decision on the trade clock, which is the reverse of the
|
|
141
|
+
# default this module promises, arriving as a crash rather than a halt.
|
|
142
|
+
logger.error("Halt state at %s is not an object; treating as NO halt", self.path)
|
|
143
|
+
return {}
|
|
144
|
+
halts = {}
|
|
145
|
+
for scope, record in raw.items():
|
|
146
|
+
try:
|
|
147
|
+
halts[scope] = Halt(
|
|
148
|
+
scope=scope,
|
|
149
|
+
reason=record["reason"],
|
|
150
|
+
actor=record.get("actor", "unknown"),
|
|
151
|
+
set_at=record.get("set_at", ""),
|
|
152
|
+
)
|
|
153
|
+
except (TypeError, KeyError):
|
|
154
|
+
logger.error("Skipping malformed halt record for scope %r", scope)
|
|
155
|
+
return halts
|
|
156
|
+
|
|
157
|
+
def _write(self, halts: Dict[str, Halt]) -> None:
|
|
158
|
+
payload = json.dumps({scope: halt.as_dict() for scope, halt in halts.items()}, indent=2)
|
|
159
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
# Write-then-rename: a process killed mid-write leaves the previous state
|
|
161
|
+
# intact rather than a truncated file that reads as "no halt".
|
|
162
|
+
temporary = self.path.with_suffix(".json.tmp")
|
|
163
|
+
with _LOCK:
|
|
164
|
+
temporary.write_text(payload)
|
|
165
|
+
os.replace(temporary, self.path)
|