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,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
|