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,764 @@
1
+ """The trading surface over HTTP, and the event stream beside it.
2
+
3
+ The read app (`synpath.server.api`) has no accounts because reading a public
4
+ market needs none. This one places orders, so it needs to know who is asking
5
+ and what they may touch, and both are checked on every route.
6
+
7
+ What shapes it:
8
+
9
+ **Permissions are per subaccount, and nothing is implied.** A key with
10
+ `view` on `kalshi:desk-a` can read that account's orders and nothing else. A
11
+ key with `trade` may place and cancel there, but may not hand the permission
12
+ to anyone; `manage_members` does that, and by itself it cannot trade. Every
13
+ route names the permission it wants and the account it wants it on.
14
+
15
+ **Every handler is async, because the engine is.** The read app's handlers
16
+ are synchronous and Starlette runs them in threads; here the engine, the
17
+ journal and the venue adapters share one event loop, so blocking it would
18
+ stall order entry for everyone.
19
+
20
+ **Responses are the library's own types.** An order comes back as `Order`,
21
+ a position as `Position`. That is what makes the generated TypeScript client
22
+ typed rather than a wall of `object`, which is the reason this layer exists
23
+ at all.
24
+
25
+ **The event stream replays.** `/ws/events` starts by sending everything after
26
+ the cursor a client gives, then stays open for what happens next, so a
27
+ client that reconnects does not miss the fill that happened while it was
28
+ away. Events are the engine's own, filtered to the accounts the key may see.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import json
34
+ import logging
35
+ from decimal import Decimal
36
+ from typing import Annotated, Any, Iterable, Literal
37
+
38
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Path, Query, Request, WebSocket, WebSocketDisconnect
39
+ from pydantic import BaseModel, Field
40
+
41
+ from .. import ids
42
+ from ..bucket import (
43
+ Bucket, BucketMember, BucketOrderReport, BucketPosition, bucket_id_of, is_bucket_id,
44
+ )
45
+ from ..engine.engine import Engine
46
+ from ..errors import (
47
+ AuthenticationError, BadRequest, ExchangeError, NetworkError, RateLimitExceeded, RequestTimeout, SynpathError,
48
+ )
49
+ from ..engine.risk import RiskConfig
50
+ from ..trading.errors import (
51
+ InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, RateBudgetExceeded, RiskRejected,
52
+ )
53
+ from .errors import install_error_handlers
54
+ from ..trading.types import (
55
+ Account, Balance, EditRequest, Fill, Order, OrderRequest, Position, Settlement, TimeInForce,
56
+ )
57
+ from .models import ErrorBody, PageResponse
58
+ from .store import ALL_ACCOUNTS, ControlStore, Grant, Permission, Principal
59
+
60
+ log = logging.getLogger("synpath.server")
61
+
62
+ TRADING_STATUS: dict[type[Exception], int] = {
63
+ OrderNotFound: 404,
64
+ InsufficientFunds: 400,
65
+ OrderRejected: 400,
66
+ InvalidOrder: 400,
67
+ BadRequest: 400,
68
+ MarketHalted: 409,
69
+ RiskRejected: 409,
70
+ RateBudgetExceeded: 429,
71
+ RateLimitExceeded: 429,
72
+ RequestTimeout: 504,
73
+ NetworkError: 502,
74
+ AuthenticationError: 502,
75
+ ExchangeError: 502,
76
+ }
77
+ """Library exception to status code on the trading routes. A venue refusing
78
+ our own credentials is 502: the caller's token was fine, the server's venue
79
+ key was not."""
80
+
81
+
82
+ def _tag_venue(exc: Exception, venue: str | None) -> None:
83
+ """Remember which venue answered, for the error body: the route knows
84
+ where the order went even when the venue's own message does not say."""
85
+ if venue and getattr(exc, "synpath_venue", None) is None:
86
+ try:
87
+ exc.synpath_venue = venue # type: ignore[attr-defined]
88
+ except AttributeError:
89
+ pass
90
+
91
+
92
+ def trading_status_for(exc: Exception) -> int:
93
+ for kind in type(exc).__mro__:
94
+ if kind in TRADING_STATUS:
95
+ return TRADING_STATUS[kind]
96
+ return 500
97
+
98
+ RESPONSES: dict[int | str, dict[str, Any]] = {
99
+ 400: {"model": ErrorBody, "description": "The request was refused before anything was sent"},
100
+ 401: {"model": ErrorBody, "description": "No key, or a key that is not valid"},
101
+ 403: {"model": ErrorBody, "description": "The key may not do this on this account"},
102
+ 404: {"model": ErrorBody, "description": "No such order"},
103
+ 409: {"model": ErrorBody, "description": "Refused by a pre-trade risk rule"},
104
+ }
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Wire shapes the engine does not already have
109
+ # ---------------------------------------------------------------------------
110
+
111
+ class AccountView(BaseModel):
112
+ """One subaccount a key may act on, and what it may do there."""
113
+
114
+ account: str = Field(description="`venue:name`, or `*` for every account")
115
+ venue: str | None = None
116
+ permissions: list[str]
117
+
118
+
119
+ class WhoAmI(BaseModel):
120
+ user_id: str
121
+ name: str
122
+ key_id: str
123
+ accounts: list[AccountView]
124
+
125
+
126
+ class HaltRequest(BaseModel):
127
+ reason: str = Field(description="Recorded in the journal and in the halt event")
128
+ scope: str = Field(default="*", description="A venue id, or `*` for everything")
129
+ policy: Literal["cancel", "hold", "rearm"] = "cancel"
130
+ rearm_after_s: float | None = None
131
+
132
+
133
+ class HaltState(BaseModel):
134
+ """Whether trading is halted, after a resume."""
135
+
136
+ halted: bool = Field(description="True if a halt is still in force, such as one on another scope")
137
+ halt_reason: str
138
+
139
+
140
+ class HaltResult(BaseModel):
141
+ policy: str
142
+ scope: str
143
+ reason: str
144
+ canceled: dict[str, Any] = Field(default_factory=dict)
145
+ remaining: dict[str, Any] = Field(default_factory=dict)
146
+ managed: int = 0
147
+
148
+
149
+ class FairValueBody(BaseModel):
150
+ account: str
151
+ market_id: str
152
+ value: Decimal
153
+ source: str = "manual"
154
+
155
+
156
+ class FairValueView(BaseModel):
157
+ account: str
158
+ market_id: str
159
+ value: Decimal
160
+
161
+
162
+ class PnlRow(BaseModel):
163
+ key: str
164
+ contracts: Decimal
165
+ cost: Decimal
166
+ realized: Decimal
167
+ unrealized: Decimal | None = None
168
+ fees: Decimal
169
+ volume: Decimal
170
+ positions: int
171
+ marked: int
172
+
173
+
174
+ class PnlView(BaseModel):
175
+ level: str
176
+ rows: list[PnlRow]
177
+ total: PnlRow
178
+
179
+
180
+ class BucketBody(BaseModel):
181
+ """A bucket to create. The server assigns the id."""
182
+
183
+ book: str = Field(description="The strategy book the bucket belongs to, as on orders")
184
+ name: str = Field(description="A label for people; not unique")
185
+ members: list[BucketMember] = Field(description="Two or more Synpath market ids, each with `flip`")
186
+
187
+
188
+ class EditBody(BaseModel):
189
+ """A change to a resting order; the order is the one in the path. Fields
190
+ left out are left alone."""
191
+
192
+ price: Decimal | None = None
193
+ amount: Decimal | None = None
194
+ time_in_force: TimeInForce | None = None
195
+ expires_at: int | None = None
196
+ client_order_id: str | None = Field(default=None, description="Idempotency key for the edit itself")
197
+
198
+
199
+ class GrantBody(BaseModel):
200
+ user_id: str
201
+ account: str = Field(description="`venue:name`, or `*`")
202
+ permission: Permission
203
+
204
+
205
+ class GrantView(BaseModel):
206
+ id: str
207
+ user_id: str
208
+ account: str
209
+ permission: str
210
+ granted_ts: int
211
+
212
+
213
+ class KeyBody(BaseModel):
214
+ user_id: str
215
+ label: str | None = None
216
+
217
+
218
+ class IssuedKeyView(BaseModel):
219
+ id: str
220
+ user_id: str
221
+ prefix: str
222
+ secret: str = Field(description="Shown once. It is stored only as a hash.")
223
+ label: str | None = None
224
+
225
+
226
+ class AuditRow(BaseModel):
227
+ id: int
228
+ ts: int
229
+ actor: str | None = None
230
+ request: str | None = None
231
+ table_name: str
232
+ action: str
233
+ row_id: str | None = None
234
+ before: dict[str, Any] | None = None
235
+ after: dict[str, Any] | None = None
236
+
237
+
238
+ class EngineStatus(BaseModel):
239
+ owner: str
240
+ running: bool
241
+ halted: bool
242
+ halt_reason: str = ""
243
+ open_orders: int
244
+ managed_orders: int
245
+ positions: int
246
+ risk_version: int | None = None
247
+ journal_events: int
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Dependencies
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def get_engine(request: Request) -> Engine:
255
+ engine = getattr(request.app.state, "engine", None)
256
+ if engine is None:
257
+ raise HTTPException(status_code=503, detail="this server has no engine attached")
258
+ return engine
259
+
260
+
261
+ def get_store(request: Request) -> ControlStore:
262
+ store = getattr(request.app.state, "control", None)
263
+ if store is None:
264
+ raise HTTPException(status_code=503, detail="this server has no account store attached")
265
+ return store
266
+
267
+
268
+ async def principal_of(request: Request, store: ControlStore) -> Principal:
269
+ header = request.headers.get("authorization") or ""
270
+ secret = header[7:].strip() if header.lower().startswith("bearer ") else request.headers.get("x-api-key", "")
271
+ if not secret:
272
+ raise HTTPException(status_code=401, detail="no API key: send `Authorization: Bearer <key>`")
273
+ principal = await store.principal(secret)
274
+ if principal is None:
275
+ raise HTTPException(status_code=401, detail="this key is not valid, or has been revoked")
276
+ await store.acting_as(f"user:{principal.user_id}", f"{request.method} {request.url.path}")
277
+ return principal
278
+
279
+
280
+ async def caller(request: Request, store: Annotated[ControlStore, Depends(get_store)]) -> Principal:
281
+ return await principal_of(request, store)
282
+
283
+
284
+ Caller = Annotated[Principal, Depends(caller)]
285
+ EngineDep = Annotated[Engine, Depends(get_engine)]
286
+ StoreDep = Annotated[ControlStore, Depends(get_store)]
287
+
288
+
289
+ def require(principal: Principal, permission: Permission, account: str | None = None) -> None:
290
+ if not principal.may(permission, account):
291
+ where = f" on {account}" if account else ""
292
+ raise HTTPException(status_code=403, detail=f"this key may not {permission}{where}")
293
+
294
+
295
+ def order_venue(body: OrderRequest, engine: Engine) -> str | None:
296
+ """Where an order goes: the named account's venue, else the venue its
297
+ market id names (`kalshi:...`), else the only venue configured."""
298
+ if body.account is not None:
299
+ return body.account.venue
300
+ venue, _ = ids.split(body.market_id)
301
+ if venue is not None and venue in engine.adapters:
302
+ return venue
303
+ if len(engine.adapters) == 1:
304
+ return next(iter(engine.adapters))
305
+ return None
306
+
307
+
308
+ def account_key(request_account: Account | None, engine: Engine, venue: str | None = None) -> str:
309
+ if request_account is not None:
310
+ return request_account.key
311
+ if venue and venue in engine.accounts:
312
+ return engine.accounts[venue].key
313
+ return f"{venue or 'unknown'}:default"
314
+
315
+
316
+ def visible(principal: Principal, account: str) -> bool:
317
+ return principal.may("view", account) or principal.may("trade", account)
318
+
319
+
320
+ def bucket_accounts(bucket: Bucket, engine: Engine) -> list[str]:
321
+ return [account_key(None, engine, venue) for venue in sorted(bucket.venues())]
322
+
323
+
324
+ def sees_bucket(principal: Principal, bucket: Bucket, engine: Engine) -> bool:
325
+ """A bucket is visible to a caller who may see every account it trades on."""
326
+ return all(visible(principal, account) for account in bucket_accounts(bucket, engine))
327
+
328
+
329
+ def bucket_report(parent: Any) -> BucketOrderReport:
330
+ report = {k: v for k, v in parent.report().items() if k in BucketOrderReport.model_fields}
331
+ return BucketOrderReport.model_validate({**report, "stop_reason": report.get("stop_reason") or None,
332
+ "status": parent.as_order().status.value})
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # The routes
337
+ # ---------------------------------------------------------------------------
338
+
339
+ def create_trading_router() -> APIRouter:
340
+ """Every route that needs an engine and a key."""
341
+ router = APIRouter()
342
+
343
+ # -- identity -------------------------------------------------------------
344
+
345
+ @router.get("/me", tags=["accounts"], summary="Who this key is", responses=RESPONSES)
346
+ async def me(principal: Caller) -> WhoAmI:
347
+ by_account: dict[str, list[str]] = {}
348
+ for grant in principal.grants:
349
+ by_account.setdefault(grant.account, []).append(grant.permission)
350
+ return WhoAmI(
351
+ user_id=principal.user_id, name=principal.name, key_id=principal.key_id,
352
+ accounts=[AccountView(account=key, venue=None if key == ALL_ACCOUNTS else key.split(":")[0],
353
+ permissions=sorted(perms))
354
+ for key, perms in sorted(by_account.items())],
355
+ )
356
+
357
+ @router.get("/accounts", tags=["accounts"], summary="Accounts this engine trades", responses=RESPONSES)
358
+ async def accounts(principal: Caller, engine: EngineDep) -> list[AccountView]:
359
+ rows = []
360
+ for venue, account in engine.accounts.items():
361
+ if not visible(principal, account.key):
362
+ continue
363
+ rows.append(AccountView(account=account.key, venue=venue,
364
+ permissions=sorted(p for p in ("view", "trade", "manage_credentials",
365
+ "manage_members") if principal.may(p, account.key))))
366
+ return rows
367
+
368
+ @router.get("/status", tags=["accounts"], summary="What the engine is doing", responses=RESPONSES)
369
+ async def status(principal: Caller, engine: EngineDep) -> EngineStatus:
370
+ require(principal, "view")
371
+ return EngineStatus(
372
+ owner=engine.journal.owner, running=engine.running, halted=engine.risk.kill.engaged,
373
+ halt_reason=engine.risk.kill.reason, open_orders=len(engine.open_orders()),
374
+ managed_orders=len(engine.orders.live()), positions=len(engine.ledger.open_positions()),
375
+ risk_version=engine.risk.config_version, journal_events=await engine.journal.last_seq(),
376
+ )
377
+
378
+ # -- orders ---------------------------------------------------------------
379
+
380
+ @router.post("/orders", tags=["orders"], summary="Place an order", responses=RESPONSES, status_code=201)
381
+ async def create_order(principal: Caller, engine: EngineDep, body: OrderRequest) -> Order:
382
+ if is_bucket_id(body.market_id):
383
+ # A bucket order puts legs on every member venue: the caller must
384
+ # be allowed to trade on each of them.
385
+ row = await engine.journal.bucket(bucket_id_of(body.market_id))
386
+ if row is None:
387
+ raise HTTPException(status_code=404, detail=f"{body.market_id}: no such bucket")
388
+ for member_venue in sorted(Bucket.model_validate(row).venues()):
389
+ require(principal, "trade", account_key(None, engine, member_venue))
390
+ venue = None
391
+ else:
392
+ venue = order_venue(body, engine)
393
+ if venue is None:
394
+ raise HTTPException(status_code=400, detail="more than one venue is configured and the market id "
395
+ "names none of them: use a Synpath id (venue:native) "
396
+ "or name an account")
397
+ require(principal, "trade", account_key(body.account, engine, venue))
398
+ try:
399
+ return await engine.submit(body, venue=venue)
400
+ except SynpathError as exc:
401
+ _tag_venue(exc, venue)
402
+ raise
403
+
404
+ # -- buckets --------------------------------------------------------------
405
+
406
+ @router.post("/buckets", tags=["buckets"], summary="Create a bucket", responses=RESPONSES, status_code=201)
407
+ async def create_bucket(principal: Caller, engine: EngineDep, body: BucketBody) -> Bucket:
408
+ bucket = Bucket(book=body.book, name=body.name, members=body.members)
409
+ try:
410
+ bucket.check()
411
+ except BadRequest as exc:
412
+ raise HTTPException(status_code=400, detail=str(exc)) from None
413
+ missing = sorted(bucket.venues() - set(engine.adapters))
414
+ if missing:
415
+ raise HTTPException(status_code=400, detail=f"this server does not trade {', '.join(missing)}: "
416
+ "every member venue needs credentials here")
417
+ # Whoever defines a bucket must be able to trade every leg it will route to.
418
+ for member_venue in sorted(bucket.venues()):
419
+ require(principal, "trade", account_key(None, engine, member_venue))
420
+ return await engine.save_bucket(bucket)
421
+
422
+ async def _bucket(principal: Principal, engine: Engine, bucket_id: str) -> Bucket:
423
+ """The bucket a path names, as `<id>` or as its market id `bucket:<id>`."""
424
+ bucket_id = bucket_id_of(bucket_id) if is_bucket_id(bucket_id) else bucket_id
425
+ row = await engine.journal.bucket(bucket_id)
426
+ if row is None:
427
+ raise HTTPException(status_code=404, detail=f"no bucket {bucket_id}")
428
+ bucket = Bucket.model_validate(row)
429
+ if not sees_bucket(principal, bucket, engine):
430
+ raise HTTPException(status_code=403, detail="this key may not view every venue in that bucket")
431
+ return bucket
432
+
433
+ @router.get("/buckets", tags=["buckets"], summary="List buckets", responses=RESPONSES)
434
+ async def list_buckets(
435
+ principal: Caller,
436
+ engine: EngineDep,
437
+ book: Annotated[str | None, Query(description="Only this strategy book")] = None,
438
+ status: Annotated[Literal["active", "archived", "all"], Query(description="Which buckets")] = "active",
439
+ ) -> PageResponse[Bucket]:
440
+ rows = await engine.journal.buckets(book=book, status=None if status == "all" else status)
441
+ buckets = [b for b in (Bucket.model_validate(r) for r in rows) if sees_bucket(principal, b, engine)]
442
+ return PageResponse[Bucket](data=buckets, next_cursor=None, count=len(buckets))
443
+
444
+ @router.get("/buckets/{bucket_id}", tags=["buckets"], summary="Get a bucket", responses=RESPONSES)
445
+ async def get_bucket(principal: Caller, engine: EngineDep, bucket_id: Annotated[str, Path()]) -> Bucket:
446
+ return await _bucket(principal, engine, bucket_id)
447
+
448
+ @router.delete("/buckets/{bucket_id}", tags=["buckets"], summary="Archive a bucket", responses=RESPONSES)
449
+ async def archive_bucket(principal: Caller, engine: EngineDep, bucket_id: Annotated[str, Path()]) -> Bucket:
450
+ bucket = await _bucket(principal, engine, bucket_id)
451
+ for account in bucket_accounts(bucket, engine):
452
+ require(principal, "trade", account)
453
+ await engine.journal.archive_bucket(bucket.id)
454
+ return Bucket.model_validate(await engine.journal.bucket(bucket.id))
455
+
456
+ @router.get("/buckets/{bucket_id}/position", tags=["buckets"], summary="Position in a bucket",
457
+ responses=RESPONSES)
458
+ async def bucket_position(
459
+ principal: Caller,
460
+ engine: EngineDep,
461
+ bucket_id: Annotated[str, Path()],
462
+ book: Annotated[str | None, Query(description="Only this strategy book; default every book")] = None,
463
+ ) -> BucketPosition:
464
+ bucket = await _bucket(principal, engine, bucket_id)
465
+ return BucketPosition.model_validate(await engine.bucket_position(bucket.id, book=book))
466
+
467
+ @router.get("/buckets/{bucket_id}/orders", tags=["buckets"], summary="Orders on a bucket",
468
+ responses=RESPONSES)
469
+ async def bucket_orders(principal: Caller, engine: EngineDep,
470
+ bucket_id: Annotated[str, Path()]) -> PageResponse[BucketOrderReport]:
471
+ bucket = await _bucket(principal, engine, bucket_id)
472
+ reports = [bucket_report(p) for p in await engine.bucket_orders(bucket.id)]
473
+ return PageResponse[BucketOrderReport](data=reports, next_cursor=None, count=len(reports))
474
+
475
+ @router.get("/buckets/{bucket_id}/orders/{order_id}", tags=["buckets"], summary="One order on a bucket",
476
+ responses=RESPONSES)
477
+ async def bucket_order(principal: Caller, engine: EngineDep, bucket_id: Annotated[str, Path()],
478
+ order_id: Annotated[str, Path()]) -> BucketOrderReport:
479
+ bucket = await _bucket(principal, engine, bucket_id)
480
+ parent = await engine.bucket_order(order_id)
481
+ if parent is None or parent.market_id != bucket.market_id:
482
+ raise HTTPException(status_code=404, detail=f"no order {order_id} on bucket {bucket_id}")
483
+ return bucket_report(parent)
484
+
485
+ @router.get("/orders", tags=["orders"], summary="Open orders", responses=RESPONSES)
486
+ async def list_orders(
487
+ principal: Caller,
488
+ engine: EngineDep,
489
+ venue: Annotated[str | None, Query(description="Only this venue")] = None,
490
+ book: Annotated[str | None, Query(description="Only this strategy")] = None,
491
+ ) -> PageResponse[Order]:
492
+ orders = [o for o in engine.open_orders(venue=venue, book=book)
493
+ if visible(principal, (o.account.key if o.account else f"{o.venue}:default"))]
494
+ return PageResponse[Order](data=orders, next_cursor=None, count=len(orders))
495
+
496
+ @router.get("/orders/{order_id}", tags=["orders"], summary="One order", responses=RESPONSES)
497
+ async def get_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()]) -> Order:
498
+ parent = engine.orders.get(order_id)
499
+ order = parent.as_order() if parent is not None else None
500
+ if order is None:
501
+ for venue in engine.adapters:
502
+ order = await engine.journal.order(venue, order_id)
503
+ if order is not None:
504
+ break
505
+ if order is None:
506
+ raise HTTPException(status_code=404, detail=f"no order {order_id}")
507
+ account = order.account.key if order.account else f"{order.venue}:default"
508
+ if not visible(principal, account):
509
+ raise HTTPException(status_code=403, detail="this key may not view that account")
510
+ return order
511
+
512
+ @router.patch("/orders/{order_id}", tags=["orders"], summary="Amend an order", responses=RESPONSES)
513
+ async def edit_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()],
514
+ body: EditBody) -> Order:
515
+ order = await _known(engine, order_id)
516
+ account = order.account.key if order.account else f"{order.venue}:default"
517
+ require(principal, "trade", account)
518
+ try:
519
+ return await engine.edit(EditRequest(order_id=order_id, **body.model_dump(exclude_none=True)))
520
+ except SynpathError as exc:
521
+ _tag_venue(exc, order.venue)
522
+ raise
523
+ except RiskRejected as exc:
524
+ raise HTTPException(status_code=409, detail=f"{exc.rule}: {exc}") from None
525
+
526
+ @router.delete("/orders/{order_id}", tags=["orders"], summary="Cancel an order", responses=RESPONSES)
527
+ async def cancel_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()]) -> Order:
528
+ order = await _known(engine, order_id)
529
+ account = order.account.key if order.account else f"{order.venue}:default"
530
+ require(principal, "trade", account)
531
+ try:
532
+ return await engine.cancel(order_id)
533
+ except SynpathError as exc:
534
+ _tag_venue(exc, order.venue)
535
+ raise
536
+
537
+ async def _known(engine: Engine, order_id: str) -> Order:
538
+ parent = engine.orders.get(order_id)
539
+ if parent is not None:
540
+ return parent.as_order()
541
+ for venue in engine.adapters:
542
+ order = await engine.journal.order(venue, order_id)
543
+ if order is not None:
544
+ return order
545
+ raise HTTPException(status_code=404, detail=f"no order {order_id}")
546
+
547
+ # -- fills, positions, balances ------------------------------------------
548
+
549
+ @router.get("/fills", tags=["portfolio"], summary="Fills", responses=RESPONSES)
550
+ async def fills(
551
+ principal: Caller,
552
+ engine: EngineDep,
553
+ since: Annotated[int | None, Query(description="Milliseconds since the epoch")] = None,
554
+ venue: str | None = None,
555
+ book: str | None = None,
556
+ ) -> PageResponse[Fill]:
557
+ rows = [f for f in await engine.journal.fills(since_ts=since, venue=venue, book=book)
558
+ if visible(principal, (f.account.key if f.account else f"{f.venue}:default"))]
559
+ return PageResponse[Fill](data=rows, next_cursor=None, count=len(rows))
560
+
561
+ @router.get("/positions", tags=["portfolio"], summary="Positions as the ledger has them", responses=RESPONSES)
562
+ async def positions(principal: Caller, engine: EngineDep) -> PageResponse[Position]:
563
+ rows = [p for p in engine.positions()
564
+ if visible(principal, (p.account.key if p.account else f"{p.venue}:default"))]
565
+ return PageResponse[Position](data=rows, next_cursor=None, count=len(rows))
566
+
567
+ @router.get("/balances", tags=["portfolio"], summary="Balances, read from each venue", responses=RESPONSES)
568
+ async def balances(principal: Caller, engine: EngineDep) -> PageResponse[Balance]:
569
+ rows = []
570
+ for venue, adapter in engine.adapters.items():
571
+ account = engine.accounts.get(venue)
572
+ if account is not None and not visible(principal, account.key):
573
+ continue
574
+ if not adapter.has.get("fetch_balance"):
575
+ continue
576
+ rows.append(await adapter.fetch_balance(account=account))
577
+ return PageResponse[Balance](data=rows, next_cursor=None, count=len(rows))
578
+
579
+ @router.get("/pnl", tags=["portfolio"], summary="Profit and loss, rolled up", responses=RESPONSES)
580
+ async def pnl(
581
+ principal: Caller,
582
+ engine: EngineDep,
583
+ level: Annotated[Literal["market", "book", "account"], Query()] = "book",
584
+ ) -> PnlView:
585
+ require(principal, "view")
586
+ report = engine.pnl(level)
587
+ rows = [PnlRow(key=key, contracts=row.contracts, cost=row.cost, realized=row.realized,
588
+ unrealized=row.unrealized, fees=row.fees, volume=row.volume, positions=row.positions,
589
+ marked=row.marked)
590
+ for key, row in report["rows"].items()]
591
+ total = report["total"]
592
+ return PnlView(level=level, rows=rows,
593
+ total=PnlRow(key="*", contracts=total.contracts, cost=total.cost, realized=total.realized,
594
+ unrealized=total.unrealized, fees=total.fees, volume=total.volume,
595
+ positions=total.positions, marked=total.marked))
596
+
597
+ # -- fair values ----------------------------------------------------------
598
+
599
+ @router.get("/fair-values", tags=["portfolio"], summary="The marks positions are valued at", responses=RESPONSES)
600
+ async def list_fair_values(principal: Caller, engine: EngineDep) -> list[FairValueView]:
601
+ require(principal, "view")
602
+ return [FairValueView(account=account, market_id=market, value=value)
603
+ for (account, market), value in engine.fair_values.marks().items()
604
+ if visible(principal, account)]
605
+
606
+ @router.put("/fair-values", tags=["portfolio"], summary="Set a mark", responses=RESPONSES)
607
+ async def set_fair_value(principal: Caller, engine: EngineDep, body: FairValueBody) -> FairValueView:
608
+ require(principal, "trade", body.account)
609
+ mark = await engine.fair_values.set(body.account, body.market_id, body.value, source=body.source)
610
+ return FairValueView(account=body.account, market_id=body.market_id, value=mark.value)
611
+
612
+ # -- risk and the kill switch --------------------------------------------
613
+
614
+ @router.get("/risk", tags=["risk"], summary="The rules in force", responses=RESPONSES)
615
+ async def get_risk(principal: Caller, engine: EngineDep) -> RiskConfig:
616
+ require(principal, "view")
617
+ return engine.risk.config
618
+
619
+ @router.put("/risk", tags=["risk"], summary="Replace the rules", responses=RESPONSES)
620
+ async def put_risk(principal: Caller, engine: EngineDep, body: RiskConfig) -> RiskConfig:
621
+ require(principal, "manage_credentials")
622
+ await engine.set_risk(body, author=principal.user_id)
623
+ return engine.risk.config
624
+
625
+ @router.post("/halt", tags=["risk"], summary="Stop trading", responses=RESPONSES)
626
+ async def halt(principal: Caller, engine: EngineDep, body: HaltRequest) -> HaltResult:
627
+ require(principal, "trade", None if body.scope == "*" else None)
628
+ result = await engine.halt(body.reason, scope=body.scope, policy=body.policy,
629
+ rearm_after_s=body.rearm_after_s)
630
+ return HaltResult(**{**result, "canceled": {k: str(v) for k, v in result.get("canceled", {}).items()},
631
+ "remaining": {k: str(v) for k, v in result.get("remaining", {}).items()}})
632
+
633
+ @router.post("/resume", tags=["risk"], summary="Lift a halt", responses=RESPONSES)
634
+ async def resume(principal: Caller, engine: EngineDep,
635
+ scope: Annotated[str | None, Query()] = None) -> HaltState:
636
+ require(principal, "trade")
637
+ await engine.resume(scope=scope)
638
+ return HaltState(halted=engine.risk.kill.engaged, halt_reason=engine.risk.kill.reason)
639
+
640
+ # -- members, keys and the audit log --------------------------------------
641
+
642
+ @router.get("/grants", tags=["members"], summary="Grants for a user", responses=RESPONSES)
643
+ async def list_grants(principal: Caller, store: StoreDep,
644
+ user_id: Annotated[str | None, Query()] = None) -> list[GrantView]:
645
+ target = user_id or principal.user_id
646
+ if target != principal.user_id:
647
+ require(principal, "manage_members")
648
+ rows = await store.grants(target)
649
+ return [GrantView(id=g.id, user_id=g.user_id, account=g.account, permission=g.permission,
650
+ granted_ts=g.granted_ts) for g in rows]
651
+
652
+ @router.post("/grants", tags=["members"], summary="Give a permission", responses=RESPONSES, status_code=201)
653
+ async def add_grant(principal: Caller, store: StoreDep, body: GrantBody) -> GrantView:
654
+ require(principal, "manage_members", body.account)
655
+ grant = await store.grant(body.user_id, body.account, body.permission)
656
+ return GrantView(id=grant.id, user_id=grant.user_id, account=grant.account, permission=grant.permission,
657
+ granted_ts=grant.granted_ts)
658
+
659
+ @router.delete("/grants", tags=["members"], summary="Take a permission away", responses=RESPONSES)
660
+ async def remove_grant(principal: Caller, store: StoreDep, body: GrantBody) -> dict[str, bool]:
661
+ require(principal, "manage_members", body.account)
662
+ return {"revoked": await store.revoke(body.user_id, body.account, body.permission)}
663
+
664
+ @router.post("/keys", tags=["members"], summary="Issue an API key", responses=RESPONSES, status_code=201)
665
+ async def issue_key(principal: Caller, store: StoreDep, body: KeyBody) -> IssuedKeyView:
666
+ require(principal, "manage_credentials")
667
+ issued = await store.issue_key(body.user_id, label=body.label)
668
+ return IssuedKeyView(id=issued.id, user_id=issued.user_id, prefix=issued.prefix, secret=issued.secret,
669
+ label=issued.label)
670
+
671
+ @router.delete("/keys/{key_id}", tags=["members"], summary="Revoke a key", responses=RESPONSES)
672
+ async def revoke_key(principal: Caller, store: StoreDep, key_id: Annotated[str, Path()]) -> dict[str, bool]:
673
+ require(principal, "manage_credentials")
674
+ return {"revoked": await store.revoke_key(key_id)}
675
+
676
+ @router.get("/audit", tags=["members"], summary="The audit log, append-only", responses=RESPONSES)
677
+ async def audit(
678
+ principal: Caller,
679
+ store: StoreDep,
680
+ since_id: Annotated[int, Query(description="Rows after this id")] = 0,
681
+ limit: Annotated[int, Query(le=1000)] = 200,
682
+ table: Annotated[str | None, Query()] = None,
683
+ ) -> list[AuditRow]:
684
+ require(principal, "manage_members")
685
+ return [AuditRow(**row) for row in await store.audit(since_id=since_id, limit=limit, table=table)]
686
+
687
+ return router
688
+
689
+
690
+ # ---------------------------------------------------------------------------
691
+ # The event stream
692
+ # ---------------------------------------------------------------------------
693
+
694
+ def add_event_socket(app: FastAPI) -> None:
695
+ """`/ws/events`: replay from a cursor, then everything as it happens."""
696
+
697
+ @app.websocket("/ws/events")
698
+ async def events(socket: WebSocket, key: str | None = Query(default=None),
699
+ since: int = Query(default=0), kinds: str | None = Query(default=None)) -> None:
700
+ store: ControlStore | None = getattr(socket.app.state, "control", None)
701
+ engine: Engine | None = getattr(socket.app.state, "engine", None)
702
+ # Accept first, then close with the reason: a close before accepting
703
+ # becomes an HTTP 403 on the handshake, and a browser cannot read why.
704
+ await socket.accept()
705
+ if store is None or engine is None:
706
+ await socket.close(code=1011, reason="this server has no engine attached")
707
+ return
708
+ secret = key or (socket.headers.get("authorization") or "")[7:].strip()
709
+ principal = await store.principal(secret) if secret else None
710
+ if principal is None or not principal.may("view"):
711
+ await socket.close(code=4401, reason="an access token with view permission is required")
712
+ return
713
+ wanted = tuple(k for k in (kinds or "").split(",") if k) or None
714
+ subscription = engine.bus.subscribe(*(wanted or ()))
715
+ try:
716
+ async for event in engine.bus.replay(since):
717
+ await socket.send_text(json.dumps({
718
+ "seq": event.seq, "ts": event.ts, "kind": event.kind, "key": event.key, "payload": event.payload,
719
+ }, default=str))
720
+ while True:
721
+ event = await subscription.queue.get()
722
+ if wanted and not subscription.wants(event):
723
+ continue
724
+ await socket.send_text(json.dumps({
725
+ "seq": event.seq, "ts": event.ts, "kind": event.kind, "key": event.key,
726
+ "payload": event.payload, "dropped": subscription.dropped,
727
+ }, default=str))
728
+ except WebSocketDisconnect:
729
+ pass
730
+ except Exception: # pragma: no cover - a broken socket is not an engine problem
731
+ log.debug("synpath.server: the event socket ended", exc_info=True)
732
+ finally:
733
+ subscription.close()
734
+
735
+
736
+ def create_trading_app(
737
+ engine: Engine,
738
+ store: ControlStore,
739
+ *,
740
+ title: str = "synpath trading",
741
+ docs: bool = True,
742
+ ) -> FastAPI:
743
+ """An app with the trading routes and the event socket, and nothing else.
744
+
745
+ ```python
746
+ app = create_trading_app(engine, store)
747
+ app.mount("/read", create_app()) # the public read surface, if wanted
748
+ ```
749
+ """
750
+ app = FastAPI(
751
+ title=title,
752
+ version=__import__("synpath").__version__,
753
+ description=__doc__,
754
+ docs_url="/docs" if docs else None,
755
+ openapi_url="/openapi.json" if docs else None,
756
+ )
757
+ app.state.engine = engine
758
+ app.state.control = store
759
+ app.include_router(create_trading_router())
760
+ add_event_socket(app)
761
+
762
+ install_error_handlers(app, trading_status_for)
763
+
764
+ return app