poly-position-watcher 0.2.8__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.8/poly_position_watcher.egg-info → poly_position_watcher-0.3.0}/PKG-INFO +28 -10
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/README.md +27 -9
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/_version.py +1 -1
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/position_service.py +163 -66
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/position_model.py +4 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/trade_calculator.py +102 -28
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0/poly_position_watcher.egg-info}/PKG-INFO +28 -10
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/SOURCES.txt +2 -1
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/pyproject.toml +1 -1
- {poly_position_watcher-0.2.8 → 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.8 → poly_position_watcher-0.3.0}/LICENSE +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/MANIFEST.in +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/__init__.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/api_worker.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/__init__.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/enums.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/logger.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/__init__.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/base.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/common_model.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/wss_worker.py +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/dependency_links.txt +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/requires.txt +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/top_level.txt +0 -0
- {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/setup.cfg +0 -0
{poly_position_watcher-0.2.8/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,8 +31,9 @@ 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
|
|
35
|
-
-
|
|
34
|
+
- Optional fee calculation using market `feeSchedule`
|
|
35
|
+
- Position fields for fill checks:
|
|
36
|
+
`size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
|
|
36
37
|
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
|
|
37
38
|
|
|
38
39
|
**Note: WSS disconnects are auto-detected and reconnected.**
|
|
@@ -64,13 +65,21 @@ with PositionWatcherService(
|
|
|
64
65
|
enable_http_fallback=True, # Enable HTTP polling fallback
|
|
65
66
|
add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
|
|
66
67
|
enable_fee_calc=True, # Optional: enable fee adjustments
|
|
67
|
-
# fee_calc_fn=custom_fee_fn, # Optional: override fee formula
|
|
68
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
|
+
|
|
69
74
|
# Non-blocking: Get current positions and orders (returns immediately)
|
|
70
75
|
position: UserPosition = service.get_position("<token_id>")
|
|
71
76
|
order: OrderMessage = service.get_order("<order_id>")
|
|
72
77
|
print(position)
|
|
73
78
|
print(order)
|
|
79
|
+
if position:
|
|
80
|
+
print("size(post-fee):", position.size)
|
|
81
|
+
print("size(pre-fee):", position.original_size)
|
|
82
|
+
print("fee_amount:", position.fee_amount)
|
|
74
83
|
service.show_positions(limit=10)
|
|
75
84
|
service.show_orders(limit=10)
|
|
76
85
|
|
|
@@ -113,7 +122,9 @@ OrderMessage(
|
|
|
113
122
|
UserPosition(
|
|
114
123
|
price: 0.0,
|
|
115
124
|
size: 0.0,
|
|
125
|
+
original_size: 0.0,
|
|
116
126
|
volume: 0.0,
|
|
127
|
+
fee_amount: 0.0,
|
|
117
128
|
sellable_size: 0.0,
|
|
118
129
|
token_id: '',
|
|
119
130
|
last_update: 0.0,
|
|
@@ -140,14 +151,20 @@ service.show_orders(limit=10)
|
|
|
140
151
|
## **⚠️ Fee notice (taker fee / maker rebate)**
|
|
141
152
|
---
|
|
142
153
|
|
|
143
|
-
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:
|
|
144
155
|
|
|
145
|
-
- Enable with `enable_fee_calc=True`
|
|
146
|
-
-
|
|
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`
|
|
147
159
|
- Disable (default) if you prefer pre-fee positions
|
|
160
|
+
- Returned position fields:
|
|
161
|
+
`size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
|
|
148
162
|
|
|
149
163
|
Default fee formula (when `fee_calc_fn` is not provided):
|
|
150
|
-
`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.
|
|
151
168
|
|
|
152
169
|
---
|
|
153
170
|
|
|
@@ -173,8 +190,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
|
|
|
173
190
|
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
|
|
174
191
|
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
|
|
175
192
|
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
|
|
176
|
-
| `enable_fee_calc` | bool | False | Apply fee adjustments using `
|
|
177
|
-
| `
|
|
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)` |
|
|
178
196
|
|
|
179
197
|
### Environment Variables
|
|
180
198
|
|
|
@@ -12,8 +12,9 @@
|
|
|
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
|
|
16
|
-
-
|
|
15
|
+
- Optional fee calculation using market `feeSchedule`
|
|
16
|
+
- Position fields for fill checks:
|
|
17
|
+
`size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
|
|
17
18
|
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
|
|
18
19
|
|
|
19
20
|
**Note: WSS disconnects are auto-detected and reconnected.**
|
|
@@ -45,13 +46,21 @@ with PositionWatcherService(
|
|
|
45
46
|
enable_http_fallback=True, # Enable HTTP polling fallback
|
|
46
47
|
add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
|
|
47
48
|
enable_fee_calc=True, # Optional: enable fee adjustments
|
|
48
|
-
# fee_calc_fn=custom_fee_fn, # Optional: override fee formula
|
|
49
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
|
+
|
|
50
55
|
# Non-blocking: Get current positions and orders (returns immediately)
|
|
51
56
|
position: UserPosition = service.get_position("<token_id>")
|
|
52
57
|
order: OrderMessage = service.get_order("<order_id>")
|
|
53
58
|
print(position)
|
|
54
59
|
print(order)
|
|
60
|
+
if position:
|
|
61
|
+
print("size(post-fee):", position.size)
|
|
62
|
+
print("size(pre-fee):", position.original_size)
|
|
63
|
+
print("fee_amount:", position.fee_amount)
|
|
55
64
|
service.show_positions(limit=10)
|
|
56
65
|
service.show_orders(limit=10)
|
|
57
66
|
|
|
@@ -94,7 +103,9 @@ OrderMessage(
|
|
|
94
103
|
UserPosition(
|
|
95
104
|
price: 0.0,
|
|
96
105
|
size: 0.0,
|
|
106
|
+
original_size: 0.0,
|
|
97
107
|
volume: 0.0,
|
|
108
|
+
fee_amount: 0.0,
|
|
98
109
|
sellable_size: 0.0,
|
|
99
110
|
token_id: '',
|
|
100
111
|
last_update: 0.0,
|
|
@@ -121,14 +132,20 @@ service.show_orders(limit=10)
|
|
|
121
132
|
## **⚠️ Fee notice (taker fee / maker rebate)**
|
|
122
133
|
---
|
|
123
134
|
|
|
124
|
-
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:
|
|
125
136
|
|
|
126
|
-
- Enable with `enable_fee_calc=True`
|
|
127
|
-
-
|
|
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`
|
|
128
140
|
- Disable (default) if you prefer pre-fee positions
|
|
141
|
+
- Returned position fields:
|
|
142
|
+
`size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
|
|
129
143
|
|
|
130
144
|
Default fee formula (when `fee_calc_fn` is not provided):
|
|
131
|
-
`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.
|
|
132
149
|
|
|
133
150
|
---
|
|
134
151
|
|
|
@@ -154,8 +171,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
|
|
|
154
171
|
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
|
|
155
172
|
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
|
|
156
173
|
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
|
|
157
|
-
| `enable_fee_calc` | bool | False | Apply fee adjustments using `
|
|
158
|
-
| `
|
|
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)` |
|
|
159
177
|
|
|
160
178
|
### Environment Variables
|
|
161
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,13 +222,16 @@ 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
|
|
170
229
|
current = UserPosition(
|
|
171
230
|
price=position_result.avg_price,
|
|
172
231
|
size=position_result.size,
|
|
232
|
+
original_size=position_result.original_size,
|
|
173
233
|
volume=position_result.amount,
|
|
234
|
+
fee_amount=position_result.fee_amount,
|
|
174
235
|
sellable_size=sellable_size,
|
|
175
236
|
token_id=token_id,
|
|
176
237
|
last_update=position_result.last_update,
|
|
@@ -202,12 +263,12 @@ class PositionStore:
|
|
|
202
263
|
return self.orders.get(order_id)
|
|
203
264
|
|
|
204
265
|
def blocking_get_token_position(
|
|
205
|
-
|
|
266
|
+
self, token_id: str, timeout: float = None
|
|
206
267
|
) -> UserPosition:
|
|
207
268
|
return self._get(token_id, timeout)
|
|
208
269
|
|
|
209
270
|
def blocking_get_order_by_id(
|
|
210
|
-
|
|
271
|
+
self, order_id: str, timeout: float = None
|
|
211
272
|
) -> OrderMessage:
|
|
212
273
|
return self._get(order_id, timeout)
|
|
213
274
|
|
|
@@ -231,16 +292,20 @@ class PositionWatcherService:
|
|
|
231
292
|
"""
|
|
232
293
|
|
|
233
294
|
def __init__(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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,
|
|
244
309
|
):
|
|
245
310
|
"""
|
|
246
311
|
:param client: ClobClient instance
|
|
@@ -251,8 +316,9 @@ class PositionWatcherService:
|
|
|
251
316
|
:param http_poll_interval: HTTP polling interval in seconds
|
|
252
317
|
:param add_init_positions_to_http: Whether to add condition_ids from init_positions to HTTP monitoring
|
|
253
318
|
:param enable_fee_calc: Whether to apply fee adjustments in position calc
|
|
254
|
-
:param
|
|
255
|
-
|
|
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
|
+
|
|
256
322
|
wss_proxies example: {
|
|
257
323
|
"http_proxy_host": "127.0.0.1",
|
|
258
324
|
"http_proxy_port": 8118,
|
|
@@ -264,6 +330,7 @@ class PositionWatcherService:
|
|
|
264
330
|
self.position_store = PositionStore(
|
|
265
331
|
self.user_address,
|
|
266
332
|
enable_fee_calc=enable_fee_calc,
|
|
333
|
+
market_fee_schedules=market_fee_schedules,
|
|
267
334
|
fee_calc_fn=fee_calc_fn,
|
|
268
335
|
)
|
|
269
336
|
self._wss_proxies = wss_proxies or {}
|
|
@@ -303,15 +370,19 @@ class PositionWatcherService:
|
|
|
303
370
|
init_condition_ids = []
|
|
304
371
|
if self.init_positions:
|
|
305
372
|
try:
|
|
306
|
-
initialize_trades = self.api_worker.fetch_trades_from_positions(
|
|
373
|
+
initialize_trades = self.api_worker.fetch_trades_from_positions(
|
|
374
|
+
self.user_address
|
|
375
|
+
)
|
|
307
376
|
if initialize_trades:
|
|
308
377
|
for token_id, trades in initialize_trades.items():
|
|
309
378
|
self.position_store.init_trades(trades)
|
|
310
|
-
positions_info =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
)
|
|
315
386
|
logger.info(f"Initialized positions:\n{positions_info}")
|
|
316
387
|
except Exception as e:
|
|
317
388
|
logger.error(f"Failed to initialize positions: {e}")
|
|
@@ -326,7 +397,9 @@ class PositionWatcherService:
|
|
|
326
397
|
# Add init positions' condition_ids to HTTP monitoring if requested
|
|
327
398
|
if self.add_init_positions_to_http and init_condition_ids:
|
|
328
399
|
self._http_fallback.add(market_ids=init_condition_ids)
|
|
329
|
-
logger.info(
|
|
400
|
+
logger.info(
|
|
401
|
+
f"Added {len(init_condition_ids)} condition_ids from init_positions to HTTP monitoring"
|
|
402
|
+
)
|
|
330
403
|
|
|
331
404
|
return self
|
|
332
405
|
|
|
@@ -391,6 +464,16 @@ class PositionWatcherService:
|
|
|
391
464
|
return position
|
|
392
465
|
return UserPosition(token_id=token_id, price=0, size=0, volume=0, last_update=0)
|
|
393
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
|
+
|
|
394
477
|
@staticmethod
|
|
395
478
|
def _truncate(value: str, limit: int) -> str:
|
|
396
479
|
if len(value) <= limit:
|
|
@@ -400,9 +483,7 @@ class PositionWatcherService:
|
|
|
400
483
|
tail = keep - head
|
|
401
484
|
return f"{value[:head]}...{value[-tail:]}" if keep else "..."
|
|
402
485
|
|
|
403
|
-
def show_positions(
|
|
404
|
-
self, limit: int | None = None, max_width: int = 24
|
|
405
|
-
) -> str:
|
|
486
|
+
def show_positions(self, limit: int | None = None, max_width: int = 24) -> str:
|
|
406
487
|
"""
|
|
407
488
|
Pretty print current positions and return the rendered table.
|
|
408
489
|
"""
|
|
@@ -421,20 +502,30 @@ class PositionWatcherService:
|
|
|
421
502
|
if pos.last_update
|
|
422
503
|
else ""
|
|
423
504
|
)
|
|
424
|
-
rows.append(
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
)
|
|
433
516
|
|
|
434
517
|
rows.sort(key=lambda r: abs(float(r["volume"])), reverse=True)
|
|
435
518
|
if limit:
|
|
436
519
|
rows = rows[:limit]
|
|
437
|
-
headers = [
|
|
520
|
+
headers = [
|
|
521
|
+
"slug",
|
|
522
|
+
"outcome",
|
|
523
|
+
"token_id",
|
|
524
|
+
"price",
|
|
525
|
+
"size",
|
|
526
|
+
"volume",
|
|
527
|
+
"updated_at",
|
|
528
|
+
]
|
|
438
529
|
table = Table(title="Positions", show_lines=False, show_footer=True)
|
|
439
530
|
table.add_column("slug", style="cyan")
|
|
440
531
|
table.add_column("outcome", style="magenta")
|
|
@@ -471,9 +562,7 @@ class PositionWatcherService:
|
|
|
471
562
|
record_console.print(table)
|
|
472
563
|
return record_console.export_text()
|
|
473
564
|
|
|
474
|
-
def show_orders(
|
|
475
|
-
self, limit: int | None = None, max_width: int = 24
|
|
476
|
-
) -> str:
|
|
565
|
+
def show_orders(self, limit: int | None = None, max_width: int = 24) -> str:
|
|
477
566
|
"""
|
|
478
567
|
Pretty print current orders and return the rendered table.
|
|
479
568
|
"""
|
|
@@ -494,17 +583,19 @@ class PositionWatcherService:
|
|
|
494
583
|
if order.timestamp
|
|
495
584
|
else ""
|
|
496
585
|
)
|
|
497
|
-
rows.append(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
+
)
|
|
508
599
|
rows.sort(key=lambda r: abs(float(r["size_matched"])), reverse=True)
|
|
509
600
|
if limit:
|
|
510
601
|
rows = rows[:limit]
|
|
@@ -556,7 +647,7 @@ class PositionWatcherService:
|
|
|
556
647
|
return self.position_store.get_order_by_id(order_id)
|
|
557
648
|
|
|
558
649
|
def blocking_get_position(
|
|
559
|
-
|
|
650
|
+
self, token_id: str, timeout: float = None
|
|
560
651
|
) -> UserPosition | None:
|
|
561
652
|
"""
|
|
562
653
|
超时返回None;若无仓位则返回 size=0 的占位 UserPosition
|
|
@@ -568,7 +659,7 @@ class PositionWatcherService:
|
|
|
568
659
|
return self.get_position(token_id)
|
|
569
660
|
|
|
570
661
|
def blocking_get_order(
|
|
571
|
-
|
|
662
|
+
self, order_id: str, timeout: float = None
|
|
572
663
|
) -> OrderMessage | None:
|
|
573
664
|
"""
|
|
574
665
|
超时返回None(即返回 None,表示没有订单更新)
|
|
@@ -582,23 +673,29 @@ class PositionWatcherService:
|
|
|
582
673
|
# -------------------------------------------------------------------------
|
|
583
674
|
# HTTP Fallback Management (delegates to HttpFallbackManager)
|
|
584
675
|
# -------------------------------------------------------------------------
|
|
585
|
-
def add_http_listen(
|
|
676
|
+
def add_http_listen(
|
|
677
|
+
self, order_ids: list[str] = None, market_ids: list[str] = None
|
|
678
|
+
):
|
|
586
679
|
"""
|
|
587
680
|
Add markets/orders to HTTP fallback polling.
|
|
588
|
-
|
|
681
|
+
|
|
589
682
|
:param order_ids: List of order IDs to monitor
|
|
590
683
|
:param market_ids: List of market (condition) IDs to monitor
|
|
591
684
|
"""
|
|
592
685
|
if not self.enable_http_fallback or not self._http_fallback:
|
|
593
|
-
logger.warning(
|
|
686
|
+
logger.warning(
|
|
687
|
+
"HTTP fallback is not enabled. Enable it in __init__ to use this method."
|
|
688
|
+
)
|
|
594
689
|
return
|
|
595
690
|
|
|
596
691
|
self._http_fallback.add(market_ids=market_ids, order_ids=order_ids)
|
|
597
692
|
|
|
598
|
-
def remove_http_listen(
|
|
693
|
+
def remove_http_listen(
|
|
694
|
+
self, order_ids: list[str] = None, market_ids: list[str] = None
|
|
695
|
+
):
|
|
599
696
|
"""
|
|
600
697
|
Remove markets/orders from HTTP fallback polling.
|
|
601
|
-
|
|
698
|
+
|
|
602
699
|
:param order_ids: List of order IDs to remove
|
|
603
700
|
:param market_ids: List of market (condition) IDs to remove
|
|
604
701
|
"""
|
|
@@ -122,7 +122,9 @@ class OrderMessage(PrettyPrintBaseModel):
|
|
|
122
122
|
class UserPosition(PrettyPrintBaseModel):
|
|
123
123
|
price: float
|
|
124
124
|
size: float
|
|
125
|
+
original_size: float = 0.0
|
|
125
126
|
volume: float
|
|
127
|
+
fee_amount: float = 0.0
|
|
126
128
|
sellable_size: float = 0.0
|
|
127
129
|
token_id: Optional[str] = None
|
|
128
130
|
last_update: float
|
|
@@ -150,9 +152,11 @@ class PositionResult(PrettyPrintBaseModel):
|
|
|
150
152
|
"""仓位计算结果"""
|
|
151
153
|
|
|
152
154
|
size: float
|
|
155
|
+
original_size: float = 0.0
|
|
153
156
|
avg_price: float
|
|
154
157
|
realized_pnl: float
|
|
155
158
|
amount: float
|
|
159
|
+
fee_amount: float = 0.0
|
|
156
160
|
position_value: Optional[float] = None
|
|
157
161
|
unrealized_pnl: Optional[float] = None
|
|
158
162
|
total_pnl: Optional[float] = None
|
|
@@ -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
|
根据交易记录直接计算用户仓位(带浮点误差修正)
|
|
@@ -39,18 +63,29 @@ def calculate_position_from_trades(
|
|
|
39
63
|
|
|
40
64
|
buy_events = []
|
|
41
65
|
sell_events = []
|
|
66
|
+
total_original_size = 0.0
|
|
42
67
|
|
|
43
68
|
def apply_fee(
|
|
44
|
-
size: float,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
size: float,
|
|
70
|
+
price: float,
|
|
71
|
+
side: Side | str,
|
|
72
|
+
market_id: str | None,
|
|
73
|
+
trader_side: str | None,
|
|
74
|
+
) -> tuple[float, float]:
|
|
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":
|
|
84
|
+
return size, 0.0
|
|
52
85
|
calc = fee_calc_fn or _default_fee_calc
|
|
53
|
-
return calc(size, price,
|
|
86
|
+
return calc(size, price, side, fee_schedule)
|
|
87
|
+
|
|
88
|
+
total_fee_amount = 0.0
|
|
54
89
|
|
|
55
90
|
# --- 1. 解析所有交易 ---
|
|
56
91
|
for trade in trades:
|
|
@@ -61,24 +96,56 @@ def calculate_position_from_trades(
|
|
|
61
96
|
if order.maker_address.upper() != user_address.upper():
|
|
62
97
|
continue
|
|
63
98
|
is_maker_order = True
|
|
64
|
-
size = apply_fee(
|
|
65
|
-
order.size,
|
|
99
|
+
size, fee_amount = apply_fee(
|
|
100
|
+
order.size,
|
|
101
|
+
order.price,
|
|
102
|
+
order.side,
|
|
103
|
+
trade.market,
|
|
104
|
+
trade.trader_side,
|
|
66
105
|
)
|
|
106
|
+
total_fee_amount += fee_amount
|
|
67
107
|
|
|
68
108
|
if order.side == Side.BUY:
|
|
69
|
-
buy_events.append(
|
|
109
|
+
buy_events.append(
|
|
110
|
+
(size, order.price, trade.match_time, order.size * order.price)
|
|
111
|
+
)
|
|
112
|
+
total_original_size += order.size
|
|
70
113
|
else:
|
|
71
|
-
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
|
+
)
|
|
122
|
+
total_original_size -= order.size
|
|
72
123
|
|
|
73
124
|
# taker 部分
|
|
74
125
|
if not is_maker_order and trade.maker_address.upper() == user_address.upper():
|
|
75
|
-
size = apply_fee(
|
|
76
|
-
trade.size,
|
|
126
|
+
size, fee_amount = apply_fee(
|
|
127
|
+
trade.size,
|
|
128
|
+
trade.price,
|
|
129
|
+
trade.side,
|
|
130
|
+
trade.market,
|
|
131
|
+
trade.trader_side,
|
|
77
132
|
)
|
|
133
|
+
total_fee_amount += fee_amount
|
|
78
134
|
if trade.side == Side.BUY:
|
|
79
|
-
buy_events.append(
|
|
135
|
+
buy_events.append(
|
|
136
|
+
(size, trade.price, trade.match_time, trade.size * trade.price)
|
|
137
|
+
)
|
|
138
|
+
total_original_size += trade.size
|
|
80
139
|
else:
|
|
81
|
-
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
|
+
)
|
|
148
|
+
total_original_size -= trade.size
|
|
82
149
|
|
|
83
150
|
# --- 2. 时间排序 ---
|
|
84
151
|
all_events = sorted(buy_events + sell_events, key=lambda x: x[2])
|
|
@@ -87,51 +154,58 @@ def calculate_position_from_trades(
|
|
|
87
154
|
buy_queue = deque()
|
|
88
155
|
realized_pnl = 0.0
|
|
89
156
|
|
|
90
|
-
for size, price, _ in all_events:
|
|
157
|
+
for size, price, _, cash_amount in all_events:
|
|
91
158
|
size = clean(size) # ← 新增:修正
|
|
92
159
|
|
|
93
160
|
if size > 0:
|
|
94
161
|
# 买入加入队列
|
|
95
|
-
|
|
162
|
+
unit_cost = cash_amount / size if size else price
|
|
163
|
+
buy_queue.append([size, unit_cost])
|
|
96
164
|
|
|
97
165
|
else:
|
|
98
166
|
# 卖出(size 为负)
|
|
99
167
|
sell_size = clean(-size)
|
|
168
|
+
sell_price = cash_amount / sell_size if sell_size else price
|
|
100
169
|
|
|
101
170
|
while sell_size > EPS and buy_queue:
|
|
102
171
|
lot_size, lot_price = buy_queue[0]
|
|
103
172
|
|
|
104
173
|
if lot_size <= sell_size + EPS:
|
|
105
174
|
# 完全消耗
|
|
106
|
-
realized_pnl += (
|
|
175
|
+
realized_pnl += (sell_price - lot_price) * lot_size
|
|
107
176
|
sell_size -= lot_size
|
|
108
177
|
buy_queue.popleft()
|
|
109
178
|
else:
|
|
110
179
|
# 部分消耗
|
|
111
|
-
realized_pnl += (
|
|
180
|
+
realized_pnl += (sell_price - lot_price) * sell_size
|
|
112
181
|
buy_queue[0][0] = clean(lot_size - sell_size)
|
|
113
182
|
sell_size = 0.0
|
|
114
183
|
|
|
115
184
|
# 如果还有卖不完 → 变成空头
|
|
116
185
|
if sell_size > EPS:
|
|
117
|
-
buy_queue.appendleft([-sell_size,
|
|
186
|
+
buy_queue.appendleft([-sell_size, sell_price])
|
|
118
187
|
|
|
119
188
|
# --- 4. 计算最终持仓 ---
|
|
120
189
|
total_size = clean(sum(q[0] for q in buy_queue))
|
|
190
|
+
original_size = clean(total_original_size)
|
|
121
191
|
cost_basis = clean(sum(clean(q[0]) * q[1] for q in buy_queue))
|
|
122
192
|
|
|
123
193
|
# 若因误差产生 0.0000003 的 ghost position → 完全清掉
|
|
124
194
|
if abs(total_size) < EPS:
|
|
125
195
|
total_size = 0.0
|
|
126
196
|
cost_basis = 0.0
|
|
197
|
+
if abs(original_size) < EPS:
|
|
198
|
+
original_size = 0.0
|
|
127
199
|
|
|
128
200
|
avg_price = cost_basis / total_size if total_size != 0 else 0.0
|
|
129
201
|
|
|
130
202
|
return PositionResult(
|
|
131
203
|
size=total_size,
|
|
204
|
+
original_size=original_size,
|
|
132
205
|
avg_price=avg_price,
|
|
133
206
|
realized_pnl=realized_pnl,
|
|
134
207
|
amount=cost_basis,
|
|
208
|
+
fee_amount=total_fee_amount,
|
|
135
209
|
is_long=total_size > 0,
|
|
136
210
|
is_short=total_size < 0,
|
|
137
211
|
details=PositionDetails(
|
{poly_position_watcher-0.2.8 → 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,8 +31,9 @@ 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
|
|
35
|
-
-
|
|
34
|
+
- Optional fee calculation using market `feeSchedule`
|
|
35
|
+
- Position fields for fill checks:
|
|
36
|
+
`size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
|
|
36
37
|
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
|
|
37
38
|
|
|
38
39
|
**Note: WSS disconnects are auto-detected and reconnected.**
|
|
@@ -64,13 +65,21 @@ with PositionWatcherService(
|
|
|
64
65
|
enable_http_fallback=True, # Enable HTTP polling fallback
|
|
65
66
|
add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
|
|
66
67
|
enable_fee_calc=True, # Optional: enable fee adjustments
|
|
67
|
-
# fee_calc_fn=custom_fee_fn, # Optional: override fee formula
|
|
68
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
|
+
|
|
69
74
|
# Non-blocking: Get current positions and orders (returns immediately)
|
|
70
75
|
position: UserPosition = service.get_position("<token_id>")
|
|
71
76
|
order: OrderMessage = service.get_order("<order_id>")
|
|
72
77
|
print(position)
|
|
73
78
|
print(order)
|
|
79
|
+
if position:
|
|
80
|
+
print("size(post-fee):", position.size)
|
|
81
|
+
print("size(pre-fee):", position.original_size)
|
|
82
|
+
print("fee_amount:", position.fee_amount)
|
|
74
83
|
service.show_positions(limit=10)
|
|
75
84
|
service.show_orders(limit=10)
|
|
76
85
|
|
|
@@ -113,7 +122,9 @@ OrderMessage(
|
|
|
113
122
|
UserPosition(
|
|
114
123
|
price: 0.0,
|
|
115
124
|
size: 0.0,
|
|
125
|
+
original_size: 0.0,
|
|
116
126
|
volume: 0.0,
|
|
127
|
+
fee_amount: 0.0,
|
|
117
128
|
sellable_size: 0.0,
|
|
118
129
|
token_id: '',
|
|
119
130
|
last_update: 0.0,
|
|
@@ -140,14 +151,20 @@ service.show_orders(limit=10)
|
|
|
140
151
|
## **⚠️ Fee notice (taker fee / maker rebate)**
|
|
141
152
|
---
|
|
142
153
|
|
|
143
|
-
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:
|
|
144
155
|
|
|
145
|
-
- Enable with `enable_fee_calc=True`
|
|
146
|
-
-
|
|
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`
|
|
147
159
|
- Disable (default) if you prefer pre-fee positions
|
|
160
|
+
- Returned position fields:
|
|
161
|
+
`size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
|
|
148
162
|
|
|
149
163
|
Default fee formula (when `fee_calc_fn` is not provided):
|
|
150
|
-
`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.
|
|
151
168
|
|
|
152
169
|
---
|
|
153
170
|
|
|
@@ -173,8 +190,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
|
|
|
173
190
|
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
|
|
174
191
|
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
|
|
175
192
|
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
|
|
176
|
-
| `enable_fee_calc` | bool | False | Apply fee adjustments using `
|
|
177
|
-
| `
|
|
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)` |
|
|
178
196
|
|
|
179
197
|
### Environment Variables
|
|
180
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.8 → poly_position_watcher-0.3.0}/poly_position_watcher/__init__.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/api_worker.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/__init__.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/enums.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/logger.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/__init__.py
RENAMED
|
File without changes
|
{poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/base.py
RENAMED
|
File without changes
|
|
File without changes
|
{poly_position_watcher-0.2.8 → 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
|