poly-position-watcher 0.2.9__tar.gz → 0.3.0__tar.gz
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.
- {poly_position_watcher-0.2.9/poly_position_watcher.egg-info → poly_position_watcher-0.3.0}/PKG-INFO +18 -9
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/README.md +17 -8
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/_version.py +1 -1
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/position_service.py +161 -66
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/trade_calculator.py +84 -26
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0/poly_position_watcher.egg-info}/PKG-INFO +18 -9
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/SOURCES.txt +2 -1
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/pyproject.toml +1 -1
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/setup.py +1 -1
- poly_position_watcher-0.3.0/tests/test_trade_calculator.py +217 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/LICENSE +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/MANIFEST.in +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/__init__.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/api_worker.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/__init__.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/enums.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/logger.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/__init__.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/base.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/common_model.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/position_model.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/wss_worker.py +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/dependency_links.txt +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/requires.txt +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/top_level.txt +0 -0
- {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/setup.cfg +0 -0
{poly_position_watcher-0.2.9/poly_position_watcher.egg-info → poly_position_watcher-0.3.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: poly-position-watcher
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: polymarket proxy wallet redeem
|
|
5
5
|
Home-page: https://github.com/tosmart01/polymarket-position-watcher
|
|
6
6
|
Author: pinbar
|
|
@@ -31,7 +31,7 @@ Dynamic: requires-python
|
|
|
31
31
|
|
|
32
32
|
- WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
|
|
33
33
|
- HTTP polling fallback for reliability
|
|
34
|
-
- Optional fee calculation
|
|
34
|
+
- Optional fee calculation using market `feeSchedule`
|
|
35
35
|
- Position fields for fill checks:
|
|
36
36
|
`size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
|
|
37
37
|
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
|
|
@@ -65,8 +65,12 @@ with PositionWatcherService(
|
|
|
65
65
|
enable_http_fallback=True, # Enable HTTP polling fallback
|
|
66
66
|
add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
|
|
67
67
|
enable_fee_calc=True, # Optional: enable fee adjustments
|
|
68
|
-
# fee_calc_fn=custom_fee_fn, # Optional: override fee formula
|
|
69
68
|
) as service:
|
|
69
|
+
service.set_market_fee_schedule(
|
|
70
|
+
"<condition_id>",
|
|
71
|
+
{"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
|
|
72
|
+
)
|
|
73
|
+
|
|
70
74
|
# Non-blocking: Get current positions and orders (returns immediately)
|
|
71
75
|
position: UserPosition = service.get_position("<token_id>")
|
|
72
76
|
order: OrderMessage = service.get_order("<order_id>")
|
|
@@ -147,16 +151,20 @@ service.show_orders(limit=10)
|
|
|
147
151
|
## **⚠️ Fee notice (taker fee / maker rebate)**
|
|
148
152
|
---
|
|
149
153
|
|
|
150
|
-
Some Polymarket markets enable taker fee / maker rebate. This library
|
|
154
|
+
Some Polymarket markets enable taker fee / maker rebate. This library supports fee calculation from market `feeSchedule` data:
|
|
151
155
|
|
|
152
|
-
- Enable with `enable_fee_calc=True`
|
|
153
|
-
-
|
|
156
|
+
- Enable with `enable_fee_calc=True`
|
|
157
|
+
- Register `condition_id -> feeSchedule` through `service.set_market_fee_schedule(...)` or `service.set_market_fee_schedules(...)`
|
|
158
|
+
- Optionally override the fee handler with `fee_calc_fn`
|
|
154
159
|
- Disable (default) if you prefer pre-fee positions
|
|
155
160
|
- Returned position fields:
|
|
156
161
|
`size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
|
|
157
162
|
|
|
158
163
|
Default fee formula (when `fee_calc_fn` is not provided):
|
|
159
|
-
`fee =
|
|
164
|
+
`fee = size * price * rate * (price * (1 - price)) ** exponent`.
|
|
165
|
+
|
|
166
|
+
On taker buys, the fee is deducted in shares, so `size` is reduced by `fee / price`.
|
|
167
|
+
On taker sells, the fee is charged in USDC, so position size is unchanged and only `fee_amount` increases.
|
|
160
168
|
|
|
161
169
|
---
|
|
162
170
|
|
|
@@ -182,8 +190,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
|
|
|
182
190
|
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
|
|
183
191
|
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
|
|
184
192
|
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
|
|
185
|
-
| `enable_fee_calc` | bool | False | Apply fee adjustments using `
|
|
186
|
-
| `
|
|
193
|
+
| `enable_fee_calc` | bool | False | Apply fee adjustments using registered market `feeSchedule` data |
|
|
194
|
+
| `market_fee_schedules` | mapping | None | Optional initial `condition_id -> feeSchedule` mapping |
|
|
195
|
+
| `fee_calc_fn` | callable | None | Custom fee function: `(size, price, side, fee_schedule) -> (new_size, fee_amount)` |
|
|
187
196
|
|
|
188
197
|
### Environment Variables
|
|
189
198
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
- WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
|
|
14
14
|
- HTTP polling fallback for reliability
|
|
15
|
-
- Optional fee calculation
|
|
15
|
+
- Optional fee calculation using market `feeSchedule`
|
|
16
16
|
- Position fields for fill checks:
|
|
17
17
|
`size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
|
|
18
18
|
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
|
|
@@ -46,8 +46,12 @@ with PositionWatcherService(
|
|
|
46
46
|
enable_http_fallback=True, # Enable HTTP polling fallback
|
|
47
47
|
add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
|
|
48
48
|
enable_fee_calc=True, # Optional: enable fee adjustments
|
|
49
|
-
# fee_calc_fn=custom_fee_fn, # Optional: override fee formula
|
|
50
49
|
) as service:
|
|
50
|
+
service.set_market_fee_schedule(
|
|
51
|
+
"<condition_id>",
|
|
52
|
+
{"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
|
|
53
|
+
)
|
|
54
|
+
|
|
51
55
|
# Non-blocking: Get current positions and orders (returns immediately)
|
|
52
56
|
position: UserPosition = service.get_position("<token_id>")
|
|
53
57
|
order: OrderMessage = service.get_order("<order_id>")
|
|
@@ -128,16 +132,20 @@ service.show_orders(limit=10)
|
|
|
128
132
|
## **⚠️ Fee notice (taker fee / maker rebate)**
|
|
129
133
|
---
|
|
130
134
|
|
|
131
|
-
Some Polymarket markets enable taker fee / maker rebate. This library
|
|
135
|
+
Some Polymarket markets enable taker fee / maker rebate. This library supports fee calculation from market `feeSchedule` data:
|
|
132
136
|
|
|
133
|
-
- Enable with `enable_fee_calc=True`
|
|
134
|
-
-
|
|
137
|
+
- Enable with `enable_fee_calc=True`
|
|
138
|
+
- Register `condition_id -> feeSchedule` through `service.set_market_fee_schedule(...)` or `service.set_market_fee_schedules(...)`
|
|
139
|
+
- Optionally override the fee handler with `fee_calc_fn`
|
|
135
140
|
- Disable (default) if you prefer pre-fee positions
|
|
136
141
|
- Returned position fields:
|
|
137
142
|
`size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
|
|
138
143
|
|
|
139
144
|
Default fee formula (when `fee_calc_fn` is not provided):
|
|
140
|
-
`fee =
|
|
145
|
+
`fee = size * price * rate * (price * (1 - price)) ** exponent`.
|
|
146
|
+
|
|
147
|
+
On taker buys, the fee is deducted in shares, so `size` is reduced by `fee / price`.
|
|
148
|
+
On taker sells, the fee is charged in USDC, so position size is unchanged and only `fee_amount` increases.
|
|
141
149
|
|
|
142
150
|
---
|
|
143
151
|
|
|
@@ -163,8 +171,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
|
|
|
163
171
|
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
|
|
164
172
|
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
|
|
165
173
|
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
|
|
166
|
-
| `enable_fee_calc` | bool | False | Apply fee adjustments using `
|
|
167
|
-
| `
|
|
174
|
+
| `enable_fee_calc` | bool | False | Apply fee adjustments using registered market `feeSchedule` data |
|
|
175
|
+
| `market_fee_schedules` | mapping | None | Optional initial `condition_id -> feeSchedule` mapping |
|
|
176
|
+
| `fee_calc_fn` | callable | None | Custom fee function: `(size, price, side, fee_schedule) -> (new_size, fee_amount)` |
|
|
168
177
|
|
|
169
178
|
### Environment Variables
|
|
170
179
|
|
|
@@ -10,7 +10,7 @@ import threading
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from queue import Queue, Empty
|
|
12
12
|
from collections import defaultdict
|
|
13
|
-
from typing import Callable, Dict, List
|
|
13
|
+
from typing import Any, Callable, Dict, List, Mapping
|
|
14
14
|
|
|
15
15
|
from poly_position_watcher.common.enums import Side, TradeStatus
|
|
16
16
|
from poly_position_watcher.common.logger import logger
|
|
@@ -36,14 +36,23 @@ class PositionStore:
|
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
def __init__(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
self,
|
|
40
|
+
user_address: str,
|
|
41
|
+
enable_fee_calc: bool = False,
|
|
42
|
+
market_fee_schedules: Mapping[str, Mapping[str, Any]] | None = None,
|
|
43
|
+
fee_calc_fn: Callable[
|
|
44
|
+
[float, float, Side | str, Mapping[str, Any]], tuple[float, float]
|
|
45
|
+
]
|
|
46
|
+
| None = None,
|
|
43
47
|
):
|
|
44
48
|
self.user_address = user_address
|
|
45
49
|
self.enable_fee_calc = enable_fee_calc
|
|
46
50
|
self.fee_calc_fn = fee_calc_fn
|
|
51
|
+
self.market_fee_schedules: Dict[str, dict[str, Any]] = {
|
|
52
|
+
condition_id: dict(fee_schedule)
|
|
53
|
+
for condition_id, fee_schedule in (market_fee_schedules or {}).items()
|
|
54
|
+
if fee_schedule
|
|
55
|
+
}
|
|
47
56
|
self.trades_by_token: Dict[str, Dict[str, TradeMessage]] = defaultdict(dict)
|
|
48
57
|
self.positions: Dict[str, UserPosition] = {}
|
|
49
58
|
self.orders: Dict[str, OrderMessage] = {}
|
|
@@ -126,9 +135,9 @@ class PositionStore:
|
|
|
126
135
|
with self._lock:
|
|
127
136
|
existing = self.orders.get(order.id)
|
|
128
137
|
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
existing
|
|
139
|
+
and order.size_matched <= existing.size_matched
|
|
140
|
+
and order.status == existing.status
|
|
132
141
|
):
|
|
133
142
|
return
|
|
134
143
|
if abs(order.size_matched - order.original_size) < 0.5:
|
|
@@ -136,8 +145,52 @@ class PositionStore:
|
|
|
136
145
|
self.orders[order.id] = order
|
|
137
146
|
self._put(order.id, order)
|
|
138
147
|
|
|
148
|
+
def set_market_fee_schedule(
|
|
149
|
+
self, condition_id: str, fee_schedule: Mapping[str, Any] | None
|
|
150
|
+
) -> None:
|
|
151
|
+
with self._lock:
|
|
152
|
+
if fee_schedule:
|
|
153
|
+
self.market_fee_schedules[condition_id] = dict(fee_schedule)
|
|
154
|
+
else:
|
|
155
|
+
self.market_fee_schedules.pop(condition_id, None)
|
|
156
|
+
self._rebuild_positions_for_market(condition_id)
|
|
157
|
+
|
|
158
|
+
def set_market_fee_schedules(
|
|
159
|
+
self, fee_schedule_map: Mapping[str, Mapping[str, Any] | None]
|
|
160
|
+
) -> None:
|
|
161
|
+
with self._lock:
|
|
162
|
+
affected_markets: set[str] = set()
|
|
163
|
+
for condition_id, fee_schedule in fee_schedule_map.items():
|
|
164
|
+
affected_markets.add(condition_id)
|
|
165
|
+
if fee_schedule:
|
|
166
|
+
self.market_fee_schedules[condition_id] = dict(fee_schedule)
|
|
167
|
+
else:
|
|
168
|
+
self.market_fee_schedules.pop(condition_id, None)
|
|
169
|
+
|
|
170
|
+
for condition_id in affected_markets:
|
|
171
|
+
self._rebuild_positions_for_market(condition_id)
|
|
172
|
+
|
|
173
|
+
def _rebuild_positions_for_market(self, condition_id: str) -> None:
|
|
174
|
+
for token_id, trades_map in self.trades_by_token.items():
|
|
175
|
+
trades = list(trades_map.values())
|
|
176
|
+
if not trades or not any(trade.market == condition_id for trade in trades):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
result = self.get_token_id_from_trade(trades[0])
|
|
180
|
+
if result is None:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
outcome, _ = result
|
|
184
|
+
user_pos = self.build_position(
|
|
185
|
+
trades=trades, token_id=token_id, outcome=outcome
|
|
186
|
+
)
|
|
187
|
+
if user_pos is None:
|
|
188
|
+
continue
|
|
189
|
+
self.positions[token_id] = user_pos
|
|
190
|
+
self._put(token_id, user_pos)
|
|
191
|
+
|
|
139
192
|
def build_position(
|
|
140
|
-
|
|
193
|
+
self, trades: list[TradeMessage], token_id, outcome: str
|
|
141
194
|
) -> UserPosition | None:
|
|
142
195
|
def _status_is(trade: TradeMessage, status: TradeStatus) -> bool:
|
|
143
196
|
return trade.status == status or trade.status == status.value
|
|
@@ -150,12 +203,17 @@ class PositionStore:
|
|
|
150
203
|
has_failed = bool(len(filled_trades))
|
|
151
204
|
if has_failed:
|
|
152
205
|
filled_size = sum([i.size for i in filled_trades])
|
|
153
|
-
logger.warning(
|
|
154
|
-
|
|
206
|
+
logger.warning(
|
|
207
|
+
f"found error trades, total size: {filled_size}: {filled_trades}"
|
|
208
|
+
)
|
|
209
|
+
market_slug = next(
|
|
210
|
+
(trade.market_slug for trade in trades if trade.market_slug), ""
|
|
211
|
+
)
|
|
155
212
|
position_result = calculate_position_from_trades(
|
|
156
213
|
success_trades,
|
|
157
214
|
user_address=self.user_address,
|
|
158
215
|
enable_fee_calc=self.enable_fee_calc,
|
|
216
|
+
fee_schedule_by_market=self.market_fee_schedules,
|
|
159
217
|
fee_calc_fn=self.fee_calc_fn,
|
|
160
218
|
)
|
|
161
219
|
sellable_size = 0.0
|
|
@@ -164,6 +222,7 @@ class PositionStore:
|
|
|
164
222
|
confirmed_trades,
|
|
165
223
|
user_address=self.user_address,
|
|
166
224
|
enable_fee_calc=self.enable_fee_calc,
|
|
225
|
+
fee_schedule_by_market=self.market_fee_schedules,
|
|
167
226
|
fee_calc_fn=self.fee_calc_fn,
|
|
168
227
|
)
|
|
169
228
|
sellable_size = confirmed_result.size
|
|
@@ -204,12 +263,12 @@ class PositionStore:
|
|
|
204
263
|
return self.orders.get(order_id)
|
|
205
264
|
|
|
206
265
|
def blocking_get_token_position(
|
|
207
|
-
|
|
266
|
+
self, token_id: str, timeout: float = None
|
|
208
267
|
) -> UserPosition:
|
|
209
268
|
return self._get(token_id, timeout)
|
|
210
269
|
|
|
211
270
|
def blocking_get_order_by_id(
|
|
212
|
-
|
|
271
|
+
self, order_id: str, timeout: float = None
|
|
213
272
|
) -> OrderMessage:
|
|
214
273
|
return self._get(order_id, timeout)
|
|
215
274
|
|
|
@@ -233,16 +292,20 @@ class PositionWatcherService:
|
|
|
233
292
|
"""
|
|
234
293
|
|
|
235
294
|
def __init__(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
295
|
+
self,
|
|
296
|
+
client,
|
|
297
|
+
ws_idle_timeout=60 * 60,
|
|
298
|
+
wss_proxies: dict | None = None,
|
|
299
|
+
init_positions: bool = False,
|
|
300
|
+
enable_http_fallback: bool = False,
|
|
301
|
+
http_poll_interval: float = 1.5,
|
|
302
|
+
add_init_positions_to_http: bool = False,
|
|
303
|
+
enable_fee_calc: bool = False,
|
|
304
|
+
market_fee_schedules: Mapping[str, Mapping[str, Any]] | None = None,
|
|
305
|
+
fee_calc_fn: Callable[
|
|
306
|
+
[float, float, Side | str, Mapping[str, Any]], tuple[float, float]
|
|
307
|
+
]
|
|
308
|
+
| None = None,
|
|
246
309
|
):
|
|
247
310
|
"""
|
|
248
311
|
:param client: ClobClient instance
|
|
@@ -253,8 +316,9 @@ class PositionWatcherService:
|
|
|
253
316
|
:param http_poll_interval: HTTP polling interval in seconds
|
|
254
317
|
:param add_init_positions_to_http: Whether to add condition_ids from init_positions to HTTP monitoring
|
|
255
318
|
:param enable_fee_calc: Whether to apply fee adjustments in position calc
|
|
256
|
-
:param
|
|
257
|
-
|
|
319
|
+
:param market_fee_schedules: Optional mapping of condition_id -> feeSchedule
|
|
320
|
+
:param fee_calc_fn: Optional custom fee function (size, price, side, fee_schedule) -> (new_size, fee_amount)
|
|
321
|
+
|
|
258
322
|
wss_proxies example: {
|
|
259
323
|
"http_proxy_host": "127.0.0.1",
|
|
260
324
|
"http_proxy_port": 8118,
|
|
@@ -266,6 +330,7 @@ class PositionWatcherService:
|
|
|
266
330
|
self.position_store = PositionStore(
|
|
267
331
|
self.user_address,
|
|
268
332
|
enable_fee_calc=enable_fee_calc,
|
|
333
|
+
market_fee_schedules=market_fee_schedules,
|
|
269
334
|
fee_calc_fn=fee_calc_fn,
|
|
270
335
|
)
|
|
271
336
|
self._wss_proxies = wss_proxies or {}
|
|
@@ -305,15 +370,19 @@ class PositionWatcherService:
|
|
|
305
370
|
init_condition_ids = []
|
|
306
371
|
if self.init_positions:
|
|
307
372
|
try:
|
|
308
|
-
initialize_trades = self.api_worker.fetch_trades_from_positions(
|
|
373
|
+
initialize_trades = self.api_worker.fetch_trades_from_positions(
|
|
374
|
+
self.user_address
|
|
375
|
+
)
|
|
309
376
|
if initialize_trades:
|
|
310
377
|
for token_id, trades in initialize_trades.items():
|
|
311
378
|
self.position_store.init_trades(trades)
|
|
312
|
-
positions_info =
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
379
|
+
positions_info = "\n".join(
|
|
380
|
+
[
|
|
381
|
+
f"slug={pos.market_slug}, price={pos.price}, size={pos.size:.4f},"
|
|
382
|
+
f"volume={pos.volume:.4f}, token_id={pos.token_id}, outcome={pos.outcome}"
|
|
383
|
+
for pos in self.position_store.positions.values()
|
|
384
|
+
]
|
|
385
|
+
)
|
|
317
386
|
logger.info(f"Initialized positions:\n{positions_info}")
|
|
318
387
|
except Exception as e:
|
|
319
388
|
logger.error(f"Failed to initialize positions: {e}")
|
|
@@ -328,7 +397,9 @@ class PositionWatcherService:
|
|
|
328
397
|
# Add init positions' condition_ids to HTTP monitoring if requested
|
|
329
398
|
if self.add_init_positions_to_http and init_condition_ids:
|
|
330
399
|
self._http_fallback.add(market_ids=init_condition_ids)
|
|
331
|
-
logger.info(
|
|
400
|
+
logger.info(
|
|
401
|
+
f"Added {len(init_condition_ids)} condition_ids from init_positions to HTTP monitoring"
|
|
402
|
+
)
|
|
332
403
|
|
|
333
404
|
return self
|
|
334
405
|
|
|
@@ -393,6 +464,16 @@ class PositionWatcherService:
|
|
|
393
464
|
return position
|
|
394
465
|
return UserPosition(token_id=token_id, price=0, size=0, volume=0, last_update=0)
|
|
395
466
|
|
|
467
|
+
def set_market_fee_schedule(
|
|
468
|
+
self, condition_id: str, fee_schedule: Mapping[str, Any] | None
|
|
469
|
+
) -> None:
|
|
470
|
+
self.position_store.set_market_fee_schedule(condition_id, fee_schedule)
|
|
471
|
+
|
|
472
|
+
def set_market_fee_schedules(
|
|
473
|
+
self, fee_schedule_map: Mapping[str, Mapping[str, Any] | None]
|
|
474
|
+
) -> None:
|
|
475
|
+
self.position_store.set_market_fee_schedules(fee_schedule_map)
|
|
476
|
+
|
|
396
477
|
@staticmethod
|
|
397
478
|
def _truncate(value: str, limit: int) -> str:
|
|
398
479
|
if len(value) <= limit:
|
|
@@ -402,9 +483,7 @@ class PositionWatcherService:
|
|
|
402
483
|
tail = keep - head
|
|
403
484
|
return f"{value[:head]}...{value[-tail:]}" if keep else "..."
|
|
404
485
|
|
|
405
|
-
def show_positions(
|
|
406
|
-
self, limit: int | None = None, max_width: int = 24
|
|
407
|
-
) -> str:
|
|
486
|
+
def show_positions(self, limit: int | None = None, max_width: int = 24) -> str:
|
|
408
487
|
"""
|
|
409
488
|
Pretty print current positions and return the rendered table.
|
|
410
489
|
"""
|
|
@@ -423,20 +502,30 @@ class PositionWatcherService:
|
|
|
423
502
|
if pos.last_update
|
|
424
503
|
else ""
|
|
425
504
|
)
|
|
426
|
-
rows.append(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
505
|
+
rows.append(
|
|
506
|
+
{
|
|
507
|
+
"slug": pos.market_slug or "",
|
|
508
|
+
"outcome": pos.outcome or "",
|
|
509
|
+
"token_id": pos.token_id or "",
|
|
510
|
+
"price": f"{pos.price:.3f}",
|
|
511
|
+
"size": f"{pos.size:.3f}",
|
|
512
|
+
"volume": f"{pos.volume:.4f}",
|
|
513
|
+
"updated_at": last_update,
|
|
514
|
+
}
|
|
515
|
+
)
|
|
435
516
|
|
|
436
517
|
rows.sort(key=lambda r: abs(float(r["volume"])), reverse=True)
|
|
437
518
|
if limit:
|
|
438
519
|
rows = rows[:limit]
|
|
439
|
-
headers = [
|
|
520
|
+
headers = [
|
|
521
|
+
"slug",
|
|
522
|
+
"outcome",
|
|
523
|
+
"token_id",
|
|
524
|
+
"price",
|
|
525
|
+
"size",
|
|
526
|
+
"volume",
|
|
527
|
+
"updated_at",
|
|
528
|
+
]
|
|
440
529
|
table = Table(title="Positions", show_lines=False, show_footer=True)
|
|
441
530
|
table.add_column("slug", style="cyan")
|
|
442
531
|
table.add_column("outcome", style="magenta")
|
|
@@ -473,9 +562,7 @@ class PositionWatcherService:
|
|
|
473
562
|
record_console.print(table)
|
|
474
563
|
return record_console.export_text()
|
|
475
564
|
|
|
476
|
-
def show_orders(
|
|
477
|
-
self, limit: int | None = None, max_width: int = 24
|
|
478
|
-
) -> str:
|
|
565
|
+
def show_orders(self, limit: int | None = None, max_width: int = 24) -> str:
|
|
479
566
|
"""
|
|
480
567
|
Pretty print current orders and return the rendered table.
|
|
481
568
|
"""
|
|
@@ -496,17 +583,19 @@ class PositionWatcherService:
|
|
|
496
583
|
if order.timestamp
|
|
497
584
|
else ""
|
|
498
585
|
)
|
|
499
|
-
rows.append(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
586
|
+
rows.append(
|
|
587
|
+
{
|
|
588
|
+
"slug": order.market_slug or "",
|
|
589
|
+
"outcome": order.outcome or "",
|
|
590
|
+
"order_id": order.id or "",
|
|
591
|
+
"side": order.side or "",
|
|
592
|
+
"price": f"{order.price:.3f}",
|
|
593
|
+
"size_matched": f"{order.size_matched:.4f}",
|
|
594
|
+
"original_size": f"{order.original_size or 0.0:.4f}",
|
|
595
|
+
"status": order.status or "",
|
|
596
|
+
"created_at": created_at,
|
|
597
|
+
}
|
|
598
|
+
)
|
|
510
599
|
rows.sort(key=lambda r: abs(float(r["size_matched"])), reverse=True)
|
|
511
600
|
if limit:
|
|
512
601
|
rows = rows[:limit]
|
|
@@ -558,7 +647,7 @@ class PositionWatcherService:
|
|
|
558
647
|
return self.position_store.get_order_by_id(order_id)
|
|
559
648
|
|
|
560
649
|
def blocking_get_position(
|
|
561
|
-
|
|
650
|
+
self, token_id: str, timeout: float = None
|
|
562
651
|
) -> UserPosition | None:
|
|
563
652
|
"""
|
|
564
653
|
超时返回None;若无仓位则返回 size=0 的占位 UserPosition
|
|
@@ -570,7 +659,7 @@ class PositionWatcherService:
|
|
|
570
659
|
return self.get_position(token_id)
|
|
571
660
|
|
|
572
661
|
def blocking_get_order(
|
|
573
|
-
|
|
662
|
+
self, order_id: str, timeout: float = None
|
|
574
663
|
) -> OrderMessage | None:
|
|
575
664
|
"""
|
|
576
665
|
超时返回None(即返回 None,表示没有订单更新)
|
|
@@ -584,23 +673,29 @@ class PositionWatcherService:
|
|
|
584
673
|
# -------------------------------------------------------------------------
|
|
585
674
|
# HTTP Fallback Management (delegates to HttpFallbackManager)
|
|
586
675
|
# -------------------------------------------------------------------------
|
|
587
|
-
def add_http_listen(
|
|
676
|
+
def add_http_listen(
|
|
677
|
+
self, order_ids: list[str] = None, market_ids: list[str] = None
|
|
678
|
+
):
|
|
588
679
|
"""
|
|
589
680
|
Add markets/orders to HTTP fallback polling.
|
|
590
|
-
|
|
681
|
+
|
|
591
682
|
:param order_ids: List of order IDs to monitor
|
|
592
683
|
:param market_ids: List of market (condition) IDs to monitor
|
|
593
684
|
"""
|
|
594
685
|
if not self.enable_http_fallback or not self._http_fallback:
|
|
595
|
-
logger.warning(
|
|
686
|
+
logger.warning(
|
|
687
|
+
"HTTP fallback is not enabled. Enable it in __init__ to use this method."
|
|
688
|
+
)
|
|
596
689
|
return
|
|
597
690
|
|
|
598
691
|
self._http_fallback.add(market_ids=market_ids, order_ids=order_ids)
|
|
599
692
|
|
|
600
|
-
def remove_http_listen(
|
|
693
|
+
def remove_http_listen(
|
|
694
|
+
self, order_ids: list[str] = None, market_ids: list[str] = None
|
|
695
|
+
):
|
|
601
696
|
"""
|
|
602
697
|
Remove markets/orders from HTTP fallback polling.
|
|
603
|
-
|
|
698
|
+
|
|
604
699
|
:param order_ids: List of order IDs to remove
|
|
605
700
|
:param market_ids: List of market (condition) IDs to remove
|
|
606
701
|
"""
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# @File: trade_calculator.py
|
|
6
6
|
# @Software: PyCharm
|
|
7
7
|
from collections import deque
|
|
8
|
-
from typing import Callable, List
|
|
8
|
+
from typing import Any, Callable, List, Mapping
|
|
9
9
|
|
|
10
10
|
from poly_position_watcher.common.enums import Side
|
|
11
11
|
from poly_position_watcher.schema.position_model import (
|
|
@@ -15,17 +15,41 @@ from poly_position_watcher.schema.position_model import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def _default_fee_calc(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
def _default_fee_calc(
|
|
19
|
+
size: float,
|
|
20
|
+
price: float,
|
|
21
|
+
side: Side | str,
|
|
22
|
+
fee_schedule: Mapping[str, Any],
|
|
23
|
+
) -> tuple[float, float]:
|
|
24
|
+
rate = float(fee_schedule.get("rate") or 0.0)
|
|
25
|
+
exponent = float(fee_schedule.get("exponent") or 0.0)
|
|
26
|
+
if size <= 0 or price <= 0 or rate <= 0:
|
|
27
|
+
return size, 0.0
|
|
28
|
+
|
|
29
|
+
fee_amount = round(
|
|
30
|
+
max(size * price * rate * ((price * (1 - price)) ** exponent), 0.0),
|
|
31
|
+
4,
|
|
32
|
+
)
|
|
33
|
+
if fee_amount <= 0:
|
|
34
|
+
return size, 0.0
|
|
35
|
+
|
|
36
|
+
if side == Side.BUY or side == Side.BUY.value:
|
|
37
|
+
fee_size = fee_amount / price
|
|
38
|
+
return max(size - fee_size, 0.0), fee_amount
|
|
39
|
+
|
|
40
|
+
return size, fee_amount
|
|
22
41
|
|
|
23
42
|
|
|
24
43
|
def calculate_position_from_trades(
|
|
25
44
|
trades: List[TradeMessage],
|
|
26
45
|
user_address: str,
|
|
27
46
|
enable_fee_calc: bool = False,
|
|
28
|
-
|
|
47
|
+
fee_schedule_by_market: Mapping[str, Mapping[str, Any]] | None = None,
|
|
48
|
+
fee_calc_fn: Callable[
|
|
49
|
+
[float, float, Side | str, Mapping[str, Any]],
|
|
50
|
+
tuple[float, float],
|
|
51
|
+
]
|
|
52
|
+
| None = None,
|
|
29
53
|
) -> PositionResult:
|
|
30
54
|
"""
|
|
31
55
|
根据交易记录直接计算用户仓位(带浮点误差修正)
|
|
@@ -42,18 +66,24 @@ def calculate_position_from_trades(
|
|
|
42
66
|
total_original_size = 0.0
|
|
43
67
|
|
|
44
68
|
def apply_fee(
|
|
45
|
-
size: float,
|
|
69
|
+
size: float,
|
|
70
|
+
price: float,
|
|
71
|
+
side: Side | str,
|
|
72
|
+
market_id: str | None,
|
|
73
|
+
trader_side: str | None,
|
|
46
74
|
) -> tuple[float, float]:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
75
|
+
fee_schedule = (
|
|
76
|
+
fee_schedule_by_market.get(market_id or "")
|
|
77
|
+
if fee_schedule_by_market
|
|
78
|
+
else None
|
|
79
|
+
)
|
|
80
|
+
if not enable_fee_calc or not fee_schedule:
|
|
81
|
+
return size, 0.0
|
|
82
|
+
taker_only = bool(fee_schedule.get("takerOnly", False))
|
|
83
|
+
if taker_only and trader_side != "TAKER":
|
|
52
84
|
return size, 0.0
|
|
53
85
|
calc = fee_calc_fn or _default_fee_calc
|
|
54
|
-
|
|
55
|
-
fee_amount = (size - size_after_fee) * price
|
|
56
|
-
return size_after_fee, fee_amount
|
|
86
|
+
return calc(size, price, side, fee_schedule)
|
|
57
87
|
|
|
58
88
|
total_fee_amount = 0.0
|
|
59
89
|
|
|
@@ -67,28 +97,54 @@ def calculate_position_from_trades(
|
|
|
67
97
|
continue
|
|
68
98
|
is_maker_order = True
|
|
69
99
|
size, fee_amount = apply_fee(
|
|
70
|
-
order.size,
|
|
100
|
+
order.size,
|
|
101
|
+
order.price,
|
|
102
|
+
order.side,
|
|
103
|
+
trade.market,
|
|
104
|
+
trade.trader_side,
|
|
71
105
|
)
|
|
72
106
|
total_fee_amount += fee_amount
|
|
73
107
|
|
|
74
108
|
if order.side == Side.BUY:
|
|
75
|
-
buy_events.append(
|
|
109
|
+
buy_events.append(
|
|
110
|
+
(size, order.price, trade.match_time, order.size * order.price)
|
|
111
|
+
)
|
|
76
112
|
total_original_size += order.size
|
|
77
113
|
else:
|
|
78
|
-
sell_events.append(
|
|
114
|
+
sell_events.append(
|
|
115
|
+
(
|
|
116
|
+
-size,
|
|
117
|
+
order.price,
|
|
118
|
+
trade.match_time,
|
|
119
|
+
size * order.price - fee_amount,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
79
122
|
total_original_size -= order.size
|
|
80
123
|
|
|
81
124
|
# taker 部分
|
|
82
125
|
if not is_maker_order and trade.maker_address.upper() == user_address.upper():
|
|
83
126
|
size, fee_amount = apply_fee(
|
|
84
|
-
trade.size,
|
|
127
|
+
trade.size,
|
|
128
|
+
trade.price,
|
|
129
|
+
trade.side,
|
|
130
|
+
trade.market,
|
|
131
|
+
trade.trader_side,
|
|
85
132
|
)
|
|
86
133
|
total_fee_amount += fee_amount
|
|
87
134
|
if trade.side == Side.BUY:
|
|
88
|
-
buy_events.append(
|
|
135
|
+
buy_events.append(
|
|
136
|
+
(size, trade.price, trade.match_time, trade.size * trade.price)
|
|
137
|
+
)
|
|
89
138
|
total_original_size += trade.size
|
|
90
139
|
else:
|
|
91
|
-
sell_events.append(
|
|
140
|
+
sell_events.append(
|
|
141
|
+
(
|
|
142
|
+
-size,
|
|
143
|
+
trade.price,
|
|
144
|
+
trade.match_time,
|
|
145
|
+
size * trade.price - fee_amount,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
92
148
|
total_original_size -= trade.size
|
|
93
149
|
|
|
94
150
|
# --- 2. 时间排序 ---
|
|
@@ -98,34 +154,36 @@ def calculate_position_from_trades(
|
|
|
98
154
|
buy_queue = deque()
|
|
99
155
|
realized_pnl = 0.0
|
|
100
156
|
|
|
101
|
-
for size, price, _ in all_events:
|
|
157
|
+
for size, price, _, cash_amount in all_events:
|
|
102
158
|
size = clean(size) # ← 新增:修正
|
|
103
159
|
|
|
104
160
|
if size > 0:
|
|
105
161
|
# 买入加入队列
|
|
106
|
-
|
|
162
|
+
unit_cost = cash_amount / size if size else price
|
|
163
|
+
buy_queue.append([size, unit_cost])
|
|
107
164
|
|
|
108
165
|
else:
|
|
109
166
|
# 卖出(size 为负)
|
|
110
167
|
sell_size = clean(-size)
|
|
168
|
+
sell_price = cash_amount / sell_size if sell_size else price
|
|
111
169
|
|
|
112
170
|
while sell_size > EPS and buy_queue:
|
|
113
171
|
lot_size, lot_price = buy_queue[0]
|
|
114
172
|
|
|
115
173
|
if lot_size <= sell_size + EPS:
|
|
116
174
|
# 完全消耗
|
|
117
|
-
realized_pnl += (
|
|
175
|
+
realized_pnl += (sell_price - lot_price) * lot_size
|
|
118
176
|
sell_size -= lot_size
|
|
119
177
|
buy_queue.popleft()
|
|
120
178
|
else:
|
|
121
179
|
# 部分消耗
|
|
122
|
-
realized_pnl += (
|
|
180
|
+
realized_pnl += (sell_price - lot_price) * sell_size
|
|
123
181
|
buy_queue[0][0] = clean(lot_size - sell_size)
|
|
124
182
|
sell_size = 0.0
|
|
125
183
|
|
|
126
184
|
# 如果还有卖不完 → 变成空头
|
|
127
185
|
if sell_size > EPS:
|
|
128
|
-
buy_queue.appendleft([-sell_size,
|
|
186
|
+
buy_queue.appendleft([-sell_size, sell_price])
|
|
129
187
|
|
|
130
188
|
# --- 4. 计算最终持仓 ---
|
|
131
189
|
total_size = clean(sum(q[0] for q in buy_queue))
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0/poly_position_watcher.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: poly-position-watcher
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: polymarket proxy wallet redeem
|
|
5
5
|
Home-page: https://github.com/tosmart01/polymarket-position-watcher
|
|
6
6
|
Author: pinbar
|
|
@@ -31,7 +31,7 @@ Dynamic: requires-python
|
|
|
31
31
|
|
|
32
32
|
- WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
|
|
33
33
|
- HTTP polling fallback for reliability
|
|
34
|
-
- Optional fee calculation
|
|
34
|
+
- Optional fee calculation using market `feeSchedule`
|
|
35
35
|
- Position fields for fill checks:
|
|
36
36
|
`size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
|
|
37
37
|
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
|
|
@@ -65,8 +65,12 @@ with PositionWatcherService(
|
|
|
65
65
|
enable_http_fallback=True, # Enable HTTP polling fallback
|
|
66
66
|
add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
|
|
67
67
|
enable_fee_calc=True, # Optional: enable fee adjustments
|
|
68
|
-
# fee_calc_fn=custom_fee_fn, # Optional: override fee formula
|
|
69
68
|
) as service:
|
|
69
|
+
service.set_market_fee_schedule(
|
|
70
|
+
"<condition_id>",
|
|
71
|
+
{"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
|
|
72
|
+
)
|
|
73
|
+
|
|
70
74
|
# Non-blocking: Get current positions and orders (returns immediately)
|
|
71
75
|
position: UserPosition = service.get_position("<token_id>")
|
|
72
76
|
order: OrderMessage = service.get_order("<order_id>")
|
|
@@ -147,16 +151,20 @@ service.show_orders(limit=10)
|
|
|
147
151
|
## **⚠️ Fee notice (taker fee / maker rebate)**
|
|
148
152
|
---
|
|
149
153
|
|
|
150
|
-
Some Polymarket markets enable taker fee / maker rebate. This library
|
|
154
|
+
Some Polymarket markets enable taker fee / maker rebate. This library supports fee calculation from market `feeSchedule` data:
|
|
151
155
|
|
|
152
|
-
- Enable with `enable_fee_calc=True`
|
|
153
|
-
-
|
|
156
|
+
- Enable with `enable_fee_calc=True`
|
|
157
|
+
- Register `condition_id -> feeSchedule` through `service.set_market_fee_schedule(...)` or `service.set_market_fee_schedules(...)`
|
|
158
|
+
- Optionally override the fee handler with `fee_calc_fn`
|
|
154
159
|
- Disable (default) if you prefer pre-fee positions
|
|
155
160
|
- Returned position fields:
|
|
156
161
|
`size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
|
|
157
162
|
|
|
158
163
|
Default fee formula (when `fee_calc_fn` is not provided):
|
|
159
|
-
`fee =
|
|
164
|
+
`fee = size * price * rate * (price * (1 - price)) ** exponent`.
|
|
165
|
+
|
|
166
|
+
On taker buys, the fee is deducted in shares, so `size` is reduced by `fee / price`.
|
|
167
|
+
On taker sells, the fee is charged in USDC, so position size is unchanged and only `fee_amount` increases.
|
|
160
168
|
|
|
161
169
|
---
|
|
162
170
|
|
|
@@ -182,8 +190,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
|
|
|
182
190
|
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
|
|
183
191
|
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
|
|
184
192
|
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
|
|
185
|
-
| `enable_fee_calc` | bool | False | Apply fee adjustments using `
|
|
186
|
-
| `
|
|
193
|
+
| `enable_fee_calc` | bool | False | Apply fee adjustments using registered market `feeSchedule` data |
|
|
194
|
+
| `market_fee_schedules` | mapping | None | Optional initial `condition_id -> feeSchedule` mapping |
|
|
195
|
+
| `fee_calc_fn` | callable | None | Custom fee function: `(size, price, side, fee_schedule) -> (new_size, fee_amount)` |
|
|
187
196
|
|
|
188
197
|
### Environment Variables
|
|
189
198
|
|
|
@@ -20,4 +20,5 @@ poly_position_watcher/common/logger.py
|
|
|
20
20
|
poly_position_watcher/schema/__init__.py
|
|
21
21
|
poly_position_watcher/schema/base.py
|
|
22
22
|
poly_position_watcher/schema/common_model.py
|
|
23
|
-
poly_position_watcher/schema/position_model.py
|
|
23
|
+
poly_position_watcher/schema/position_model.py
|
|
24
|
+
tests/test_trade_calculator.py
|
|
@@ -24,7 +24,7 @@ for cache_dir in HERE.rglob("__pycache__"):
|
|
|
24
24
|
|
|
25
25
|
setup(
|
|
26
26
|
name=PROJECT.get("name", "poly-position-watcher"),
|
|
27
|
-
version=PROJECT.get("version", "0.
|
|
27
|
+
version=PROJECT.get("version", "0.3.0"),
|
|
28
28
|
description=PROJECT.get("description", ""),
|
|
29
29
|
long_description=README_PATH.read_text(encoding="utf-8") if README_PATH.exists() else "",
|
|
30
30
|
long_description_content_type="text/markdown",
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from poly_position_watcher.common.enums import Side
|
|
7
|
+
from poly_position_watcher.schema.position_model import TradeMessage
|
|
8
|
+
from poly_position_watcher.trade_calculator import calculate_position_from_trades
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
USER_ADDRESS = "0x123"
|
|
12
|
+
MARKET_ID = "0xmarket"
|
|
13
|
+
TOKEN_ID = "0xtoken"
|
|
14
|
+
FEE_SCHEDULE = {
|
|
15
|
+
"rate": 0.0175,
|
|
16
|
+
"exponent": 1,
|
|
17
|
+
"takerOnly": True,
|
|
18
|
+
"rebateRate": 0.25,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_taker_trade(
|
|
23
|
+
*,
|
|
24
|
+
trade_id: str,
|
|
25
|
+
side: str,
|
|
26
|
+
size: float,
|
|
27
|
+
price: float,
|
|
28
|
+
match_time: int,
|
|
29
|
+
) -> TradeMessage:
|
|
30
|
+
return TradeMessage(
|
|
31
|
+
type="TRADE",
|
|
32
|
+
event_type="trade",
|
|
33
|
+
asset_id=TOKEN_ID,
|
|
34
|
+
id=trade_id,
|
|
35
|
+
maker_orders=[],
|
|
36
|
+
transaction_hash=f"0xhash-{trade_id}",
|
|
37
|
+
market=MARKET_ID,
|
|
38
|
+
maker_address=USER_ADDRESS,
|
|
39
|
+
outcome="YES",
|
|
40
|
+
owner=USER_ADDRESS,
|
|
41
|
+
price=price,
|
|
42
|
+
side=side,
|
|
43
|
+
size=size,
|
|
44
|
+
status="CONFIRMED",
|
|
45
|
+
taker_order_id=f"0xorder-{trade_id}",
|
|
46
|
+
timestamp=match_time,
|
|
47
|
+
match_time=match_time,
|
|
48
|
+
last_update=match_time,
|
|
49
|
+
trade_owner=USER_ADDRESS,
|
|
50
|
+
trader_side="TAKER",
|
|
51
|
+
fee_rate_bps=0,
|
|
52
|
+
market_slug="test-market",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def expected_fee(size: float, price: float, rate: float, exponent: float) -> float:
|
|
57
|
+
return round(size * price * rate * ((price * (1 - price)) ** exponent), 4)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TradeCalculatorFeeTests(unittest.TestCase):
|
|
61
|
+
def test_maker_trade_does_not_apply_taker_only_fee(self) -> None:
|
|
62
|
+
trade = TradeMessage(
|
|
63
|
+
type="TRADE",
|
|
64
|
+
event_type="trade",
|
|
65
|
+
asset_id="outer-asset-id",
|
|
66
|
+
id="maker-1",
|
|
67
|
+
maker_orders=[
|
|
68
|
+
{
|
|
69
|
+
"order_id": "order-user",
|
|
70
|
+
"owner": "owner-user",
|
|
71
|
+
"maker_address": USER_ADDRESS,
|
|
72
|
+
"matched_amount": "1947.37",
|
|
73
|
+
"price": "0.57",
|
|
74
|
+
"fee_rate_bps": "1000",
|
|
75
|
+
"asset_id": TOKEN_ID,
|
|
76
|
+
"outcome": "Up",
|
|
77
|
+
"side": "BUY",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"order_id": "order-other",
|
|
81
|
+
"owner": "owner-other",
|
|
82
|
+
"maker_address": "0xother",
|
|
83
|
+
"matched_amount": "5",
|
|
84
|
+
"price": "0.45",
|
|
85
|
+
"fee_rate_bps": "1000",
|
|
86
|
+
"asset_id": "other-token",
|
|
87
|
+
"outcome": "Down",
|
|
88
|
+
"side": "BUY",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
transaction_hash="0xhash-maker",
|
|
92
|
+
market=MARKET_ID,
|
|
93
|
+
maker_address="0xouter-maker",
|
|
94
|
+
outcome="OuterOutcome",
|
|
95
|
+
owner="outer-owner",
|
|
96
|
+
price=0.57,
|
|
97
|
+
side=Side.BUY.value,
|
|
98
|
+
size=1947.37,
|
|
99
|
+
status="CONFIRMED",
|
|
100
|
+
taker_order_id="0xtaker-order",
|
|
101
|
+
timestamp=1,
|
|
102
|
+
match_time=1,
|
|
103
|
+
last_update=1,
|
|
104
|
+
trade_owner="outer-owner",
|
|
105
|
+
trader_side="MAKER",
|
|
106
|
+
fee_rate_bps=1000,
|
|
107
|
+
market_slug="test-market",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
result = calculate_position_from_trades(
|
|
111
|
+
[trade],
|
|
112
|
+
user_address=USER_ADDRESS,
|
|
113
|
+
enable_fee_calc=True,
|
|
114
|
+
fee_schedule_by_market={MARKET_ID: FEE_SCHEDULE},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self.assertTrue(math.isclose(result.fee_amount, 0.0, rel_tol=0, abs_tol=1e-9))
|
|
118
|
+
self.assertTrue(math.isclose(result.size, 1947.37, rel_tol=0, abs_tol=1e-9))
|
|
119
|
+
self.assertTrue(
|
|
120
|
+
math.isclose(result.original_size, 1947.37, rel_tol=0, abs_tol=1e-9)
|
|
121
|
+
)
|
|
122
|
+
self.assertTrue(
|
|
123
|
+
math.isclose(result.amount, 1947.37 * 0.57, rel_tol=0, abs_tol=1e-9)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def test_taker_buy_deducts_fee_in_shares(self) -> None:
|
|
127
|
+
trade = build_taker_trade(
|
|
128
|
+
trade_id="buy-1",
|
|
129
|
+
side=Side.BUY.value,
|
|
130
|
+
size=100.0,
|
|
131
|
+
price=0.25,
|
|
132
|
+
match_time=1,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
result = calculate_position_from_trades(
|
|
136
|
+
[trade],
|
|
137
|
+
user_address=USER_ADDRESS,
|
|
138
|
+
enable_fee_calc=True,
|
|
139
|
+
fee_schedule_by_market={MARKET_ID: FEE_SCHEDULE},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
fee_amount = expected_fee(100.0, 0.25, 0.0175, 1)
|
|
143
|
+
expected_size = 100.0 - fee_amount / 0.25
|
|
144
|
+
|
|
145
|
+
self.assertTrue(math.isclose(result.fee_amount, fee_amount, rel_tol=0, abs_tol=1e-9))
|
|
146
|
+
self.assertTrue(math.isclose(result.size, expected_size, rel_tol=0, abs_tol=1e-9))
|
|
147
|
+
self.assertTrue(math.isclose(result.original_size, 100.0, rel_tol=0, abs_tol=1e-9))
|
|
148
|
+
self.assertTrue(math.isclose(result.amount, 25.0, rel_tol=0, abs_tol=1e-9))
|
|
149
|
+
self.assertTrue(
|
|
150
|
+
math.isclose(result.avg_price, 25.0 / expected_size, rel_tol=0, abs_tol=1e-9)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def test_taker_sell_keeps_share_count_and_charges_usdc(self) -> None:
|
|
154
|
+
trade = build_taker_trade(
|
|
155
|
+
trade_id="sell-1",
|
|
156
|
+
side=Side.SELL.value,
|
|
157
|
+
size=100.0,
|
|
158
|
+
price=0.25,
|
|
159
|
+
match_time=1,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
result = calculate_position_from_trades(
|
|
163
|
+
[trade],
|
|
164
|
+
user_address=USER_ADDRESS,
|
|
165
|
+
enable_fee_calc=True,
|
|
166
|
+
fee_schedule_by_market={MARKET_ID: FEE_SCHEDULE},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
fee_amount = expected_fee(100.0, 0.25, 0.0175, 1)
|
|
170
|
+
net_proceeds = 100.0 * 0.25 - fee_amount
|
|
171
|
+
|
|
172
|
+
self.assertTrue(math.isclose(result.fee_amount, fee_amount, rel_tol=0, abs_tol=1e-9))
|
|
173
|
+
self.assertTrue(math.isclose(result.size, -100.0, rel_tol=0, abs_tol=1e-9))
|
|
174
|
+
self.assertTrue(math.isclose(result.original_size, -100.0, rel_tol=0, abs_tol=1e-9))
|
|
175
|
+
self.assertTrue(math.isclose(result.amount, -net_proceeds, rel_tol=0, abs_tol=1e-9))
|
|
176
|
+
self.assertTrue(math.isclose(result.avg_price, net_proceeds / 100.0, rel_tol=0, abs_tol=1e-9))
|
|
177
|
+
|
|
178
|
+
def test_round_trip_uses_net_sell_proceeds_for_realized_pnl(self) -> None:
|
|
179
|
+
buy_trade = build_taker_trade(
|
|
180
|
+
trade_id="buy-1",
|
|
181
|
+
side=Side.BUY.value,
|
|
182
|
+
size=100.0,
|
|
183
|
+
price=0.25,
|
|
184
|
+
match_time=1,
|
|
185
|
+
)
|
|
186
|
+
buy_fee = expected_fee(100.0, 0.25, 0.0175, 1)
|
|
187
|
+
net_buy_size = 100.0 - buy_fee / 0.25
|
|
188
|
+
|
|
189
|
+
sell_trade = build_taker_trade(
|
|
190
|
+
trade_id="sell-1",
|
|
191
|
+
side=Side.SELL.value,
|
|
192
|
+
size=net_buy_size,
|
|
193
|
+
price=0.4,
|
|
194
|
+
match_time=2,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
result = calculate_position_from_trades(
|
|
198
|
+
[buy_trade, sell_trade],
|
|
199
|
+
user_address=USER_ADDRESS,
|
|
200
|
+
enable_fee_calc=True,
|
|
201
|
+
fee_schedule_by_market={MARKET_ID: FEE_SCHEDULE},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
sell_fee = expected_fee(net_buy_size, 0.4, 0.0175, 1)
|
|
205
|
+
expected_realized_pnl = (net_buy_size * 0.4 - sell_fee) - 25.0
|
|
206
|
+
|
|
207
|
+
self.assertTrue(math.isclose(result.size, 0.0, rel_tol=0, abs_tol=1e-9))
|
|
208
|
+
self.assertTrue(
|
|
209
|
+
math.isclose(result.realized_pnl, expected_realized_pnl, rel_tol=0, abs_tol=1e-9)
|
|
210
|
+
)
|
|
211
|
+
self.assertTrue(
|
|
212
|
+
math.isclose(result.fee_amount, buy_fee + sell_fee, rel_tol=0, abs_tol=1e-9)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/__init__.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/api_worker.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/__init__.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/enums.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/logger.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/__init__.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/wss_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|