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,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
|