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
synpath/engine/paper.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Paper trading: a venue that fills orders the way a real book would.
|
|
2
|
+
|
|
3
|
+
A backtest that fills every order at the touch teaches a strategy to be
|
|
4
|
+
wrong. This one is deliberately pessimistic in the two places that matter:
|
|
5
|
+
|
|
6
|
+
**Taking costs the spread and walks the book.** A marketable order eats
|
|
7
|
+
levels in order and pays the average of what it ate, not the touch. If the
|
|
8
|
+
book is thinner than the order, the rest rests or is cancelled, exactly as
|
|
9
|
+
the venue's time-in-force says.
|
|
10
|
+
|
|
11
|
+
**Resting means queuing.** An order joining a price level is behind
|
|
12
|
+
everything already there. It fills only after the trades printed at that
|
|
13
|
+
price have consumed the size ahead of it, and if the level trades away
|
|
14
|
+
without reaching it, it does not fill. Size added at the same price later
|
|
15
|
+
sits behind, and size cancelled ahead moves it up only when the venue tells
|
|
16
|
+
us so, which is why the queue is an estimate and says so.
|
|
17
|
+
|
|
18
|
+
Fees come from the venue's own schedule, passed in as a callable, so a paper
|
|
19
|
+
run's profit and loss uses the same formula the live venue would charge.
|
|
20
|
+
|
|
21
|
+
It implements `TradingExchange`, so the engine, the risk rules and the
|
|
22
|
+
journal cannot tell it from a real adapter: the same code path runs in paper
|
|
23
|
+
and in production, which is the only way a paper run proves anything.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from decimal import Decimal
|
|
31
|
+
from typing import Any, Callable, Iterable, Sequence
|
|
32
|
+
|
|
33
|
+
from ..base import Capability
|
|
34
|
+
from ..trading.base import TradingExchange
|
|
35
|
+
from ..trading.errors import InvalidOrder, OrderNotFound
|
|
36
|
+
from ..trading.types import (
|
|
37
|
+
Account, Balance, EditRequest, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide,
|
|
38
|
+
Side, TimeInForce,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
ZERO = Decimal("0")
|
|
42
|
+
ONE = Decimal("1")
|
|
43
|
+
|
|
44
|
+
Level = tuple[Decimal, Decimal]
|
|
45
|
+
FeeModel = Callable[[str, Decimal, Decimal, Liquidity], Decimal]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def no_fees(market_id: str, price: Decimal, amount: Decimal, liquidity: Liquidity) -> Decimal:
|
|
49
|
+
return ZERO
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def quadratic_fee(rate: Decimal = Decimal("0.07"), *, exponent: Decimal = ONE, maker_rate: Decimal | None = ZERO) -> FeeModel:
|
|
53
|
+
"""The prediction-market fee shape: `rate * contracts * (p(1-p))^exponent`.
|
|
54
|
+
|
|
55
|
+
Kalshi charges it on takers at 0.07 and rounds up to the cent; Polymarket
|
|
56
|
+
uses the same shape with its own rate and exponent. `maker_rate` of zero
|
|
57
|
+
is both venues today.
|
|
58
|
+
"""
|
|
59
|
+
def fee(market_id: str, price: Decimal, amount: Decimal, liquidity: Liquidity) -> Decimal:
|
|
60
|
+
applied = rate if liquidity != Liquidity.MAKER else (maker_rate if maker_rate is not None else rate)
|
|
61
|
+
if not applied:
|
|
62
|
+
return ZERO
|
|
63
|
+
edge = price * (ONE - price)
|
|
64
|
+
if exponent != ONE:
|
|
65
|
+
edge = Decimal(str(float(edge) ** float(exponent)))
|
|
66
|
+
raw = applied * amount * edge
|
|
67
|
+
cents = (raw * 100).to_integral_value(rounding="ROUND_CEILING")
|
|
68
|
+
return cents / 100
|
|
69
|
+
return fee
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class PriceLevel:
|
|
74
|
+
"""One level, shaped like the streaming layer's, so code that reads a
|
|
75
|
+
live book reads a simulated one without noticing."""
|
|
76
|
+
|
|
77
|
+
price: Decimal
|
|
78
|
+
size: Decimal
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class BookState:
|
|
83
|
+
"""One instrument's book, as the simulator sees it."""
|
|
84
|
+
|
|
85
|
+
bids: list[Level] = field(default_factory=list)
|
|
86
|
+
asks: list[Level] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
def levels(self, depth: int | None = None) -> tuple[tuple[PriceLevel, ...], tuple[PriceLevel, ...]]:
|
|
89
|
+
"""Bids best first, asks best first, as `synpath.ws.LocalBook` gives them."""
|
|
90
|
+
bids = [PriceLevel(p, s) for p, s in self.bids[: depth or len(self.bids)]]
|
|
91
|
+
asks = [PriceLevel(p, s) for p, s in self.asks[: depth or len(self.asks)]]
|
|
92
|
+
return tuple(bids), tuple(asks)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def ready(self) -> bool:
|
|
96
|
+
return bool(self.bids or self.asks)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def best_bid(self) -> Decimal | None:
|
|
100
|
+
return self.bids[0][0] if self.bids else None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def best_ask(self) -> Decimal | None:
|
|
104
|
+
return self.asks[0][0] if self.asks else None
|
|
105
|
+
|
|
106
|
+
def size_at(self, side: Side, price: Decimal) -> Decimal:
|
|
107
|
+
levels = self.bids if side == Side.BUY else self.asks
|
|
108
|
+
for level_price, size in levels:
|
|
109
|
+
if level_price == price:
|
|
110
|
+
return size
|
|
111
|
+
return ZERO
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class Resting:
|
|
116
|
+
"""A resting paper order and where it stands in the queue."""
|
|
117
|
+
|
|
118
|
+
order: Order
|
|
119
|
+
queue_ahead: Decimal
|
|
120
|
+
"""Contracts that must trade at this price before this order fills."""
|
|
121
|
+
request: OrderRequest
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PaperVenue(TradingExchange):
|
|
125
|
+
"""A venue that exists only in this process.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
paper = PaperVenue(venue="kalshi", fees=quadratic_fee())
|
|
129
|
+
paper.set_book("KXX:yes", bids=[(D("0.41"), D("500"))], asks=[(D("0.43"), D("300"))])
|
|
130
|
+
order = await paper.create_order(request) # rests behind 500 contracts
|
|
131
|
+
paper.on_trade("KXX:yes", price=D("0.41"), amount=D("520")) # 20 of them are ours
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Feed it books and trades from `synpath.ws` and a strategy runs against
|
|
135
|
+
the real tape without sending anything.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
id = "paper"
|
|
139
|
+
name = "Paper trading"
|
|
140
|
+
has: dict[str, Capability] = {
|
|
141
|
+
"create_order": True, "create_orders": True, "cancel_order": True, "cancel_orders": True,
|
|
142
|
+
"cancel_all_orders": True, "edit_order": True, "fetch_order": True, "fetch_open_orders": True,
|
|
143
|
+
"fetch_orders": True, "fetch_my_trades": True, "fetch_positions": True, "fetch_balance": True,
|
|
144
|
+
"fetch_settlements": False, "fetch_queue_position": True, "fetch_fee_estimate": True,
|
|
145
|
+
"rfq": False, "split_merge": False,
|
|
146
|
+
"watch_orders": False, "watch_my_trades": False, "watch_positions": False, "watch_balance": False,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
venue: str = "paper",
|
|
153
|
+
cash: Decimal = Decimal("10000"),
|
|
154
|
+
fees: FeeModel = no_fees,
|
|
155
|
+
account: Account | None = None,
|
|
156
|
+
clock: Callable[[], float] = time.time,
|
|
157
|
+
face_value: Decimal = ONE,
|
|
158
|
+
):
|
|
159
|
+
self.venue = venue
|
|
160
|
+
self.account = account or Account(venue=venue, name="paper")
|
|
161
|
+
self.fees = fees
|
|
162
|
+
self.clock = clock
|
|
163
|
+
self.face_value = face_value
|
|
164
|
+
self.cash = cash
|
|
165
|
+
self.start_cash = cash
|
|
166
|
+
self.books: dict[str, BookState] = {}
|
|
167
|
+
self.resting: dict[str, Resting] = {}
|
|
168
|
+
self.orders: dict[str, Order] = {}
|
|
169
|
+
self.fills: list[Fill] = []
|
|
170
|
+
self.positions_held: dict[str, Decimal] = {}
|
|
171
|
+
self.locked = ZERO
|
|
172
|
+
self._n = 0
|
|
173
|
+
self.pending: list[Fill] = []
|
|
174
|
+
"""Fills made but not yet delivered. A real venue reports a fill on a
|
|
175
|
+
stream, after the call that caused it has returned, and code that
|
|
176
|
+
reacts to fills must work that way here too."""
|
|
177
|
+
self.listeners: list[Callable[[Fill], Any]] = []
|
|
178
|
+
self.order_listeners: list[Callable[[Order], Any]] = []
|
|
179
|
+
self.pending_orders: list[Order] = []
|
|
180
|
+
|
|
181
|
+
def subscribe(self, listener: Callable[[Fill], Any]) -> Callable[[Fill], Any]:
|
|
182
|
+
"""Receive fills as they are delivered, as a user stream would send them."""
|
|
183
|
+
self.listeners.append(listener)
|
|
184
|
+
return listener
|
|
185
|
+
|
|
186
|
+
def subscribe_orders(self, listener: Callable[[Order], Any]) -> Callable[[Order], Any]:
|
|
187
|
+
"""Receive order updates, as a user stream would send them."""
|
|
188
|
+
self.order_listeners.append(listener)
|
|
189
|
+
return listener
|
|
190
|
+
|
|
191
|
+
async def deliver(self) -> list[Fill]:
|
|
192
|
+
"""Hand over everything that has happened since the last call: fills
|
|
193
|
+
first, then the order updates they caused, which is the order a venue's
|
|
194
|
+
own stream reports them in."""
|
|
195
|
+
made, self.pending = self.pending, []
|
|
196
|
+
for fill in made:
|
|
197
|
+
for listener in list(self.listeners):
|
|
198
|
+
result = listener(fill)
|
|
199
|
+
if hasattr(result, "__await__"):
|
|
200
|
+
await result
|
|
201
|
+
updates, self.pending_orders = self.pending_orders, []
|
|
202
|
+
for order in updates:
|
|
203
|
+
latest = self.orders.get(order.id, order)
|
|
204
|
+
for listener in list(self.order_listeners):
|
|
205
|
+
result = listener(latest)
|
|
206
|
+
if hasattr(result, "__await__"):
|
|
207
|
+
await result
|
|
208
|
+
return made
|
|
209
|
+
|
|
210
|
+
# -- feeding it -----------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def set_book(self, market_id: str, *, bids: Sequence[Level] = (), asks: Sequence[Level] = ()) -> None:
|
|
213
|
+
self.books[market_id] = BookState(
|
|
214
|
+
bids=sorted(((Decimal(p), Decimal(s)) for p, s in bids), key=lambda l: -l[0]),
|
|
215
|
+
asks=sorted(((Decimal(p), Decimal(s)) for p, s in asks), key=lambda l: l[0]),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def from_local_book(self, market_id: str, book: Any, depth: int | None = None) -> None:
|
|
219
|
+
"""Take a `synpath.ws` `LocalBook` as it stands."""
|
|
220
|
+
bids, asks = book.levels(depth)
|
|
221
|
+
self.set_book(market_id, bids=[(l.price, l.size) for l in bids], asks=[(l.price, l.size) for l in asks])
|
|
222
|
+
|
|
223
|
+
def on_trade(self, market_id: str, *, price: Decimal, amount: Decimal, taker_side: Side | None = None) -> list[Fill]:
|
|
224
|
+
"""A print on the tape. Consumes queue ahead and fills what it reaches."""
|
|
225
|
+
made: list[Fill] = []
|
|
226
|
+
for order_id, rest in list(self.resting.items()):
|
|
227
|
+
order = rest.order
|
|
228
|
+
if order.market_id != market_id or order.price != price:
|
|
229
|
+
continue
|
|
230
|
+
if taker_side is not None and taker_side == order.side:
|
|
231
|
+
# A taker on our own side lifts the other side of the book.
|
|
232
|
+
continue
|
|
233
|
+
consumed = min(rest.queue_ahead, amount)
|
|
234
|
+
rest.queue_ahead -= consumed
|
|
235
|
+
left = amount - consumed
|
|
236
|
+
if left <= 0:
|
|
237
|
+
continue
|
|
238
|
+
fillable = min(left, order.remaining or ZERO)
|
|
239
|
+
if fillable > 0:
|
|
240
|
+
made.append(self._fill(rest, price=price, amount=fillable, liquidity=Liquidity.MAKER))
|
|
241
|
+
amount = left - fillable
|
|
242
|
+
if amount <= 0:
|
|
243
|
+
break
|
|
244
|
+
return made
|
|
245
|
+
|
|
246
|
+
# -- the venue interface --------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async def create_order(self, request: OrderRequest) -> Order:
|
|
249
|
+
if request.type not in (OrderType.LIMIT, OrderType.MARKET):
|
|
250
|
+
raise InvalidOrder(f"paper trading holds {request.type.value} orders in the engine, not the venue")
|
|
251
|
+
book = self.books.get(request.market_id, BookState())
|
|
252
|
+
self._n += 1
|
|
253
|
+
order_id = f"paper-{self._n}"
|
|
254
|
+
price = request.price
|
|
255
|
+
order = Order(
|
|
256
|
+
id=order_id, client_order_id=request.client_order_id, venue=self.venue, account=request.account or self.account,
|
|
257
|
+
market_id=request.market_id, side=request.side,
|
|
258
|
+
type=request.type, time_in_force=request.time_in_force, status=OrderStatus.OPEN, price=price,
|
|
259
|
+
amount=request.amount, filled=ZERO, remaining=request.amount, book=request.book, trader=request.trader,
|
|
260
|
+
created_at=int(self.clock() * 1000), tags=dict(request.tags), expires_at=request.expires_at,
|
|
261
|
+
post_only=request.post_only, reduce_only=request.reduce_only,
|
|
262
|
+
)
|
|
263
|
+
self.orders[order_id] = order
|
|
264
|
+
rest = Resting(order=order, queue_ahead=book.size_at(request.side, price) if price is not None else ZERO, request=request)
|
|
265
|
+
self.resting[order_id] = rest
|
|
266
|
+
|
|
267
|
+
if not request.post_only:
|
|
268
|
+
self._cross(rest, book)
|
|
269
|
+
order = self.orders[order_id]
|
|
270
|
+
if order.remaining and order.remaining > 0:
|
|
271
|
+
if request.time_in_force in (TimeInForce.IOC, TimeInForce.FOK):
|
|
272
|
+
if request.time_in_force == TimeInForce.FOK and order.filled > 0 and order.remaining > 0:
|
|
273
|
+
# Fill or kill: what filled should not have. Undo it.
|
|
274
|
+
self._unfill(order_id)
|
|
275
|
+
self._close(order_id, OrderStatus.CANCELED)
|
|
276
|
+
elif request.post_only and self._would_cross(order, book):
|
|
277
|
+
self._close(order_id, OrderStatus.CANCELED)
|
|
278
|
+
return self.orders[order_id]
|
|
279
|
+
|
|
280
|
+
async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
|
|
281
|
+
out: list[Order | Exception] = []
|
|
282
|
+
for request in requests:
|
|
283
|
+
try:
|
|
284
|
+
out.append(await self.create_order(request))
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
out.append(exc)
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
async def cancel_order(self, order_id: str, *, market_id: str | None = None, current: Order | None = None) -> Order:
|
|
290
|
+
if order_id not in self.orders:
|
|
291
|
+
raise OrderNotFound(f"paper: no order {order_id}")
|
|
292
|
+
if self.orders[order_id].is_terminal:
|
|
293
|
+
return self.orders[order_id]
|
|
294
|
+
return self._close(order_id, OrderStatus.CANCELED)
|
|
295
|
+
|
|
296
|
+
async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
|
|
297
|
+
out: list[Order | Exception] = []
|
|
298
|
+
for order_id in order_ids:
|
|
299
|
+
try:
|
|
300
|
+
out.append(await self.cancel_order(order_id))
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
out.append(exc)
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
|
|
306
|
+
count = 0
|
|
307
|
+
for order_id, rest in list(self.resting.items()):
|
|
308
|
+
if market_id and rest.order.market_id != market_id:
|
|
309
|
+
continue
|
|
310
|
+
self._close(order_id, OrderStatus.CANCELED)
|
|
311
|
+
count += 1
|
|
312
|
+
return count
|
|
313
|
+
|
|
314
|
+
async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
|
|
315
|
+
rest = self.resting.get(request.order_id)
|
|
316
|
+
if rest is None:
|
|
317
|
+
raise OrderNotFound(f"paper: no resting order {request.order_id}")
|
|
318
|
+
order = rest.order
|
|
319
|
+
new_price = request.price if request.price is not None else order.price
|
|
320
|
+
new_amount = request.amount if request.amount is not None else order.amount
|
|
321
|
+
shrinking = new_price == order.price and new_amount < order.amount
|
|
322
|
+
updated = order.model_copy(update={
|
|
323
|
+
"price": new_price, "amount": new_amount,
|
|
324
|
+
"remaining": max(ZERO, new_amount - order.filled),
|
|
325
|
+
"queue_priority_preserved": shrinking,
|
|
326
|
+
})
|
|
327
|
+
self.orders[order.id] = updated
|
|
328
|
+
rest.order = updated
|
|
329
|
+
if not shrinking:
|
|
330
|
+
# A price change goes to the back of the new level's queue.
|
|
331
|
+
book = self.books.get(order.market_id, BookState())
|
|
332
|
+
rest.queue_ahead = book.size_at(order.side, new_price) if new_price is not None else ZERO
|
|
333
|
+
return updated
|
|
334
|
+
|
|
335
|
+
async def fetch_order(self, order_id: str) -> Order:
|
|
336
|
+
if order_id not in self.orders:
|
|
337
|
+
raise OrderNotFound(f"paper: no order {order_id}")
|
|
338
|
+
return self.orders[order_id]
|
|
339
|
+
|
|
340
|
+
async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
|
|
341
|
+
return [r.order for r in self.resting.values() if not market_id or r.order.market_id == market_id]
|
|
342
|
+
|
|
343
|
+
async def fetch_orders(self, *, market_id: str | None = None, status: Any = None, since: int | None = None,
|
|
344
|
+
until: int | None = None, limit: int | None = None, cursor: str | None = None) -> list[Order]:
|
|
345
|
+
orders = list(self.orders.values())
|
|
346
|
+
if market_id:
|
|
347
|
+
orders = [o for o in orders if o.market_id == market_id]
|
|
348
|
+
if since:
|
|
349
|
+
orders = [o for o in orders if (o.created_at or 0) >= since]
|
|
350
|
+
return orders[: limit or len(orders)]
|
|
351
|
+
|
|
352
|
+
async def fetch_my_trades(self, *, market_id: str | None = None, since: int | None = None, until: int | None = None,
|
|
353
|
+
limit: int | None = None, cursor: str | None = None) -> list[Fill]:
|
|
354
|
+
fills = self.fills
|
|
355
|
+
if market_id:
|
|
356
|
+
fills = [f for f in fills if f.market_id == market_id]
|
|
357
|
+
if since:
|
|
358
|
+
fills = [f for f in fills if f.timestamp >= since]
|
|
359
|
+
return fills[: limit or len(fills)]
|
|
360
|
+
|
|
361
|
+
async def fetch_queue_position(self, order_id: str) -> Decimal:
|
|
362
|
+
rest = self.resting.get(order_id)
|
|
363
|
+
if rest is None:
|
|
364
|
+
raise OrderNotFound(f"paper: no resting order {order_id}")
|
|
365
|
+
return rest.queue_ahead
|
|
366
|
+
|
|
367
|
+
async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
|
|
368
|
+
out = []
|
|
369
|
+
for held_market, contracts in self.positions_held.items():
|
|
370
|
+
if contracts == 0:
|
|
371
|
+
continue
|
|
372
|
+
if market_id and held_market != market_id:
|
|
373
|
+
continue
|
|
374
|
+
out.append(Position(
|
|
375
|
+
venue=self.venue, account=self.account, market_id=held_market,
|
|
376
|
+
side=PositionSide.LONG if contracts > 0 else PositionSide.SHORT, contracts=abs(contracts),
|
|
377
|
+
timestamp=int(self.clock() * 1000),
|
|
378
|
+
))
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
async def fetch_balance(self, *, account: Account | None = None) -> Balance:
|
|
382
|
+
return Balance(
|
|
383
|
+
venue=self.venue, account=account or self.account, currency="USD", total=self.cash,
|
|
384
|
+
available=self.cash - self.locked, locked=self.locked, timestamp=int(self.clock() * 1000),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> Any:
|
|
388
|
+
from ..trading.types import FeeEstimate
|
|
389
|
+
|
|
390
|
+
return FeeEstimate(
|
|
391
|
+
venue=self.venue, market_id=market_id, side=side, price=price, amount=amount,
|
|
392
|
+
taker_fee=self.fees(market_id, price, amount, Liquidity.TAKER),
|
|
393
|
+
maker_fee=self.fees(market_id, price, amount, Liquidity.MAKER),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
async def close(self) -> None:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
# -- the simulation itself ------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def _would_cross(self, order: Order, book: BookState) -> bool:
|
|
402
|
+
if order.price is None:
|
|
403
|
+
return True
|
|
404
|
+
if order.side == Side.BUY:
|
|
405
|
+
return book.best_ask is not None and order.price >= book.best_ask
|
|
406
|
+
return book.best_bid is not None and order.price <= book.best_bid
|
|
407
|
+
|
|
408
|
+
def _cross(self, rest: Resting, book: BookState) -> None:
|
|
409
|
+
"""Take whatever of the book this order can reach, level by level."""
|
|
410
|
+
order = rest.order
|
|
411
|
+
levels = book.asks if order.side == Side.BUY else book.bids
|
|
412
|
+
limit = order.price
|
|
413
|
+
remaining = order.remaining or ZERO
|
|
414
|
+
taken: list[Level] = []
|
|
415
|
+
for price, size in list(levels):
|
|
416
|
+
if remaining <= 0:
|
|
417
|
+
break
|
|
418
|
+
if limit is not None:
|
|
419
|
+
if order.side == Side.BUY and price > limit:
|
|
420
|
+
break
|
|
421
|
+
if order.side == Side.SELL and price < limit:
|
|
422
|
+
break
|
|
423
|
+
amount = min(size, remaining)
|
|
424
|
+
taken.append((price, amount))
|
|
425
|
+
remaining -= amount
|
|
426
|
+
for price, amount in taken:
|
|
427
|
+
self._fill(rest, price=price, amount=amount, liquidity=Liquidity.TAKER)
|
|
428
|
+
self._consume(book, order.side, price, amount)
|
|
429
|
+
if taken:
|
|
430
|
+
# Whatever is left joins the queue at its own price, behind the rest.
|
|
431
|
+
rest.queue_ahead = book.size_at(order.side, order.price) if order.price is not None else ZERO
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _consume(book: BookState, side: Side, price: Decimal, amount: Decimal) -> None:
|
|
435
|
+
levels = book.asks if side == Side.BUY else book.bids
|
|
436
|
+
for index, (level_price, size) in enumerate(levels):
|
|
437
|
+
if level_price == price:
|
|
438
|
+
left = size - amount
|
|
439
|
+
if left > 0:
|
|
440
|
+
levels[index] = (level_price, left)
|
|
441
|
+
else:
|
|
442
|
+
levels.pop(index)
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
def _fill(self, rest: Resting, *, price: Decimal, amount: Decimal, liquidity: Liquidity) -> Fill:
|
|
446
|
+
order = rest.order
|
|
447
|
+
fee = self.fees(order.market_id, price, amount, liquidity)
|
|
448
|
+
stamp = int(self.clock() * 1000)
|
|
449
|
+
fill = Fill(
|
|
450
|
+
id=f"pf-{uuid.uuid4().hex[:12]}", order_id=order.id, client_order_id=order.client_order_id,
|
|
451
|
+
venue=self.venue, account=order.account or self.account,
|
|
452
|
+
market_id=order.market_id, side=order.side, price=price, amount=amount, fee=fee, fee_currency="USD",
|
|
453
|
+
liquidity=liquidity, timestamp=stamp,
|
|
454
|
+
)
|
|
455
|
+
self.fills.append(fill)
|
|
456
|
+
self.pending.append(fill)
|
|
457
|
+
filled = order.filled + amount
|
|
458
|
+
average = ((order.average_price or ZERO) * order.filled + price * amount) / filled if filled else ZERO
|
|
459
|
+
updated = order.model_copy(update={
|
|
460
|
+
"filled": filled, "remaining": order.amount - filled, "average_price": average,
|
|
461
|
+
"last_fill_price": price, "last_fill_amount": amount, "fee": (order.fee or ZERO) + fee,
|
|
462
|
+
"status": OrderStatus.CLOSED if order.amount - filled <= 0 else OrderStatus.OPEN,
|
|
463
|
+
"updated_at": stamp,
|
|
464
|
+
})
|
|
465
|
+
self.orders[order.id] = updated
|
|
466
|
+
rest.order = updated
|
|
467
|
+
self._changed(updated)
|
|
468
|
+
signed = amount if order.side == Side.BUY else -amount
|
|
469
|
+
self.positions_held[order.market_id] = self.positions_held.get(order.market_id, ZERO) + signed
|
|
470
|
+
self.cash -= (price * amount if order.side == Side.BUY else -price * amount) + fee
|
|
471
|
+
if updated.remaining is not None and updated.remaining <= 0:
|
|
472
|
+
self.resting.pop(order.id, None)
|
|
473
|
+
return fill
|
|
474
|
+
|
|
475
|
+
def _unfill(self, order_id: str) -> None:
|
|
476
|
+
"""Undo the fills of a fill-or-kill that could not complete."""
|
|
477
|
+
order = self.orders[order_id]
|
|
478
|
+
mine = [f for f in self.fills if f.order_id == order_id]
|
|
479
|
+
for fill in mine:
|
|
480
|
+
signed = fill.amount if fill.side == Side.BUY else -fill.amount
|
|
481
|
+
self.positions_held[fill.market_id] = self.positions_held.get(fill.market_id, ZERO) - signed
|
|
482
|
+
self.cash += (fill.price * fill.amount if fill.side == Side.BUY else -fill.price * fill.amount) + (fill.fee or ZERO)
|
|
483
|
+
self.fills = [f for f in self.fills if f.order_id != order_id]
|
|
484
|
+
self.pending = [f for f in self.pending if f.order_id != order_id]
|
|
485
|
+
self.orders[order_id] = order.model_copy(update={
|
|
486
|
+
"filled": ZERO, "remaining": order.amount, "average_price": None, "fee": ZERO,
|
|
487
|
+
})
|
|
488
|
+
if order_id in self.resting:
|
|
489
|
+
self.resting[order_id].order = self.orders[order_id]
|
|
490
|
+
|
|
491
|
+
def _close(self, order_id: str, status: OrderStatus) -> Order:
|
|
492
|
+
order = self.orders[order_id]
|
|
493
|
+
closed = order.model_copy(update={"status": status, "updated_at": int(self.clock() * 1000)})
|
|
494
|
+
self.orders[order_id] = closed
|
|
495
|
+
self.resting.pop(order_id, None)
|
|
496
|
+
self._changed(closed)
|
|
497
|
+
return closed
|
|
498
|
+
|
|
499
|
+
def _changed(self, order: Order) -> None:
|
|
500
|
+
"""Queue an order update for delivery, newest state per order."""
|
|
501
|
+
self.pending_orders = [o for o in self.pending_orders if o.id != order.id]
|
|
502
|
+
self.pending_orders.append(order)
|
|
503
|
+
|
|
504
|
+
# -- reporting ------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
def settle(self, market_id: str, *, yes_wins: bool) -> Decimal:
|
|
507
|
+
"""Resolve a market: pay the holders and flatten the position. The
|
|
508
|
+
position is signed on the YES leg: a long is paid the face value if
|
|
509
|
+
YES wins, a short (NO held) if it does not."""
|
|
510
|
+
contracts = self.positions_held.get(market_id, ZERO)
|
|
511
|
+
if contracts == 0:
|
|
512
|
+
return ZERO
|
|
513
|
+
if contracts > 0:
|
|
514
|
+
proceeds = (self.face_value if yes_wins else ZERO) * contracts
|
|
515
|
+
else:
|
|
516
|
+
proceeds = (ZERO if yes_wins else self.face_value) * -contracts
|
|
517
|
+
self.cash += proceeds
|
|
518
|
+
self.positions_held[market_id] = ZERO
|
|
519
|
+
return proceeds
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def equity(self) -> Decimal:
|
|
523
|
+
"""Cash plus what the positions would fetch at the touch."""
|
|
524
|
+
total = self.cash
|
|
525
|
+
for market_id, contracts in self.positions_held.items():
|
|
526
|
+
if contracts == 0:
|
|
527
|
+
continue
|
|
528
|
+
book = self.books.get(market_id, BookState())
|
|
529
|
+
price = book.best_bid if contracts > 0 else book.best_ask
|
|
530
|
+
if price is not None:
|
|
531
|
+
total += price * contracts
|
|
532
|
+
return total
|