quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)