synpath 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.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,354 @@
1
+ """`python -m synpath.engine`: run the engine as a daemon, or ask it what it knows.
2
+
3
+ ```bash
4
+ python -m synpath.engine run --config engine.toml # trade
5
+ python -m synpath.engine status --journal trading.db # what the journal says
6
+ python -m synpath.engine halt --journal trading.db --reason "manual"
7
+ python -m synpath.engine resume --journal trading.db
8
+ python -m synpath.engine eod --config engine.toml # close the day and print the report
9
+ ```
10
+
11
+ Three things make this a daemon rather than a script:
12
+
13
+ **One writer.** Starting takes the journal's lease. A second process on the
14
+ same journal exits with a message naming the one that holds it, rather than
15
+ quietly trading the same account twice.
16
+
17
+ **Signals stop it properly.** `SIGINT` and `SIGTERM` apply the configured
18
+ halt policy first (by default, cancel everything resting through each
19
+ venue's own cancel-all), then release the lease and close the journal. Kill
20
+ it with `SIGKILL` and the journal still has every intent, which is what the
21
+ recovery path is for.
22
+
23
+ **Halting does not need this process.** `halt` writes the request into the
24
+ journal; the running engine picks it up within a second and applies it. An
25
+ operator who cannot reach the process can still stop it from the same
26
+ machine, and the request is recorded with who asked and when.
27
+
28
+ The configuration is TOML (or JSON), and names venues, the journal, and the
29
+ risk rules. Credentials never appear in it: they come from the environment
30
+ or a `.env`, the same way the adapters read them.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import asyncio
36
+ import json
37
+ import logging
38
+ import os
39
+ import signal
40
+ import sys
41
+ from decimal import Decimal
42
+ from typing import Any
43
+
44
+ from ..trading.credentials import load_credentials
45
+ from ..trading.errors import CredentialsMissing
46
+ from .engine import Engine, EngineConfig
47
+ from .feeds import Feeds, default_streams
48
+ from .eod import EndOfDay
49
+ from .journal import Journal, LeaseLost, now_ms
50
+ from .reconcile import Reconciler
51
+ from .risk import RiskConfig
52
+
53
+ log = logging.getLogger("synpath.engine")
54
+
55
+ HALT_REQUEST = "control:halt"
56
+ RESUME_REQUEST = "control:resume"
57
+
58
+ ADAPTERS = {
59
+ "kalshi": ("..trading.kalshi", "KalshiTrading"),
60
+ "polymarket": ("..trading.polymarket", "PolymarketTrading"),
61
+ "polymarket_us": ("..trading.polymarket_us", "PolymarketUSTrading"),
62
+ "polymarket_us_exchange": ("..trading.polymarket_us_exchange", "PolymarketUSExchangeTrading"),
63
+ }
64
+
65
+
66
+ def load_config(path: str) -> dict[str, Any]:
67
+ text = open(path, "rb").read()
68
+ if path.endswith(".json"):
69
+ return json.loads(text)
70
+ try:
71
+ import tomllib
72
+ except ImportError: # pragma: no cover - Python 3.10, where the same parser is the tomli package
73
+ import tomli as tomllib
74
+ return tomllib.loads(text.decode())
75
+
76
+
77
+ def build_adapters(config: dict[str, Any], *, dotenv: str | None = None) -> dict[str, Any]:
78
+ """One adapter per venue named in the configuration, from env credentials."""
79
+ import importlib
80
+
81
+ wanted = [name for name, section in (config.get("venues") or {}).items() if section.get("enabled", True)]
82
+ if not wanted:
83
+ raise SystemExit("the configuration names no venues: add [venues.kalshi] or another")
84
+ credentials = load_credentials(dotenv=dotenv)
85
+ adapters: dict[str, Any] = {}
86
+ for name in wanted:
87
+ if name == "paper":
88
+ from .paper import PaperVenue, quadratic_fee
89
+
90
+ section = config["venues"][name]
91
+ adapters[name] = PaperVenue(
92
+ venue=section.get("mirrors", "paper"), cash=Decimal(str(section.get("cash", "10000"))),
93
+ fees=quadratic_fee(Decimal(str(section.get("fee_rate", "0.07")))),
94
+ )
95
+ continue
96
+ if name not in ADAPTERS:
97
+ raise SystemExit(f"unknown venue {name!r}; known: {sorted(ADAPTERS) + ['paper']}")
98
+ creds = credentials.get(name)
99
+ if creds is None:
100
+ raise SystemExit(f"{name} is enabled but its credentials are not configured; run `python -m synpath.trading doctor`")
101
+ module_name, class_name = ADAPTERS[name]
102
+ module = importlib.import_module(module_name, package=__package__)
103
+ adapters[name] = getattr(module, class_name)(creds)
104
+ return adapters
105
+
106
+
107
+ def risk_from(config: dict[str, Any]) -> RiskConfig:
108
+ section = dict(config.get("risk") or {})
109
+ for key in ("price_collar", "max_order_contracts", "max_order_notional", "max_position_contracts",
110
+ "max_event_contracts", "max_venue_notional", "daily_loss_limit", "min_price", "max_price"):
111
+ if key in section and section[key] is not None:
112
+ section[key] = Decimal(str(section[key]))
113
+ if "exchange_limits" in section:
114
+ section["exchange_limits"] = {k: Decimal(str(v)) for k, v in section["exchange_limits"].items()}
115
+ return RiskConfig(**section)
116
+
117
+
118
+ async def run(args: argparse.Namespace) -> int:
119
+ config = load_config(args.config) if args.config else {}
120
+ logging.basicConfig(level=getattr(logging, (args.log_level or "INFO").upper(), logging.INFO),
121
+ format="%(asctime)s %(levelname)s %(message)s")
122
+ engine_config = EngineConfig(
123
+ journal_path=args.journal or config.get("journal", "synpath.db"),
124
+ poll_interval_s=float(config.get("poll_interval_s", 5)),
125
+ reconcile_interval_s=float(config.get("reconcile_interval_s", 60)),
126
+ sweep_interval_s=float(config.get("sweep_interval_s", 10)),
127
+ in_doubt_timeout_s=float(config.get("in_doubt_timeout_s", 20)),
128
+ halt_policy=config.get("halt_policy", "cancel"),
129
+ )
130
+ adapters = build_adapters(config, dotenv=args.dotenv)
131
+ engine = Engine(adapters, engine_config, risk=risk_from(config))
132
+ feeds = (Feeds(engine, default_streams(adapters, load_credentials(dotenv=args.dotenv)))
133
+ if config.get("streams", True) else None)
134
+ reconciler = Reconciler(engine, orphan_policy=config.get("orphan_policy", "report"))
135
+ eod = EndOfDay(engine, hour_utc=int(config.get("eod_hour_utc", 0)))
136
+
137
+ try:
138
+ recovery = await engine.start()
139
+ except LeaseLost as exc:
140
+ print(str(exc), file=sys.stderr)
141
+ return 3
142
+ print(f"engine {engine.journal.owner} started on {engine_config.journal_path}: "
143
+ f"{recovery.orders_open} open orders, {recovery.fills_replayed} fills replayed, "
144
+ f"{recovery.in_doubt} in doubt ({recovery.adopted} adopted, {recovery.swept} swept, "
145
+ f"{recovery.unresolved} unresolved)")
146
+
147
+ if feeds is not None:
148
+ await feeds.start()
149
+
150
+ stopping = asyncio.Event()
151
+
152
+ def _signal(name: str) -> None:
153
+ log.warning("synpath.engine: %s received, halting", name)
154
+ stopping.set()
155
+
156
+ loop = asyncio.get_running_loop()
157
+ for sig in (signal.SIGINT, signal.SIGTERM):
158
+ try:
159
+ loop.add_signal_handler(sig, _signal, sig.name)
160
+ except NotImplementedError: # pragma: no cover - Windows
161
+ pass
162
+
163
+ # The engine's own loops (lease, sweep, poll, managed) and the streams'
164
+ # pumps, from the lists they publish, so none can be left out.
165
+ loops = {**engine.background(), **(feeds.background() if feeds is not None else {})}
166
+ tasks = [loop.create_task(coro, name=name) for name, coro in loops.items()] + [
167
+ loop.create_task(_reconcile_loop(engine, reconciler, engine_config.reconcile_interval_s), name="reconcile"),
168
+ loop.create_task(_control_loop(engine, stopping), name="control"),
169
+ loop.create_task(eod.loop(), name="eod"),
170
+ loop.create_task(_status_loop(engine, float(config.get("status_interval_s", 60))), name="status"),
171
+ ]
172
+ waiter = loop.create_task(stopping.wait())
173
+ done, _ = await asyncio.wait([*tasks, waiter], return_when=asyncio.FIRST_COMPLETED)
174
+ for task in tasks:
175
+ task.cancel()
176
+ failure = next((t for t in done if t is not waiter and t.exception()), None)
177
+ if failure is not None:
178
+ log.error("synpath.engine: %s stopped the engine: %s", failure.get_name(), failure.exception())
179
+ if config.get("halt_on_exit", True):
180
+ await engine.halt("the engine is shutting down", policy=engine_config.halt_policy)
181
+ if feeds is not None:
182
+ await feeds.close()
183
+ await engine.stop()
184
+ for adapter in adapters.values():
185
+ try:
186
+ await adapter.close()
187
+ except Exception:
188
+ pass
189
+ print("engine stopped; the lease is released")
190
+ return 0 if failure is None else 4
191
+
192
+
193
+ async def _reconcile_loop(engine: Engine, reconciler: Reconciler, interval: float) -> None:
194
+ while engine.running:
195
+ await asyncio.sleep(interval)
196
+ try:
197
+ for report in await reconciler.run():
198
+ if not report.clean:
199
+ log.warning("synpath.engine: reconciliation on %s found %s", report.venue, report.summary())
200
+ except Exception:
201
+ log.exception("synpath.engine: reconciliation failed")
202
+
203
+
204
+ async def _control_loop(engine: Engine, stopping: asyncio.Event) -> None:
205
+ """Apply halt and resume requests written into the journal by the CLI."""
206
+ seen_halt = await engine.journal.cursor(HALT_REQUEST)
207
+ seen_resume = await engine.journal.cursor(RESUME_REQUEST)
208
+ while engine.running:
209
+ await asyncio.sleep(1.0)
210
+ halt = await engine.journal.cursor(HALT_REQUEST)
211
+ if halt and halt != seen_halt:
212
+ seen_halt = halt
213
+ request = json.loads(halt)
214
+ await engine.halt(request.get("reason", "requested"), scope=request.get("scope", "*"),
215
+ policy=request.get("policy"))
216
+ log.warning("synpath.engine: halted by request: %s", request.get("reason"))
217
+ if request.get("stop"):
218
+ stopping.set()
219
+ resume = await engine.journal.cursor(RESUME_REQUEST)
220
+ if resume and resume != seen_resume:
221
+ seen_resume = resume
222
+ await engine.resume(scope=json.loads(resume).get("scope"))
223
+ log.warning("synpath.engine: resumed by request")
224
+
225
+
226
+ async def _status_loop(engine: Engine, interval: float) -> None:
227
+ while engine.running:
228
+ await asyncio.sleep(interval)
229
+ marks = engine.fair_values.marks()
230
+ total = engine.ledger.total(marks)
231
+ log.info(
232
+ "synpath.engine: %s open orders, %s positions, realized %s, fees %s, events %s, halted=%s",
233
+ len(engine.open_orders()), len(engine.ledger.open_positions()), total.realized, total.fees,
234
+ engine.bus.published, engine.risk.kill.engaged,
235
+ )
236
+
237
+
238
+ async def status(args: argparse.Namespace) -> int:
239
+ """Read a journal without taking its lease."""
240
+ journal = Journal(args.journal)
241
+ await journal.open()
242
+ try:
243
+ async with journal._db.execute("SELECT name, owner, expires_ts FROM leases") as cursor:
244
+ leases = [dict(row) async for row in cursor]
245
+ open_orders = await journal.open_orders()
246
+ in_doubt = await journal.in_doubt()
247
+ fills = await journal.fills()
248
+ last = await journal.last_seq()
249
+ print(f"journal {args.journal}: {last} events")
250
+ for lease in leases:
251
+ alive = lease["expires_ts"] > now_ms()
252
+ print(f" lease {lease['name']}: {lease['owner']} ({'live' if alive else 'expired'})")
253
+ print(f" open orders: {len(open_orders)}")
254
+ for order in open_orders[:20]:
255
+ print(f" {order.venue} {order.id} {order.side.value} {order.remaining} {order.market_id} "
256
+ f"at {order.price} ({order.book or 'no book'})")
257
+ print(f" intents in doubt: {len(in_doubt)}")
258
+ for intent in in_doubt[:20]:
259
+ print(f" {intent.client_order_id} {intent.operation} {intent.venue} {intent.market_id} "
260
+ f"({round(intent.age_ms / 1000)}s)")
261
+ print(f" fills recorded: {len(fills)}")
262
+ config = await journal.config("risk")
263
+ if config:
264
+ print(f" risk configuration: version {config[0]}")
265
+ finally:
266
+ await journal.close()
267
+ return 0
268
+
269
+
270
+ async def control(args: argparse.Namespace, *, resume: bool = False) -> int:
271
+ journal = Journal(args.journal)
272
+ await journal.open()
273
+ try:
274
+ payload = {"ts": now_ms(), "by": os.environ.get("USER", "unknown")}
275
+ if resume:
276
+ payload["scope"] = args.scope
277
+ await journal.set_cursor(RESUME_REQUEST, json.dumps(payload))
278
+ print("resume requested; a running engine applies it within a second")
279
+ else:
280
+ payload |= {"reason": args.reason, "scope": args.scope, "policy": args.policy, "stop": args.stop}
281
+ await journal.set_cursor(HALT_REQUEST, json.dumps(payload))
282
+ print(f"halt requested ({args.policy}); a running engine applies it within a second")
283
+ finally:
284
+ await journal.close()
285
+ return 0
286
+
287
+
288
+ async def eod_command(args: argparse.Namespace) -> int:
289
+ config = load_config(args.config) if args.config else {}
290
+ adapters = build_adapters(config, dotenv=args.dotenv) if config.get("venues") else {}
291
+ engine = Engine(adapters, EngineConfig(journal_path=args.journal or config.get("journal", "synpath.db"),
292
+ require_lease=False))
293
+ await engine.journal.open()
294
+ await engine.recover()
295
+ report = await EndOfDay(engine).run(book_settlements=bool(adapters))
296
+ print(json.dumps(report.summary(), indent=2))
297
+ await engine.journal.close()
298
+ return 0
299
+
300
+
301
+ def main(argv: list[str] | None = None) -> int:
302
+ parser = argparse.ArgumentParser(prog="python -m synpath.engine", description=__doc__.splitlines()[0])
303
+ parser.add_argument("--journal", default=None, help="path to the journal database")
304
+ parser.add_argument("--dotenv", default=None, help="path to a .env file with venue credentials")
305
+ parser.add_argument("--log-level", default="INFO")
306
+ # The same flags after the subcommand, where a hand naturally types them.
307
+ # SUPPRESS so an unset one does not overwrite the value given before it.
308
+ common = argparse.ArgumentParser(add_help=False)
309
+ common.add_argument("--journal", default=argparse.SUPPRESS)
310
+ common.add_argument("--dotenv", default=argparse.SUPPRESS)
311
+ common.add_argument("--log-level", default=argparse.SUPPRESS)
312
+ sub = parser.add_subparsers(dest="command", required=True)
313
+
314
+ run_parser = sub.add_parser("run", help="run the engine until stopped", parents=[common])
315
+ run_parser.add_argument("--config", default=None, help="TOML or JSON configuration")
316
+
317
+ sub.add_parser("status", help="print what a journal knows, without taking its lease", parents=[common])
318
+
319
+ halt_parser = sub.add_parser("halt", help="ask a running engine to stop trading", parents=[common])
320
+ halt_parser.add_argument("--reason", default="requested by an operator")
321
+ halt_parser.add_argument("--scope", default="*", help="a venue id, or * for everything")
322
+ halt_parser.add_argument("--policy", default="cancel", choices=["cancel", "hold", "rearm"])
323
+ halt_parser.add_argument("--stop", action="store_true", help="also stop the process")
324
+
325
+ resume_parser = sub.add_parser("resume", help="lift a halt", parents=[common])
326
+ resume_parser.add_argument("--scope", default=None)
327
+
328
+ eod_parser = sub.add_parser("eod", help="close the day and print the report", parents=[common])
329
+ eod_parser.add_argument("--config", default=None)
330
+
331
+ args = parser.parse_args(argv)
332
+ if args.command in ("status", "halt", "resume") and not args.journal:
333
+ parser.error("--journal is required for this command")
334
+ try:
335
+ if args.command == "run":
336
+ return asyncio.run(run(args))
337
+ if args.command == "status":
338
+ return asyncio.run(status(args))
339
+ if args.command == "halt":
340
+ return asyncio.run(control(args))
341
+ if args.command == "resume":
342
+ return asyncio.run(control(args, resume=True))
343
+ if args.command == "eod":
344
+ return asyncio.run(eod_command(args))
345
+ except CredentialsMissing as exc:
346
+ print(f"credentials: {exc}", file=sys.stderr)
347
+ return 2
348
+ except KeyboardInterrupt:
349
+ return 130
350
+ return 1
351
+
352
+
353
+ if __name__ == "__main__":
354
+ raise SystemExit(main())
@@ -0,0 +1,170 @@
1
+ """Alerts: the few events a person needs to see now.
2
+
3
+ The event stream carries everything; almost none of it is worth waking
4
+ somebody for. This module is the filter, and it is deliberately small: a
5
+ handful of conditions, each with a severity, each stated as a sentence an
6
+ operator can act on without opening the journal.
7
+
8
+ What is worth an alert, and why:
9
+
10
+ * **the lease was taken** — another engine is trading this account, or this
11
+ one lost the right to; either way orders may be duplicated;
12
+ * **an intent stayed in doubt** — an order may exist at a venue that this
13
+ engine does not manage;
14
+ * **reconciliation found a difference** — an orphan, a ghost, or a position
15
+ the venue and the ledger disagree about;
16
+ * **the kill switch engaged**, and by whom;
17
+ * **the daily loss is approaching its limit** — at the warning fraction,
18
+ while there is still time to decide;
19
+ * **a stream went quiet or reconnected with reconciliation required**;
20
+ * **a venue rejected orders repeatedly** — the count in a window, not each
21
+ one, because a rejection storm is one problem, not fifty.
22
+
23
+ Delivery is a callback. A file, a log line, a webhook, a chat message: the
24
+ engine does not care, and none of them are built in, because an alerting
25
+ path that cannot be tested is worse than none.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ import time
32
+ from collections import deque
33
+ from dataclasses import dataclass, field
34
+ from decimal import Decimal
35
+ from typing import Any, Awaitable, Callable, Literal
36
+
37
+ from .events import EngineEvent, EventBus
38
+
39
+ log = logging.getLogger("synpath.engine.alerts")
40
+
41
+ Severity = Literal["info", "warning", "critical"]
42
+ Sink = Callable[["Alert"], Any | Awaitable[Any]]
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Alert:
47
+ severity: Severity
48
+ kind: str
49
+ message: str
50
+ detail: dict[str, Any] = field(default_factory=dict)
51
+ ts: int = field(default_factory=lambda: int(time.time() * 1000))
52
+
53
+ def __str__(self) -> str:
54
+ return f"[{self.severity}] {self.message}"
55
+
56
+
57
+ @dataclass
58
+ class AlertRules:
59
+ """When to speak up."""
60
+
61
+ reject_burst: int = 10
62
+ """Venue rejections within the window before one alert is raised."""
63
+ reject_window_s: float = 60.0
64
+ daily_loss_warning: Decimal = Decimal("0.8")
65
+ """Fraction of the daily loss limit that warrants a warning."""
66
+ repeat_after_s: float = 300.0
67
+ """The same alert is not repeated inside this window."""
68
+ quiet_kinds: tuple[str, ...] = ()
69
+ """Alert kinds to suppress entirely."""
70
+
71
+
72
+ class Alerts:
73
+ """Watches the event bus and calls the sinks when something matters."""
74
+
75
+ def __init__(self, bus: EventBus, *, rules: AlertRules | None = None, sinks: list[Sink] | None = None):
76
+ self.bus = bus
77
+ self.rules = rules or AlertRules()
78
+ self.sinks: list[Sink] = list(sinks or [])
79
+ self.raised: list[Alert] = []
80
+ self._last: dict[str, float] = {}
81
+ self._rejects: deque[float] = deque(maxlen=512)
82
+ bus.on(self._on_event)
83
+
84
+ def sink(self, sink: Sink) -> Sink:
85
+ self.sinks.append(sink)
86
+ return sink
87
+
88
+ # -- the rules ------------------------------------------------------------
89
+
90
+ def _on_event(self, event: EngineEvent) -> None:
91
+ rules = self.rules
92
+ kind, payload = event.kind, event.payload
93
+ if kind == "engine.lease_lost":
94
+ self.raise_alert("critical", "lease", "another engine took the journal lease; this one stopped trading", payload)
95
+ elif kind == "intent.unresolved":
96
+ self.raise_alert("critical", "in_doubt",
97
+ f"an order sent to {payload.get('venue')} could not be resolved: "
98
+ f"{payload.get('reason', 'unknown')}; it may be live and unmanaged", payload)
99
+ elif kind == "intent.swept":
100
+ self.raise_alert("info", "swept", f"an unanswered order was swept: {payload.get('client_order_id')}", payload)
101
+ elif kind == "engine.halted":
102
+ self.raise_alert("critical", "halt", f"trading halted ({payload.get('policy')}): {payload.get('reason')}", payload)
103
+ elif kind == "reconcile.orphan":
104
+ self.raise_alert("warning", "orphan",
105
+ f"{payload.get('venue')} has a resting order this engine did not place "
106
+ f"({payload.get('key')} on {payload.get('market_id')})", payload)
107
+ elif kind == "reconcile.position":
108
+ self.raise_alert("warning", "position",
109
+ f"{payload.get('venue')} and the ledger disagree on {payload.get('key')}: "
110
+ f"engine {payload.get('engine')}, venue {payload.get('venue_contracts')}", payload)
111
+ elif kind == "reconcile.balance":
112
+ self.raise_alert("warning", "balance",
113
+ f"{payload.get('venue')} cash moved {payload.get('moved')} with "
114
+ f"{payload.get('unexplained')} no fill explains", payload)
115
+ elif kind == "order.rejected":
116
+ now = time.time()
117
+ self._rejects.append(now)
118
+ recent = [t for t in self._rejects if t > now - rules.reject_window_s]
119
+ if len(recent) >= rules.reject_burst:
120
+ self.raise_alert("warning", "rejects",
121
+ f"{len(recent)} orders rejected in the last {int(rules.reject_window_s)}s", payload)
122
+ elif kind == "stream.gap" or (kind == "stream.status" and payload.get("state") == "gap"):
123
+ self.raise_alert("warning", "stream", f"a stream reported a gap: {payload.get('detail', '')}", payload)
124
+
125
+ def check_daily_loss(self, realized_today: Decimal, limit: Decimal | None) -> Alert | None:
126
+ """Called by the engine's day loop; warns before the limit stops trading."""
127
+ if limit is None or limit == 0:
128
+ return None
129
+ loss = -realized_today
130
+ if loss <= 0:
131
+ return None
132
+ fraction = loss / limit
133
+ if fraction >= 1:
134
+ return self.raise_alert("critical", "daily_loss", f"today's loss {loss} has reached the {limit} limit",
135
+ {"loss": str(loss), "limit": str(limit)})
136
+ if fraction >= self.rules.daily_loss_warning:
137
+ return self.raise_alert("warning", "daily_loss",
138
+ f"today's loss {loss} is {round(float(fraction) * 100)}% of the {limit} limit",
139
+ {"loss": str(loss), "limit": str(limit)})
140
+ return None
141
+
142
+ # -- raising --------------------------------------------------------------
143
+
144
+ def raise_alert(self, severity: Severity, kind: str, message: str, detail: dict[str, Any] | None = None) -> Alert | None:
145
+ if kind in self.rules.quiet_kinds:
146
+ return None
147
+ now = time.time()
148
+ last = self._last.get(f"{kind}:{severity}")
149
+ if last is not None and now - last < self.rules.repeat_after_s:
150
+ return None
151
+ self._last[f"{kind}:{severity}"] = now
152
+ alert = Alert(severity=severity, kind=kind, message=message, detail=detail or {})
153
+ self.raised.append(alert)
154
+ log.log(logging.CRITICAL if severity == "critical" else logging.WARNING if severity == "warning" else logging.INFO,
155
+ "synpath.engine: %s", alert)
156
+ for sink in list(self.sinks):
157
+ try:
158
+ result = sink(alert)
159
+ if asyncio.iscoroutine(result):
160
+ asyncio.get_running_loop().create_task(_guard(result))
161
+ except Exception:
162
+ log.exception("synpath.engine: an alert sink failed")
163
+ return alert
164
+
165
+
166
+ async def _guard(coro: Any) -> None:
167
+ try:
168
+ await coro
169
+ except Exception:
170
+ log.exception("synpath.engine: an async alert sink failed")