pitbridge 0.1.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.
pitbridge/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """PitBridge daemon package.
2
+
3
+ Guardrailed bridge between AI agents (MCP / REST / WS) and a NinjaTrader 8
4
+ AddOn. Binding architecture: docs/architecture.md at the repo root.
5
+ """
6
+
7
+ __version__ = "0.1.0"
pitbridge/accounts.py ADDED
@@ -0,0 +1,253 @@
1
+ """Per-account state + day ledger.
2
+
3
+ Contract (architecture.md sections 3 and 4): the mutable bookkeeping behind
4
+ the pure guardrail engine. `AccountBook` ingests executions (realized P&L,
5
+ average-cost) and account_state updates (unrealized P&L straight from the
6
+ AddOn's AccountItem events — no market-data feed needed), applies the ET
7
+ day-ledger rollover, and produces the frozen `AccountSnapshot` the engine
8
+ checks against.
9
+
10
+ Rollover semantics are the Crucible live-loop semantics (live/run.py,
11
+ pinned by tests/test_live_run.py): on the first activity of a new ET date,
12
+ yesterday's realized folds into the balance, the best-day marker updates,
13
+ and — for the eval profile — the EOD trailing floor ratchets to
14
+ max(floor, balance - trailing_drawdown). The funded profile keeps a static
15
+ floor. Because the day P&L resets to zero, a daily-loss halt or profit lock
16
+ from yesterday automatically lifts (no sticky flag; the engine recomputes).
17
+
18
+ Import-pure and I/O-free: state lives in memory; the M2 link layer feeds it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from datetime import date, datetime, timedelta
25
+ from typing import Literal
26
+
27
+ from pitbridge.guardrails.engine import AccountSnapshot, OrderEvent, Side
28
+ from pitbridge.timeutil import et_date, require_aware
29
+
30
+ Profile = Literal["eval", "funded"]
31
+
32
+ _EVENT_RETENTION = timedelta(hours=24) # covers the largest rolling rate-limit window
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class _Position:
37
+ qty: int = 0 # signed net contracts
38
+ avg_px: float = 0.0
39
+
40
+
41
+ class AccountBook:
42
+ """Mutable per-account ledger. Not thread-safe; the daemon owns one per
43
+ account on its single event loop."""
44
+
45
+ def __init__(
46
+ self,
47
+ account: str,
48
+ *,
49
+ profile: Profile = "eval",
50
+ starting_balance: float = 0.0,
51
+ trailing_drawdown: float = 0.0,
52
+ point_value: float = 1.0,
53
+ day_halt_sticky: bool = False,
54
+ daily_loss_halt: float | None = None,
55
+ profit_lock: float | None = None,
56
+ ) -> None:
57
+ self.account = account
58
+ self.profile: Profile = profile
59
+ self.point_value = point_value
60
+ self.trailing_drawdown = trailing_drawdown
61
+ self.balance = starting_balance
62
+ self.trailing_floor = starting_balance - trailing_drawdown
63
+ self.max_day_profit = 0.0
64
+ self.day_date: date | None = None
65
+ self.day_realized = 0.0
66
+ self.unrealized = 0.0
67
+ self.link_up = True
68
+ self.first_order_done = False
69
+ self.max_order_qty = 0
70
+ self.last_loss_ts: datetime | None = None
71
+ self.last_order_ts: datetime | None = None
72
+ self._positions: dict[str, _Position] = {}
73
+ self._order_events: list[OrderEvent] = []
74
+ # exec_ids already booked into the ledger — the link dedups inbound
75
+ # frames by envelope id, but an execution redelivered with a fresh
76
+ # envelope id and the same broker exec_id (M4 reconnect/replay) would
77
+ # otherwise double-count. Ledger booking is idempotent by exec_id.
78
+ self._booked_exec_ids: set[str] = set()
79
+ # Sticky day-halt latch (M2 follow-up). The thresholds mirror the
80
+ # per-account guardrail config; the daemon wires them in. When sticky
81
+ # halting is on, the latch trips on observation (any account_state /
82
+ # execution) and holds until the ET rollover — recovery cannot re-open
83
+ # trading. With sticky off it stays dormant (M1 recompute-per-check).
84
+ self.day_halt_sticky = day_halt_sticky
85
+ self.daily_loss_halt = daily_loss_halt
86
+ self.profit_lock = profit_lock
87
+ self.day_halted = False
88
+ self.day_halt_reason: str | None = None
89
+ self._pending_halt_notice: str | None = None
90
+
91
+ # ------------------------------------------------------------- rollover
92
+
93
+ def roll_if_needed(self, now: datetime) -> bool:
94
+ """Apply the ET day-ledger rollover if `now` is on a new ET date.
95
+ Returns True when a rollover happened (Crucible EOD fold)."""
96
+ today = et_date(require_aware(now))
97
+ if self.day_date == today:
98
+ return False
99
+ rolled = self.day_date is not None
100
+ if rolled:
101
+ self.balance += self.day_realized
102
+ self.max_day_profit = max(self.max_day_profit, self.day_realized)
103
+ if self.profile == "eval":
104
+ # EOD trailing: floor ratchets with the end-of-day high-water
105
+ self.trailing_floor = max(
106
+ self.trailing_floor, self.balance - self.trailing_drawdown
107
+ )
108
+ self.day_date = today
109
+ self.day_realized = 0.0
110
+ cutoff = now - _EVENT_RETENTION
111
+ self._order_events = [e for e in self._order_events if e.ts > cutoff]
112
+ if rolled:
113
+ # A fresh ET day clears the sticky latch (and any pending flatten
114
+ # notice for it): yesterday's halt does not carry into today.
115
+ self.day_halted = False
116
+ self.day_halt_reason = None
117
+ self._pending_halt_notice = None
118
+ return rolled
119
+
120
+ # --------------------------------------------------------- sticky halt
121
+
122
+ def _evaluate_halt(self) -> None:
123
+ """Latch the sticky day-halt if a loss-halt or profit-lock threshold is
124
+ breached. No-op when sticky is off or the latch is already set."""
125
+ if not self.day_halt_sticky or self.day_halted:
126
+ return
127
+ pnl = self.day_pnl
128
+ if self.daily_loss_halt is not None and pnl <= -self.daily_loss_halt:
129
+ self._latch("DAILY_LOSS_HALT")
130
+ elif self.profit_lock is not None and pnl >= self.profit_lock:
131
+ self._latch("PROFIT_LOCK")
132
+
133
+ def _latch(self, reason: str) -> None:
134
+ self.day_halted = True
135
+ self.day_halt_reason = reason
136
+ self._pending_halt_notice = reason
137
+
138
+ def consume_halt_notice(self) -> str | None:
139
+ """Pop the reason of a *newly* latched sticky halt (for flatten_on_halt /
140
+ notifier), or None if nothing new latched since the last call."""
141
+ notice = self._pending_halt_notice
142
+ self._pending_halt_notice = None
143
+ return notice
144
+
145
+ # ------------------------------------------------------------- ingestion
146
+
147
+ def record_order(self, ts: datetime, instrument: str, side: Side, qty: int) -> None:
148
+ """Book a submitted order (feeds rate-limit / duplicate / cooldown)."""
149
+ require_aware(ts, "ts")
150
+ self.roll_if_needed(ts)
151
+ self._order_events.append(OrderEvent(ts=ts, instrument=instrument, side=side, qty=qty))
152
+ self.last_order_ts = ts
153
+ self.first_order_done = True
154
+ self.max_order_qty = max(self.max_order_qty, qty)
155
+
156
+ def record_execution(
157
+ self,
158
+ ts: datetime,
159
+ instrument: str,
160
+ side: Side,
161
+ qty: int,
162
+ price: float,
163
+ *,
164
+ commission: float = 0.0,
165
+ exec_id: str | None = None,
166
+ ) -> float:
167
+ """Book a fill. Average-cost method: a position-reducing fill books
168
+ (price - avg) * closed_qty * point_value (sign-adjusted) into the day
169
+ ledger; a flip re-opens the remainder at the fill price. Commission
170
+ is subtracted from realized (Crucible's approximation). Returns the
171
+ realized P&L delta. A realized loss on a closing fill stamps
172
+ `last_loss_ts` (cooldown guardrail); pure opens do not.
173
+
174
+ Idempotent by `exec_id` (FIX 3): a redelivered execution with an
175
+ already-booked exec_id is a no-op (returns 0.0), so a replayed fill
176
+ cannot double-count position or realized P&L. exec_id=None disables
177
+ dedup (direct test bookings)."""
178
+ require_aware(ts, "ts")
179
+ if qty <= 0:
180
+ raise ValueError(f"qty must be positive (got {qty})")
181
+ if exec_id is not None:
182
+ if exec_id in self._booked_exec_ids:
183
+ return 0.0
184
+ self._booked_exec_ids.add(exec_id)
185
+ self.roll_if_needed(ts)
186
+
187
+ pos = self._positions.setdefault(instrument, _Position())
188
+ signed = qty if side == "BUY" else -qty
189
+ realized = 0.0
190
+ closed_qty = 0
191
+
192
+ if pos.qty != 0 and (pos.qty > 0) != (signed > 0):
193
+ closed_qty = min(abs(signed), abs(pos.qty))
194
+ direction = 1.0 if pos.qty > 0 else -1.0
195
+ realized = (price - pos.avg_px) * closed_qty * self.point_value * direction
196
+
197
+ new_qty = pos.qty + signed
198
+ if pos.qty == 0 or (pos.qty > 0) == (signed > 0):
199
+ # opening / adding: volume-weighted average price
200
+ total = abs(pos.qty) + qty
201
+ pos.avg_px = (pos.avg_px * abs(pos.qty) + price * qty) / total
202
+ elif abs(signed) > abs(pos.qty):
203
+ pos.avg_px = price # flip: remainder opens at the fill price
204
+ elif new_qty == 0:
205
+ pos.avg_px = 0.0
206
+ pos.qty = new_qty
207
+
208
+ realized -= commission
209
+ self.day_realized += realized
210
+ if closed_qty > 0 and realized < 0:
211
+ self.last_loss_ts = ts
212
+ self._evaluate_halt()
213
+ return realized
214
+
215
+ def set_unrealized(self, value: float) -> None:
216
+ """Unrealized P&L as reported by the AddOn's account_state message."""
217
+ self.unrealized = value
218
+ self._evaluate_halt()
219
+
220
+ def set_link(self, up: bool) -> None:
221
+ self.link_up = up
222
+
223
+ # ------------------------------------------------------------- reads
224
+
225
+ def position(self, instrument: str) -> int:
226
+ pos = self._positions.get(instrument)
227
+ return pos.qty if pos is not None else 0
228
+
229
+ @property
230
+ def day_pnl(self) -> float:
231
+ return self.day_realized + self.unrealized
232
+
233
+ def snapshot(self, now: datetime, *, kill_engaged: bool = False) -> AccountSnapshot:
234
+ """The frozen per-account snapshot the guardrail engine checks
235
+ against. Rolls the ledger first, so a stale book can never leak
236
+ yesterday's P&L into today's decisions."""
237
+ self.roll_if_needed(now)
238
+ self._evaluate_halt()
239
+ return AccountSnapshot(
240
+ account=self.account,
241
+ kill_engaged=kill_engaged,
242
+ link_up=self.link_up,
243
+ day_realized=self.day_realized,
244
+ unrealized=self.unrealized,
245
+ positions={k: p.qty for k, p in self._positions.items() if p.qty != 0},
246
+ order_events=tuple(self._order_events),
247
+ last_loss_ts=self.last_loss_ts,
248
+ last_order_ts=self.last_order_ts,
249
+ first_order_done=self.first_order_done,
250
+ max_order_qty=self.max_order_qty,
251
+ day_halted=self.day_halted,
252
+ day_halt_reason=self.day_halt_reason,
253
+ )
pitbridge/adapter.py ADDED
@@ -0,0 +1,53 @@
1
+ """Platform-adapter seam (Tradovate-readiness — architect M2 follow-up).
2
+
3
+ The frozen pipeline and the executor depend on this `PlatformAdapter` Protocol,
4
+ not on the concrete `AddonLink`, so a second broker adapter (e.g. Tradovate's
5
+ WS user-sync) can slot in behind the same safety kernel without touching
6
+ pipeline/executor. The NT8 AddOn link (`addonlink.AddonLink`) implements the
7
+ Protocol structurally — no behaviour change, pure decoupling.
8
+
9
+ The broker order-state vocabulary (NinjaTrader `OrderState` strings) also lives
10
+ here rather than in the executor, so the executor stays vocabulary-neutral: it
11
+ applies whatever `state -> Status` mapper it is handed. A Tradovate adapter
12
+ ships its own map.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime
18
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
19
+
20
+ from pitbridge.protocol import SubmitOrderPayload
21
+
22
+ if TYPE_CHECKING:
23
+ from pitbridge.executor import Status
24
+
25
+
26
+ @runtime_checkable
27
+ class PlatformAdapter(Protocol):
28
+ """The broker-link surface the pipeline + executor depend on. Generic verbs
29
+ only (submit/cancel/close/flatten + link freshness); no NT-specific naming
30
+ leaks into the kernel. `AddonLink` satisfies it; a Tradovate adapter will."""
31
+
32
+ async def send_submit_order(self, payload: SubmitOrderPayload) -> None: ...
33
+ async def send_cancel_order(self, client_order_id: str, account: str) -> None: ...
34
+ async def send_close_position(self, account: str, instrument: str) -> None: ...
35
+ async def send_flatten_all(self, account: str) -> None: ...
36
+ def check_staleness(self, now: datetime | None = None) -> None: ...
37
+
38
+
39
+ # NinjaTrader broker OrderState -> executor Status. Moved out of executor.py so
40
+ # the executor holds no broker vocabulary; the NT8 adapter owns this table.
41
+ NT_STATE_TO_STATUS: dict[str, Status] = {
42
+ "Accepted": "ACKED",
43
+ "Working": "WORKING",
44
+ "PartFilled": "PARTFILLED",
45
+ "Filled": "FILLED",
46
+ "Rejected": "REJECTED",
47
+ "Cancelled": "CANCELLED",
48
+ }
49
+
50
+
51
+ def nt_state_to_status(state: str) -> Status:
52
+ """Map a NinjaTrader OrderState string to the executor's Status."""
53
+ return NT_STATE_TO_STATUS[state]
pitbridge/addonlink.py ADDED
@@ -0,0 +1,369 @@
1
+ """WS endpoint /v1/addon — the AddOn link.
2
+
3
+ Contract (architecture.md section 2.3): the AddOn connects OUT to
4
+ ws://<daemon>:8873/v1/addon (never a listener on the NT8 box). This module
5
+ owns the daemon side of that link, independent of the transport:
6
+
7
+ - hello + token auth (wrong/absent token -> close), hello_ack with a
8
+ session id, heartbeats both ways (5 s), link DOWN after 15 s of silence.
9
+ - inbound dispatch (snapshot / order_update / execution / position_update /
10
+ account_state / heartbeat / error), deduped by envelope id (bounded set).
11
+ - reconnect reconcile: on (re)connect the daemon sends request_snapshot and
12
+ reconciles in-flight client_order_ids against the broker's working orders
13
+ (broker = source of truth).
14
+ - reconcile-first HARD (M1 review follow-up): a link is not 'fresh' — and so
15
+ the link-down/staleness guard keeps blocking new orders — until the FIRST
16
+ account_state arrives for an account after (re)connect. A snapshot alone
17
+ does not make the link fresh.
18
+ - flatten_on_halt: when a sticky day-halt latches on an account_state, send
19
+ flatten_account (if the account opted in) and audit it.
20
+
21
+ Transport-agnostic: `AddonLink.serve(transport)` drives any object satisfying
22
+ the `Transport` protocol. The FastAPI /v1/addon route (rest.py) adapts a
23
+ Starlette WebSocket; tests drive an in-memory `memory_pair()`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import contextlib
30
+ from collections import deque
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass, field
33
+ from datetime import UTC, datetime
34
+ from typing import Protocol
35
+
36
+ from pitbridge.accounts import AccountBook
37
+ from pitbridge.audit import AuditLog
38
+ from pitbridge.config import AccountConfig
39
+ from pitbridge.executor import Executor
40
+ from pitbridge.notify import Notifier
41
+ from pitbridge.protocol import (
42
+ AccountStatePayload,
43
+ Envelope,
44
+ ExecutionPayload,
45
+ FlattenAllPayload,
46
+ HelloAckPayload,
47
+ HelloPayload,
48
+ OrderUpdatePayload,
49
+ ProtocolError,
50
+ RequestSnapshotPayload,
51
+ SnapshotPayload,
52
+ SubmitOrderPayload,
53
+ new_id,
54
+ )
55
+
56
+ HEARTBEAT_INTERVAL_S = 5
57
+ STALE_AFTER_S = 15.0
58
+ _SEEN_IDS_MAX = 4096
59
+
60
+
61
+ class TransportClosed(Exception):
62
+ """The underlying transport is closed; recv/send cannot continue."""
63
+
64
+
65
+ class Transport(Protocol):
66
+ async def send(self, text: str) -> None: ...
67
+ async def recv(self) -> str: ... # raises TransportClosed when closed
68
+ async def close(self) -> None: ...
69
+
70
+
71
+ class MemoryTransport:
72
+ """In-memory paired transport for tests + the fake AddOn. One side's send
73
+ is the other side's recv."""
74
+
75
+ def __init__(self, inbox: asyncio.Queue, outbox: asyncio.Queue) -> None:
76
+ self._inbox = inbox
77
+ self._outbox = outbox
78
+ self._closed = False
79
+
80
+ async def send(self, text: str) -> None:
81
+ if self._closed:
82
+ raise TransportClosed
83
+ await self._outbox.put(text)
84
+
85
+ async def recv(self) -> str:
86
+ item = await self._inbox.get()
87
+ if item is None: # close sentinel
88
+ raise TransportClosed
89
+ return item
90
+
91
+ async def close(self) -> None:
92
+ if not self._closed:
93
+ self._closed = True
94
+ await self._outbox.put(None)
95
+
96
+
97
+ def memory_pair() -> tuple[MemoryTransport, MemoryTransport]:
98
+ """(daemon_side, addon_side) connected transports."""
99
+ a: asyncio.Queue = asyncio.Queue()
100
+ b: asyncio.Queue = asyncio.Queue()
101
+ return MemoryTransport(inbox=a, outbox=b), MemoryTransport(inbox=b, outbox=a)
102
+
103
+
104
+ @dataclass
105
+ class AccountEntry:
106
+ alias: str
107
+ config: AccountConfig
108
+ book: AccountBook
109
+
110
+
111
+ @dataclass
112
+ class AddonLink:
113
+ """Daemon side of one AddOn WebSocket link. One instance per daemon; a new
114
+ connection replaces the previous transport (single AddOn in v0)."""
115
+
116
+ accounts: dict[str, AccountEntry] # keyed by nt_account
117
+ executor: Executor | None = None
118
+ audit: AuditLog | None = None
119
+ notifier: Notifier = field(default_factory=Notifier)
120
+ token: str | None = None
121
+ server_version: str = "0.1.0"
122
+ heartbeat_interval_s: int = HEARTBEAT_INTERVAL_S
123
+ stale_after_s: float = STALE_AFTER_S
124
+ clock: Callable[[], datetime] = field(default=lambda: datetime.now(UTC))
125
+
126
+ # runtime state
127
+ session_id: str | None = field(default=None, init=False)
128
+ connected: bool = field(default=False, init=False)
129
+ _transport: Transport | None = field(default=None, init=False)
130
+ _seq_out: int = field(default=0, init=False)
131
+ _last_rx: datetime | None = field(default=None, init=False)
132
+ _fresh_accounts: set[str] = field(default_factory=set, init=False)
133
+ _seen_ids: deque[str] = field(default_factory=lambda: deque(maxlen=_SEEN_IDS_MAX), init=False)
134
+ _seen_set: set[str] = field(default_factory=set, init=False)
135
+
136
+ # ------------------------------------------------------------- link status
137
+
138
+ def is_fresh(self, account_nt: str, now: datetime | None = None) -> bool:
139
+ """A link is fresh for an account only after its first post-connect
140
+ account_state AND within the staleness window (reconcile-first hard)."""
141
+ if not self.connected or account_nt not in self._fresh_accounts:
142
+ return False
143
+ if self._last_rx is None:
144
+ return False
145
+ now = now or self.clock()
146
+ return (now - self._last_rx).total_seconds() <= self.stale_after_s
147
+
148
+ def _refresh_links(self, now: datetime | None = None) -> None:
149
+ now = now or self.clock()
150
+ for nt, entry in self.accounts.items():
151
+ entry.book.set_link(self.is_fresh(nt, now))
152
+
153
+ def mark_all_links_down(self) -> None:
154
+ for entry in self.accounts.values():
155
+ entry.book.set_link(False)
156
+
157
+ def check_staleness(self, now: datetime | None = None) -> None:
158
+ """Re-evaluate link freshness against the clock (heartbeat sweeper)."""
159
+ self._refresh_links(now)
160
+
161
+ # ------------------------------------------------------------- outbound
162
+
163
+ def _next_seq(self) -> int:
164
+ seq = self._seq_out
165
+ self._seq_out += 1
166
+ return seq
167
+
168
+ async def _send(self, type_: str, payload) -> None:
169
+ transport = self._transport
170
+ if transport is None:
171
+ raise TransportClosed
172
+ env = Envelope.build(type_, payload, seq=self._next_seq())
173
+ await transport.send(env.to_json())
174
+
175
+ async def send_submit_order(self, payload: SubmitOrderPayload) -> None:
176
+ await self._send("submit_order", payload)
177
+
178
+ async def send_cancel_order(self, client_order_id: str, account: str) -> None:
179
+ await self._send("cancel_order",
180
+ {"client_order_id": client_order_id, "account": account})
181
+
182
+ async def send_close_position(self, account: str, instrument: str) -> None:
183
+ await self._send("close_position", {"account": account, "instrument": instrument})
184
+
185
+ async def send_flatten_all(self, account: str) -> None:
186
+ await self._send("flatten_all", FlattenAllPayload(account=account))
187
+
188
+ async def send_request_snapshot(self, account: str | None = None) -> None:
189
+ await self._send("request_snapshot", RequestSnapshotPayload(account=account))
190
+
191
+ async def send_heartbeat(self) -> None:
192
+ await self._send("heartbeat", {})
193
+
194
+ # ------------------------------------------------------------- lifecycle
195
+
196
+ async def serve(self, transport: Transport) -> None:
197
+ """Handle one connection start-to-finish. Returns when it closes."""
198
+ self._transport = transport
199
+ try:
200
+ hello = await self._await_hello(transport)
201
+ except TransportClosed:
202
+ return
203
+ if hello is None:
204
+ return # auth failed; transport already closed
205
+ await self._accept(hello)
206
+ hb_task = asyncio.create_task(self._heartbeat_loop())
207
+ try:
208
+ await self._receive_loop(transport)
209
+ finally:
210
+ hb_task.cancel()
211
+ with contextlib.suppress(asyncio.CancelledError):
212
+ await hb_task
213
+ self._on_disconnect()
214
+
215
+ async def _await_hello(self, transport: Transport) -> HelloPayload | None:
216
+ raw = await transport.recv()
217
+ try:
218
+ env = Envelope.from_json(raw)
219
+ except ProtocolError:
220
+ await self._reject(transport, "BAD_FRAME", "first frame is not a valid envelope")
221
+ return None
222
+ if env.type != "hello":
223
+ await self._reject(transport, "EXPECTED_HELLO", f"first frame was {env.type!r}")
224
+ return None
225
+ hello = env.decode()
226
+ if self.token is not None and hello.token != self.token:
227
+ await self._reject(transport, "BAD_TOKEN", "pairing token missing or wrong")
228
+ return None
229
+ return hello
230
+
231
+ async def _reject(self, transport: Transport, code: str, message: str) -> None:
232
+ with contextlib.suppress(Exception):
233
+ env = Envelope.build("error", {"code": code, "message": message}, seq=self._next_seq())
234
+ await transport.send(env.to_json())
235
+ await transport.close()
236
+
237
+ async def _accept(self, hello: HelloPayload) -> None:
238
+ self.session_id = new_id()
239
+ self.connected = True
240
+ self._last_rx = self.clock()
241
+ self._fresh_accounts.clear()
242
+ self.mark_all_links_down() # reconcile-first: not fresh until account_state
243
+ await self._send(
244
+ "hello_ack",
245
+ HelloAckPayload(
246
+ session_id=self.session_id,
247
+ heartbeat_interval_s=self.heartbeat_interval_s,
248
+ server_version=self.server_version,
249
+ ),
250
+ )
251
+ await self.send_request_snapshot(None) # reconcile before deciding
252
+
253
+ def _on_disconnect(self) -> None:
254
+ self.connected = False
255
+ self._transport = None
256
+ self._fresh_accounts.clear()
257
+ self.mark_all_links_down()
258
+
259
+ async def _heartbeat_loop(self) -> None:
260
+ while True:
261
+ await asyncio.sleep(self.heartbeat_interval_s)
262
+ self.check_staleness()
263
+ with contextlib.suppress(TransportClosed):
264
+ await self.send_heartbeat()
265
+
266
+ async def _receive_loop(self, transport: Transport) -> None:
267
+ while True:
268
+ try:
269
+ raw = await transport.recv()
270
+ except TransportClosed:
271
+ return
272
+ try:
273
+ env = Envelope.from_json(raw)
274
+ except ProtocolError:
275
+ continue # ignore malformed frame, keep the link
276
+ self._last_rx = self.clock()
277
+ if env.id in self._seen_set:
278
+ continue # duplicate delivery -> idempotent no-op
279
+ self._remember(env.id)
280
+ self._dispatch(env)
281
+
282
+ def _remember(self, msg_id: str) -> None:
283
+ if len(self._seen_ids) == self._seen_ids.maxlen:
284
+ oldest = self._seen_ids[0]
285
+ self._seen_set.discard(oldest)
286
+ self._seen_ids.append(msg_id)
287
+ self._seen_set.add(msg_id)
288
+
289
+ # ------------------------------------------------------------- dispatch
290
+
291
+ def _dispatch(self, env: Envelope) -> None:
292
+ handler = getattr(self, f"_on_{env.type}", None)
293
+ if handler is None:
294
+ return
295
+ handler(env.decode())
296
+
297
+ def _on_heartbeat(self, _payload) -> None:
298
+ self._refresh_links()
299
+
300
+ def _on_error(self, payload) -> None:
301
+ self.notifier.notify("addon_error", payload.message, code=payload.code)
302
+
303
+ def _on_snapshot(self, payload: SnapshotPayload) -> None:
304
+ entry = self.accounts.get(payload.account)
305
+ if entry is None:
306
+ return
307
+ # reconcile in-flight ids against the broker's working orders — SCOPED to this
308
+ # account so an account-A snapshot cannot CANCEL account-B's in-flight orders.
309
+ if self.executor is not None:
310
+ changes = self.executor.reconcile(
311
+ payload.working_orders, account=payload.account
312
+ )
313
+ if changes and self.audit is not None:
314
+ self.audit.append(
315
+ "reconcile",
316
+ {"account": payload.account, "changes": changes},
317
+ account=entry.alias,
318
+ )
319
+ # a snapshot does NOT mark the link fresh (reconcile-first hard)
320
+
321
+ def _on_account_state(self, payload: AccountStatePayload) -> None:
322
+ entry = self.accounts.get(payload.account)
323
+ if entry is None:
324
+ return
325
+ entry.book.set_unrealized(payload.unrealized_pnl)
326
+ self._fresh_accounts.add(payload.account) # link is now fresh for this account
327
+ self._refresh_links()
328
+ self._maybe_flatten_on_halt(entry)
329
+
330
+ def _on_position_update(self, _payload) -> None:
331
+ # positions are authoritative via snapshot/executions in v0; noted for M4.
332
+ return
333
+
334
+ def _on_order_update(self, payload: OrderUpdatePayload) -> None:
335
+ if self.executor is not None:
336
+ self.executor.on_order_update(payload)
337
+
338
+ def _on_execution(self, payload: ExecutionPayload) -> None:
339
+ entry = self.accounts.get(payload.account)
340
+ if entry is not None:
341
+ entry.book.record_execution(
342
+ self.clock(), payload.instrument, payload.action, payload.qty,
343
+ payload.price, commission=payload.commission, exec_id=payload.exec_id,
344
+ )
345
+ self._maybe_flatten_on_halt(entry)
346
+ if self.executor is not None:
347
+ self.executor.on_execution(payload)
348
+
349
+ def _maybe_flatten_on_halt(self, entry: AccountEntry) -> None:
350
+ reason = entry.book.consume_halt_notice()
351
+ if reason is None:
352
+ return
353
+ self.notifier.notify(
354
+ "day_halt", f"{entry.alias}: sticky day-halt latched ({reason})",
355
+ account=entry.alias, reason=reason,
356
+ )
357
+ if self.audit is not None:
358
+ self.audit.append("day_halt", {"reason": reason}, account=entry.alias)
359
+ if entry.config.flatten_on_halt:
360
+ asyncio.ensure_future(self._flatten_on_halt(entry)) # noqa: RUF006
361
+
362
+ async def _flatten_on_halt(self, entry: AccountEntry) -> None:
363
+ with contextlib.suppress(TransportClosed):
364
+ await self.send_flatten_all(entry.config.nt_account)
365
+ if self.audit is not None:
366
+ self.audit.append(
367
+ "flatten_on_halt", {"account": entry.config.nt_account},
368
+ account=entry.alias,
369
+ )