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,758 @@
1
+ """The position reconciliation ledger: what we meant to do, versus what happened.
2
+
3
+ Orders are submitted and then forgotten. A partial fill, a rejection, or a human
4
+ closing a position in the broker's web UI is currently discovered by reading the
5
+ P&L and being surprised — which is to say, discovered late, by inference, at the
6
+ worst moment.
7
+
8
+ This is an append-only local record of **intent** (what was submitted) and
9
+ **observation** (what the broker reported), reconciled on a schedule against the
10
+ broker's actual account state. Its whole job is to make divergence *visible*.
11
+
12
+ Three rules, and the first two are the ones that keep it safe:
13
+
14
+ **The broker is authoritative, always.** This ledger never overrides, corrects, or
15
+ "repairs" the account. It records what we believed so a difference can be noticed;
16
+ when the two disagree, the broker is right and the ledger is a question for a human.
17
+
18
+ **It reports; it never remediates.** No corrective order is ever placed from here.
19
+ An automated system that notices a missed fill and fixes it is an automated system
20
+ that can double a position at 3am while nobody is watching. Detection is the
21
+ feature; action is a decision.
22
+
23
+ **Append-only.** Like the research journal, entries are never edited or deleted. A
24
+ ledger you can rewrite is a ledger that cannot be trusted about the past, which is
25
+ the only thing it exists to be trusted about.
26
+
27
+ Deliberately trade-clock only: plain stdlib, no pandas, no research imports, and no
28
+ unbounded work in the order path.
29
+ """
30
+
31
+ import json
32
+ import logging
33
+ import threading
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Any, Dict, Iterable, List, Optional
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ #: Divergence classes. Named rather than boolean because the three mean genuinely
42
+ #: different things to whoever has to act on them.
43
+ #: The shape of a record written by this code. Stamped on every line.
44
+ #:
45
+ #: This file is append-only and has nothing behind it to rebuild from, so a record
46
+ #: written badly is readable but not fixable, forever. Two compatibility decisions have
47
+ #: already been improvised per-field - a fill's ``basis``, a decision's ``reason_code`` -
48
+ #: and each cost a live session before it was noticed. A version is what stops the third
49
+ #: being improvised too: a reader can ask what shape it is looking at rather than
50
+ #: inferring it from which keys happen to be present.
51
+ #:
52
+ #: * **absent** - written before this existed. Fill quantities carry no ``basis`` and may
53
+ #: have been summed as if incremental when they were cumulative; sides were defaulted
54
+ #: to buy, so shorts were recorded long. Reported at reconciliation, never
55
+ #: reinterpreted, because those numbers cannot be recovered.
56
+ #: * **1** - fills carry ``basis``, ``fill_price``, ``filled_at`` and ``broker_fee``;
57
+ #: intents carry ``decision_id`` and the order plan; decisions carry ``reason_code``;
58
+ #: adoptions are their own event. No record says what run wrote it.
59
+ #: * **2** - the current shape. Adds the ``session`` event: a header written once at the
60
+ #: start of a run, saying under what contract the records after it were produced. A
61
+ #: file with no session record is one written before runs identified themselves, and
62
+ #: reads as exactly that - the records are still readable, and what they were traded
63
+ #: at is simply not recoverable. Never inferred from whatever run happens to be
64
+ #: asking now, which would put a contract nobody used onto somebody else's fills.
65
+ #: * **3** - the current shape. Adds the ``session_outcome`` event: what became of the
66
+ #: header before it. The header is written when the contract is *committed to*, before
67
+ #: anything can fill, so a run that dies on its first bar still records what its fills
68
+ #: were measured against - and the cost of that placement was that a committed run and
69
+ #: a run that actually traded were the same record. A v2 header has no outcome, and
70
+ #: that is a third state rather than either of the two: not started, not refused,
71
+ #: simply not carried by the file. Never inferred from the absence of a later row,
72
+ #: because a session that began and traded nothing and one that never began are
73
+ #: indistinguishable from silence and are opposite facts.
74
+ #: * **4** - the current shape. Adds the ``flattened`` event: a position an operator
75
+ #: closed directly at the broker, bypassing the engine. A flatten has to work when the
76
+ #: engine is wedged, so the ledger never observes those exits and reconciliation
77
+ #: reported every one of them as ``missing`` from then on, for good. A v3 file simply
78
+ #: has none, and a book flattened before this existed still reads as missing - the
79
+ #: records cannot say what happened to it, and inferring a flatten from a divergence
80
+ #: would excuse exactly the dropped fill the divergence exists to surface.
81
+ LEDGER_VERSION = 4
82
+
83
+ #: What a recorded fill quantity measures. ``CUMULATIVE`` is the order's running
84
+ #: total (what Alpaca reports); ``INCREMENTAL`` is this event's own shares.
85
+ CUMULATIVE = "cumulative"
86
+ INCREMENTAL = "incremental"
87
+
88
+
89
+ def filled_quantity(fill_events: Iterable[Dict[str, Any]]) -> float:
90
+ """How much of one order actually filled, from its fill events.
91
+
92
+ The same rule :meth:`PositionLedger._replay` applies, in one place, because the
93
+ two had already diverged: a cumulative report is the whole truth about the order
94
+ so the last one wins, while incremental events each stand alone and sum. Reading
95
+ every fill as cumulative reported an order filled 3+3+2 as having filled 2, which
96
+ ``fill_summary`` then counted as a short fill against a submitted 8.
97
+
98
+ Records written before ``basis`` existed carry none, and are read the old way -
99
+ as cumulative - which is what they were.
100
+ """
101
+ total = 0.0
102
+ for event in fill_events:
103
+ qty = float(event.get("qty") or 0.0)
104
+ if event.get("basis") == INCREMENTAL:
105
+ total += qty
106
+ else:
107
+ total = qty
108
+ return total
109
+
110
+
111
+ MISSING = "missing" # we believe in a position the broker does not have
112
+ UNEXPECTED = "unexpected" # the broker holds something we never ordered
113
+ QUANTITY_DRIFT = "quantity_drift" # both agree it exists, at different sizes
114
+
115
+ _LOCK = threading.Lock()
116
+
117
+
118
+ def slippage_bps(side: Optional[str], reference_price, fill_price) -> Optional[float]:
119
+ """Adverse price movement between deciding and filling, in basis points.
120
+
121
+ Signed so that **positive is always worse**, whichever way the trade went: a buy
122
+ that paid more than the reference price and a sell that received less both come
123
+ out positive. An unsigned measure would let a good sell cancel a bad buy in any
124
+ average taken over it.
125
+
126
+ ``None`` when either price is missing — absent is not zero, and a fill with no
127
+ recorded price must not read as one that filled exactly on reference.
128
+ """
129
+ if not reference_price or fill_price is None:
130
+ return None
131
+ direction = 1.0 if str(side).lower() in {"buy", "long"} else -1.0
132
+ return direction * (float(fill_price) - float(reference_price)) / float(reference_price) * 10_000.0
133
+
134
+
135
+ def _elapsed_ms(start: Optional[str], end: Optional[str]) -> Optional[float]:
136
+ """Milliseconds between two ISO timestamps, or None if either is missing."""
137
+ if not start or not end:
138
+ return None
139
+ try:
140
+ return (datetime.fromisoformat(end) - datetime.fromisoformat(start)).total_seconds() * 1000.0
141
+ except ValueError:
142
+ return None
143
+
144
+
145
+ def default_ledger_path() -> Path:
146
+ """Where the ledger lives — beside the research journal, under the state root."""
147
+ from quantred.settings import state_path
148
+
149
+ return state_path("logs", "position_ledger.jsonl")
150
+
151
+
152
+ def small_real_ledger_path() -> Path:
153
+ """Telemetry from a deliberately shrunken run, kept in its own file.
154
+
155
+ A separate path rather than a field on the shared one, because every roll-up over a
156
+ ledger — slippage, fill ratio, fees — is an average, and averaging fills from a
157
+ book at full size with fills from the same book at a twentieth of it produces a
158
+ number describing neither. The whole reason the small run exists is that its
159
+ execution is *different*, so mixing the two discards the finding.
160
+
161
+ Not a trial journal, and deliberately nowhere near one: a run that measures its own
162
+ execution has searched nothing, so it must never reach the multiple-testing total
163
+ that the deflated Sharpe deflates against.
164
+ """
165
+ from quantred.settings import state_path
166
+
167
+ return state_path("logs", "small_real_ledger.jsonl")
168
+
169
+
170
+ @dataclass
171
+ class Divergence:
172
+ """One disagreement between what we intended and what the broker holds."""
173
+
174
+ symbol: str
175
+ kind: str
176
+ expected_qty: float
177
+ actual_qty: float
178
+ detail: str
179
+
180
+ def as_dict(self) -> Dict[str, Any]:
181
+ return {
182
+ "symbol": self.symbol,
183
+ "kind": self.kind,
184
+ "expected_qty": self.expected_qty,
185
+ "actual_qty": self.actual_qty,
186
+ "detail": self.detail,
187
+ }
188
+
189
+
190
+ @dataclass
191
+ class ReconcileReport:
192
+ """The outcome of one sweep."""
193
+
194
+ checked_at: str
195
+ divergences: List[Divergence] = field(default_factory=list)
196
+ n_expected: int = 0
197
+ n_actual: int = 0
198
+
199
+ @property
200
+ def clean(self) -> bool:
201
+ return not self.divergences
202
+
203
+ def as_dict(self) -> Dict[str, Any]:
204
+ return {
205
+ "checked_at": self.checked_at,
206
+ "clean": self.clean,
207
+ "n_expected": self.n_expected,
208
+ "n_actual": self.n_actual,
209
+ "divergences": [d.as_dict() for d in self.divergences],
210
+ }
211
+
212
+ def summary(self) -> str:
213
+ if self.clean:
214
+ return f"Reconciled {self.n_actual} broker position(s): no divergence."
215
+ lines = [f"RECONCILIATION FOUND {len(self.divergences)} DIVERGENCE(S):"]
216
+ for d in self.divergences:
217
+ lines.append(f" [{d.kind}] {d.symbol}: {d.detail}")
218
+ lines.append("The broker's state is authoritative. Nothing has been corrected automatically.")
219
+ return "\n".join(lines)
220
+
221
+
222
+ class PositionLedger:
223
+ """Append-only record of intended orders and observed fills."""
224
+
225
+ def __init__(self, path: Optional[Any] = None):
226
+ self.path = Path(path) if path else default_ledger_path()
227
+ self.path.parent.mkdir(parents=True, exist_ok=True)
228
+
229
+ # ------------------------------------------------------------------ #
230
+ # Writing
231
+ # ------------------------------------------------------------------ #
232
+ def record_intent(
233
+ self,
234
+ symbol: str,
235
+ side: str,
236
+ qty: float,
237
+ order_id: Optional[str] = None,
238
+ decision_id: Optional[str] = None,
239
+ plan: Optional[Dict[str, Any]] = None,
240
+ ) -> None:
241
+ """An order we submitted. Written at submission, before any fill is known.
242
+
243
+ ``decision_id`` and ``order_id`` are what make the lifecycle reconstructable:
244
+ the decision says what the strategy wanted, this says what was sent, and the
245
+ fills that follow carry the same ``order_id``. Without the pair, "what did we
246
+ expect, what did we submit, what did the broker do" is three separate guesses.
247
+ """
248
+ self._append(
249
+ {
250
+ "event": "intent",
251
+ "symbol": symbol,
252
+ "side": side,
253
+ "qty": float(qty),
254
+ "order_id": order_id,
255
+ "decision_id": decision_id,
256
+ **({"plan": plan} if plan else {}),
257
+ }
258
+ )
259
+
260
+ def record_fill(
261
+ self,
262
+ symbol: str,
263
+ side: str,
264
+ qty: float,
265
+ order_id: Optional[str] = None,
266
+ status: str = "filled",
267
+ basis: str = CUMULATIVE,
268
+ fill_price: Optional[float] = None,
269
+ filled_at: Optional[str] = None,
270
+ broker_fee: Optional[float] = None,
271
+ ) -> None:
272
+ """A fill (or partial fill) the broker reported.
273
+
274
+ ``basis`` says what ``qty`` measures, and getting it wrong silently inflates
275
+ the book. Alpaca reports the order's *running total* on every partial fill and
276
+ again on the final fill, so summing those events counts the same shares
277
+ repeatedly - an order that filled 8 in three reports arrives as 21.
278
+ """
279
+ self._append(
280
+ {
281
+ "event": "fill",
282
+ "symbol": symbol,
283
+ "side": side,
284
+ "qty": float(qty),
285
+ "order_id": order_id,
286
+ "status": status,
287
+ "basis": basis,
288
+ "fill_price": fill_price,
289
+ "filled_at": filled_at,
290
+ # Separate from the model's estimate on the intent. A paper venue
291
+ # reports no fees, and ``None`` there means "not reported", which is
292
+ # not the same as zero and must not be averaged as though it were.
293
+ "broker_fee": broker_fee,
294
+ }
295
+ )
296
+
297
+ def record_session(self, mode: str, context: Optional[Dict[str, Any]] = None) -> None:
298
+ """What contract the records after this one were produced under.
299
+
300
+ Every other event says what happened to one symbol; this says what run it
301
+ happened in. Without it a fill is a number with no denominator — a ledger can
302
+ say an order filled 40 basis points wide and not what capital, what book or
303
+ what account that was measured against, and those are exactly the facts that
304
+ decide whether the number means anything.
305
+
306
+ ``context`` is passed through uninterpreted. The trade clock has no business
307
+ knowing what a mode chooses to record about itself, and a schema here would be
308
+ one more place to keep in step with the surfaces that write it.
309
+
310
+ It is a header, not a position event. :meth:`_replay` and :meth:`lifecycles`
311
+ both dispatch on the event *name* — an event neither of them names moves
312
+ nothing, whatever fields it happens to carry — so a mode free to record what it
313
+ likes here cannot make a session start change the book by choosing an unlucky
314
+ key. That is the property worth pinning, and it is stronger than omitting a
315
+ symbol: omission would only hold until someone recorded a per-symbol fact about
316
+ the run.
317
+ """
318
+ # The fixed keys go *after* the spread. Written the other way round, a context
319
+ # key named `event` silently replaced the record's kind: a session header
320
+ # carrying `{"event": "fill", "symbol": ..., "qty": 999}` stopped being a header,
321
+ # vanished from `sessions()`, and moved the replayed book by 999 shares. A caller
322
+ # is trusted to choose what it records about itself and must not be able to
323
+ # choose what kind of record it is writing.
324
+ self._append({**(context or {}), "event": "session", "mode": mode})
325
+
326
+ #: The outcomes a session can reach. ``started`` is the only one that means fills
327
+ #: were possible; everything else means a contract was committed to and then was
328
+ #: not traded. Deliberately a closed set: a reader has to be able to tell "this run
329
+ #: never began" from "this run began and did nothing", and free text cannot.
330
+ SESSION_STARTED = "started"
331
+ SESSION_REFUSED = "refused"
332
+
333
+ def record_session_outcome(self, outcome: str, detail: Optional[str] = None) -> None:
334
+ """What became of the session header written before it.
335
+
336
+ The header is written when the contract is *committed to* - before anything can
337
+ fill, so a run that dies on its first bar still records what capital and what
338
+ book its fills were measured against. The cost of that placement is that a
339
+ committed run and a run that actually traded were the same record, and a report
340
+ listing both alike says a session happened when one never began.
341
+
342
+ A separate event rather than a rewrite: the file is append-only, and an outcome
343
+ is genuinely later news about the same run. ``_replay`` and ``lifecycles``
344
+ dispatch on the event name, so a name neither of them knows moves no position,
345
+ whatever it carries.
346
+
347
+ **Never inferred from absence.** A row written before outcomes existed has no
348
+ outcome, and that is a third thing - not started, not refused. Readers say so
349
+ rather than guessing, because a session that traded nothing and a session that
350
+ never began are indistinguishable from silence and are opposite facts.
351
+ """
352
+ paired = self.sessions_with_outcomes()
353
+ if not paired or paired[-1]["outcome"] is not None:
354
+ # Nothing to attach it to. `live` runs the same engine and records no
355
+ # header, so an outcome written there would belong to no run — and a later
356
+ # reader pairing by position would hang it on whichever session came next.
357
+ # Refusing at the writer makes the orphan impossible rather than something
358
+ # every reader has to remember to drop.
359
+ #
360
+ # One outcome per session is today's lifecycle, not a law: a `stopped`
361
+ # event after `started` is the obvious next one, and this is the line it
362
+ # will have to change.
363
+ return
364
+ self._append(
365
+ {
366
+ "event": "session_outcome",
367
+ "outcome": outcome,
368
+ **({"detail": detail} if detail is not None else {}),
369
+ }
370
+ )
371
+
372
+ def sessions_with_outcomes(self) -> List[Dict[str, Any]]:
373
+ """Each session header paired with the outcome recorded after it, if any.
374
+
375
+ Paired by position, which is what an append-only file makes true: an outcome
376
+ belongs to the most recent header before it. An outcome with no header before
377
+ it is dropped rather than attached to the next one - it belongs to a run this
378
+ file did not record the start of, and hanging it on a later session would
379
+ mislabel that session with another run's ending.
380
+
381
+ Returned as ``{"session": ..., "outcome": ...}`` rather than folded into the
382
+ header, because ``record_session`` passes its context through uninterpreted: a
383
+ mode is free to record a key called ``outcome`` about itself, and a derived key
384
+ merged into the same dict would silently take its place.
385
+ """
386
+ paired: List[Dict[str, Any]] = []
387
+ for record in self._read():
388
+ event = record.get("event")
389
+ if event == "session":
390
+ paired.append({"session": record, "outcome": None})
391
+ elif event == "session_outcome" and paired and paired[-1]["outcome"] is None:
392
+ # Only the first outcome after a header counts. A second would be a
393
+ # later run's, or a bug; either way the first is what that session did.
394
+ paired[-1]["outcome"] = record
395
+ return paired
396
+
397
+ def sessions(self) -> List[Dict[str, Any]]:
398
+ """Every session header in this file, oldest first.
399
+
400
+ A list rather than "the current one": an append-only file accumulates runs, and
401
+ a reader summarising it needs to know when it is looking at more than one. A
402
+ file with none is one written before runs identified themselves — reported as
403
+ unknown, never backfilled from whoever is asking now.
404
+ """
405
+ return [record for record in self._read() if record.get("event") == "session"]
406
+
407
+ def record_adoption(self, symbol: str, side: str, qty: float, source: str = "broker") -> None:
408
+ """A position found already open and taken over, at start-up or a resync.
409
+
410
+ A *baseline*, not a fill: it replaces whatever the ledger believed about the
411
+ symbol rather than adding to it, because the broker's holding at that moment
412
+ is the whole truth about it. Without this the durable record disagrees with
413
+ the very book the process just adopted - the engine resumes six positions and
414
+ reconciliation calls all six unexpected, which is the one situation where an
415
+ operator most needs the two to agree.
416
+ """
417
+ self._append(
418
+ {
419
+ "event": "adopt",
420
+ "symbol": symbol,
421
+ "side": side,
422
+ "qty": abs(float(qty)),
423
+ "source": source,
424
+ }
425
+ )
426
+
427
+ def record_close(self, symbol: str) -> None:
428
+ """A position we asked to close. Zeroes our expectation for the symbol."""
429
+ self._append({"event": "close", "symbol": symbol})
430
+
431
+ #: A position closed by an operator's flatten, outside the engine.
432
+ FLATTENED = "flattened"
433
+
434
+ def record_flatten(
435
+ self, symbols: Iterable[str], *, reason: str, actor: str, checked_at: Optional[str] = None
436
+ ) -> List[str]:
437
+ """Positions an operator closed directly at the broker, bypassing the engine.
438
+
439
+ A flatten goes straight to the venue on purpose - it has to work when the engine
440
+ is wedged - so the ledger never sees those exits. What it sees is a book it still
441
+ expects and a broker holding nothing, which reconciliation reports as ``missing``
442
+ with the note that a fill may have been dropped. Every symbol, for good. The
443
+ wording is honest and the file is useless: a ledger that can never reconcile
444
+ clean again cannot tell "something is wrong" from "the operator meant it".
445
+
446
+ Recorded as its own kind rather than as :meth:`record_close`, which is what the
447
+ *engine* writes when it asks to close a position. Both zero the expectation, and
448
+ collapsing them would make an operator's emergency exit indistinguishable from
449
+ an ordinary one in the record that exists to tell them apart.
450
+
451
+ **No fill is invented.** This says the position is gone and who ended it - not
452
+ at what price, not for how much. A flatten submits market orders whose fills the
453
+ ledger genuinely did not observe, and manufacturing them would put fabricated
454
+ prices into the one file that is supposed to be evidence.
455
+
456
+ Returns the symbols actually recorded, which is what the caller should report.
457
+ """
458
+ recorded = []
459
+ for symbol in symbols:
460
+ self._append(
461
+ {
462
+ "event": self.FLATTENED,
463
+ "symbol": symbol,
464
+ "reason": reason,
465
+ "actor": actor,
466
+ **({"checked_at": checked_at} if checked_at else {}),
467
+ }
468
+ )
469
+ recorded.append(symbol)
470
+ return recorded
471
+
472
+ def flattens(self) -> List[Dict[str, Any]]:
473
+ """Every flatten this file recorded, oldest first."""
474
+ return [record for record in self._read() if record.get("event") == self.FLATTENED]
475
+
476
+ def record_decision(self, decision) -> None:
477
+ """What execution decided about a signal, and which guards it consulted.
478
+
479
+ Deliberately recorded whether or not an order resulted: a *declined* signal is
480
+ the case that leaves no other trace, and it is the one you go looking for when
481
+ asking why a strategy did nothing all day. Carries no position meaning, so
482
+ :meth:`expected_positions` ignores it.
483
+ """
484
+ self._append({"event": "decision", **decision.as_dict()})
485
+
486
+ def _append(self, record: Dict[str, Any]) -> None:
487
+ """One line, flushed, under a lock.
488
+
489
+ Failure here is logged and swallowed: this is a *detection* aid, and a
490
+ ledger that cannot be written must never be the reason an order path
491
+ raises. A gap in the ledger is a visible reconciliation divergence, which
492
+ is exactly the signal it exists to produce.
493
+ """
494
+ # The stamp is applied last for the same reason a session's kind is: a record
495
+ # that could overwrite its own version or timestamp would be unreadable by the
496
+ # one mechanism that exists to make every past shape readable.
497
+ record = {**record, "ts": datetime.now(timezone.utc).isoformat(), "v": LEDGER_VERSION}
498
+ try:
499
+ with _LOCK:
500
+ with self.path.open("a") as fh:
501
+ fh.write(json.dumps(record) + "\n")
502
+ except OSError:
503
+ logger.warning("Could not append to the position ledger at %s", self.path, exc_info=True)
504
+
505
+ # ------------------------------------------------------------------ #
506
+ # Reading
507
+ # ------------------------------------------------------------------ #
508
+ def expected_positions(self) -> Dict[str, float]:
509
+ """Net signed quantity per symbol implied by the ledger.
510
+
511
+ A replay, not a running total: the file is the state, so a restarted process
512
+ recovers its expectation exactly. Long is positive, short negative, and a
513
+ symbol netting to zero is dropped rather than recorded as a zero position —
514
+ "flat" and "never traded" should look the same to a reconciliation.
515
+ """
516
+ net, _ = self._replay()
517
+ return net
518
+
519
+ def _replay(self) -> tuple:
520
+ """``(net signed quantity per symbol, saw pre-basis fill records)``.
521
+
522
+ Cumulative fills are resolved to the **last** report per order rather than
523
+ summed, which makes the replay idempotent: a duplicated event, a stream
524
+ reconnect that repeats history, or a missed intermediate partial all land on
525
+ the same answer, because the broker's running total is already the whole truth
526
+ about that order.
527
+
528
+ Bracket legs fall out of this correctly without special-casing - the entry and
529
+ each protective leg carry their own order id, so a stop that fills nets against
530
+ the entry it closes.
531
+ """
532
+ by_order: Dict[str, Dict[str, Any]] = {}
533
+ #: symbol -> (sequence, quantity it was reset to). A close resets to zero; an
534
+ #: adoption resets to what the broker held. Both discard everything before.
535
+ reset: Dict[str, tuple] = {}
536
+ legacy = False
537
+ seq = 0
538
+
539
+ for record in self._read():
540
+ symbol = record.get("symbol")
541
+ if not symbol:
542
+ continue
543
+ seq += 1
544
+ event = record.get("event")
545
+ if event in ("close", self.FLATTENED):
546
+ # Both zero the symbol, so only activity *after* one counts. They are
547
+ # separate events because the *reason* differs and a report has to be
548
+ # able to say which — but to a replay of the book they mean the same
549
+ # thing, and giving the flatten its own arithmetic would be a second
550
+ # answer to a question that has one.
551
+ reset[symbol] = (seq, 0.0)
552
+ elif event == "adopt":
553
+ signed = abs(float(record.get("qty") or 0.0))
554
+ if str(record.get("side", "")).lower() in {"sell", "short"}:
555
+ signed = -signed
556
+ reset[symbol] = (seq, signed)
557
+ elif event == "fill":
558
+ basis = record.get("basis")
559
+ if basis is None:
560
+ # Version and basis are checked separately on purpose: a record can
561
+ # be current-shape and still lack a basis if a writer omitted it,
562
+ # and that is as unreadable as a pre-version one.
563
+ # Written before fill accounting distinguished the two, when the
564
+ # side was defaulted as well. Counted the old way so a reconcile
565
+ # still runs, and reported, because silently mixing the two would
566
+ # produce a number with no meaning.
567
+ legacy = True
568
+ signed = float(record.get("qty") or 0.0)
569
+ if str(record.get("side", "")).lower() in {"sell", "short"}:
570
+ signed = -signed
571
+ order_id = record.get("order_id")
572
+ if basis == CUMULATIVE and order_id:
573
+ by_order[str(order_id)] = {"symbol": symbol, "qty": signed, "seq": seq}
574
+ else:
575
+ # Incremental, or no order id to collapse on: each event stands
576
+ # alone, keyed uniquely so none overwrites another.
577
+ by_order[f"#{seq}"] = {"symbol": symbol, "qty": signed, "seq": seq}
578
+
579
+ net: Dict[str, float] = {symbol: qty for symbol, (_, qty) in reset.items()}
580
+ for entry in by_order.values():
581
+ baseline_seq = reset.get(entry["symbol"], (0, 0.0))[0]
582
+ if entry["seq"] < baseline_seq:
583
+ continue # superseded by a close or an adoption
584
+ net[entry["symbol"]] = net.get(entry["symbol"], 0.0) + entry["qty"]
585
+ return {s: q for s, q in net.items() if abs(q) > 1e-9}, legacy
586
+
587
+ # ------------------------------------------------------------------ #
588
+ # Execution quality
589
+ # ------------------------------------------------------------------ #
590
+ def lifecycles(self) -> List[Dict[str, Any]]:
591
+ """One row per submitted order: what was decided, sent, and filled.
592
+
593
+ The join the ledger exists to make possible — decision to intent by
594
+ ``decision_id``, intent to fill by ``order_id``. Derived here rather than
595
+ written at fill time so it is correct across a restart: a process that dies
596
+ between submitting and filling still has both halves on disk, and nothing in
597
+ the order path has to carry state to make the arithmetic work.
598
+ """
599
+ decisions: Dict[str, Dict[str, Any]] = {}
600
+ intents: Dict[str, Dict[str, Any]] = {}
601
+ fills: Dict[str, List[Dict[str, Any]]] = {}
602
+ for record in self._read():
603
+ event = record.get("event")
604
+ if event == "decision" and record.get("decision_id"):
605
+ decisions[record["decision_id"]] = record
606
+ elif event == "intent" and record.get("order_id"):
607
+ intents[record["order_id"]] = record
608
+ elif event == "fill" and record.get("order_id"):
609
+ fills.setdefault(record["order_id"], []).append(record)
610
+
611
+ rows = []
612
+ for order_id, intent in intents.items():
613
+ plan = intent.get("plan") or {}
614
+ decision = decisions.get(intent.get("decision_id")) or {}
615
+ order_fills = fills.get(order_id, [])
616
+ last = order_fills[-1] if order_fills else None
617
+ reference = plan.get("reference_price")
618
+ fill_price = last.get("fill_price") if last else None
619
+ rows.append(
620
+ {
621
+ "order_id": order_id,
622
+ "decision_id": intent.get("decision_id"),
623
+ "symbol": intent.get("symbol"),
624
+ "side": intent.get("side"),
625
+ "submitted_qty": intent.get("qty"),
626
+ "filled_qty": filled_quantity(order_fills),
627
+ "reference_price": reference,
628
+ "fill_price": fill_price,
629
+ "slippage_bps": slippage_bps(intent.get("side"), reference, fill_price),
630
+ "submitted_at": intent.get("ts"),
631
+ "decided_at": decision.get("ts") or intent.get("ts"),
632
+ "filled_at": last.get("filled_at") or last.get("ts") if last else None,
633
+ "decision_to_fill_ms": _elapsed_ms(
634
+ decision.get("ts") or intent.get("ts"),
635
+ (last.get("filled_at") or last.get("ts")) if last else None,
636
+ ),
637
+ "n_fill_events": len(order_fills),
638
+ "order_type": plan.get("order_type"),
639
+ "time_in_force": plan.get("time_in_force"),
640
+ "stop_loss": plan.get("stop_loss"),
641
+ "take_profit": plan.get("take_profit"),
642
+ "client_order_id": plan.get("client_order_id"),
643
+ "cost_estimate": plan.get("cost_estimate"),
644
+ "broker_fee": last.get("broker_fee") if last else None,
645
+ "reason": decision.get("reason"),
646
+ }
647
+ )
648
+ return rows
649
+
650
+ def declines(self) -> List[Dict[str, Any]]:
651
+ """Decisions that produced no order, with the reason each gave."""
652
+ return [
653
+ record
654
+ for record in self._read()
655
+ if record.get("event") == "decision" and not record.get("allowed")
656
+ ]
657
+
658
+ def version_summary(self) -> Dict[str, int]:
659
+ """How many records of each shape this ledger holds.
660
+
661
+ A file that mixes shapes is the normal case for anything append-only, and the
662
+ counts are what let an operator decide whether to archive it rather than guess.
663
+ """
664
+ counts: Dict[str, int] = {}
665
+ for record in self._read():
666
+ key = str(record.get("v", "pre-version"))
667
+ counts[key] = counts.get(key, 0) + 1
668
+ return counts
669
+
670
+ def _read(self) -> Iterable[Dict[str, Any]]:
671
+ if not self.path.exists():
672
+ return []
673
+ records = []
674
+ try:
675
+ for line in self.path.read_text().splitlines():
676
+ line = line.strip()
677
+ if not line:
678
+ continue
679
+ try:
680
+ records.append(json.loads(line))
681
+ except json.JSONDecodeError:
682
+ # A torn final line (killed mid-write) must not make the whole
683
+ # ledger unreadable — that would turn a small gap into total
684
+ # amnesia at exactly the wrong moment.
685
+ logger.warning("Skipping malformed ledger line in %s", self.path)
686
+ except OSError:
687
+ logger.warning("Could not read the position ledger at %s", self.path, exc_info=True)
688
+ return records
689
+
690
+ # ------------------------------------------------------------------ #
691
+ # Reconciliation
692
+ # ------------------------------------------------------------------ #
693
+ def reconcile(self, broker) -> ReconcileReport:
694
+ """Compare what we believe against what the broker holds.
695
+
696
+ One ``list_positions`` call, nothing per symbol — this runs inside the trade
697
+ clock and must not scale its API usage with the universe.
698
+ """
699
+ checked_at = datetime.now(timezone.utc).isoformat()
700
+ expected, legacy = self._replay()
701
+ if legacy:
702
+ logger.error(
703
+ "This ledger contains fill records written before fill accounting "
704
+ "distinguished cumulative from incremental quantities, and those "
705
+ "records also defaulted every side to buy. Divergences below may be "
706
+ "artefacts of that. Archive %s and start a fresh ledger.",
707
+ self.path,
708
+ )
709
+ try:
710
+ positions = broker.list_positions() or []
711
+ except Exception: # noqa: BLE001 - an unreachable broker is not a divergence
712
+ logger.warning("Reconciliation skipped: could not read broker positions", exc_info=True)
713
+ return ReconcileReport(checked_at=checked_at, n_expected=len(expected), n_actual=0)
714
+
715
+ actual = {p.symbol: (p.qty if p.is_long else -p.qty) for p in positions}
716
+ report = ReconcileReport(checked_at=checked_at, n_expected=len(expected), n_actual=len(actual))
717
+
718
+ for symbol in sorted(set(expected) | set(actual)):
719
+ want, have = expected.get(symbol, 0.0), actual.get(symbol, 0.0)
720
+ if abs(want - have) <= 1e-6:
721
+ continue
722
+ if symbol not in actual:
723
+ report.divergences.append(
724
+ Divergence(
725
+ symbol,
726
+ MISSING,
727
+ want,
728
+ 0.0,
729
+ f"ledger expects {want:+g} but the broker holds nothing — "
730
+ "a fill may have been missed, or the position closed elsewhere",
731
+ )
732
+ )
733
+ elif symbol not in expected:
734
+ report.divergences.append(
735
+ Divergence(
736
+ symbol,
737
+ UNEXPECTED,
738
+ 0.0,
739
+ have,
740
+ f"broker holds {have:+g} that this ledger never ordered — "
741
+ "opened manually, or by another process",
742
+ )
743
+ )
744
+ else:
745
+ report.divergences.append(
746
+ Divergence(
747
+ symbol,
748
+ QUANTITY_DRIFT,
749
+ want,
750
+ have,
751
+ f"ledger expects {want:+g}, broker holds {have:+g} — likely a partial fill",
752
+ )
753
+ )
754
+
755
+ self._append({"event": "reconcile", **report.as_dict()})
756
+ if not report.clean:
757
+ logger.error("%s", report.summary())
758
+ return report