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 +7 -0
- pitbridge/accounts.py +253 -0
- pitbridge/adapter.py +53 -0
- pitbridge/addonlink.py +369 -0
- pitbridge/audit.py +255 -0
- pitbridge/cli.py +394 -0
- pitbridge/config.py +147 -0
- pitbridge/denylist.py +46 -0
- pitbridge/executor.py +309 -0
- pitbridge/guardrails/__init__.py +68 -0
- pitbridge/guardrails/calendar.py +109 -0
- pitbridge/guardrails/cooldown.py +38 -0
- pitbridge/guardrails/daily_loss_halt.py +40 -0
- pitbridge/guardrails/duplicate_protection.py +38 -0
- pitbridge/guardrails/engine.py +214 -0
- pitbridge/guardrails/human_confirm.py +44 -0
- pitbridge/guardrails/instrument_allowlist.py +24 -0
- pitbridge/guardrails/kill_switch.py +22 -0
- pitbridge/guardrails/link_down.py +21 -0
- pitbridge/guardrails/position_caps.py +38 -0
- pitbridge/guardrails/profit_lock.py +36 -0
- pitbridge/guardrails/rate_limit.py +57 -0
- pitbridge/guardrails/registry.py +72 -0
- pitbridge/guardrails/trading_window.py +65 -0
- pitbridge/mcp_server.py +211 -0
- pitbridge/modes.py +103 -0
- pitbridge/notify.py +66 -0
- pitbridge/pipeline.py +402 -0
- pitbridge/protocol.py +302 -0
- pitbridge/rest.py +270 -0
- pitbridge/runtime.py +135 -0
- pitbridge/timeutil.py +28 -0
- pitbridge-0.1.0.dist-info/METADATA +60 -0
- pitbridge-0.1.0.dist-info/RECORD +38 -0
- pitbridge-0.1.0.dist-info/WHEEL +4 -0
- pitbridge-0.1.0.dist-info/entry_points.txt +2 -0
- pitbridge-0.1.0.dist-info/licenses/LICENSE +202 -0
- pitbridge-0.1.0.dist-info/licenses/NOTICE +31 -0
pitbridge/__init__.py
ADDED
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
|
+
)
|