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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|
synpath/engine/alerts.py
ADDED
|
@@ -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")
|