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.
Files changed (26) hide show
  1. {poly_position_watcher-0.2.8/poly_position_watcher.egg-info → poly_position_watcher-0.3.0}/PKG-INFO +28 -10
  2. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/README.md +27 -9
  3. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/_version.py +1 -1
  4. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/position_service.py +163 -66
  5. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/position_model.py +4 -0
  6. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/trade_calculator.py +102 -28
  7. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0/poly_position_watcher.egg-info}/PKG-INFO +28 -10
  8. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/SOURCES.txt +2 -1
  9. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/pyproject.toml +1 -1
  10. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/setup.py +1 -1
  11. poly_position_watcher-0.3.0/tests/test_trade_calculator.py +217 -0
  12. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/LICENSE +0 -0
  13. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/MANIFEST.in +0 -0
  14. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/__init__.py +0 -0
  15. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/api_worker.py +0 -0
  16. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/__init__.py +0 -0
  17. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/enums.py +0 -0
  18. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/common/logger.py +0 -0
  19. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/__init__.py +0 -0
  20. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/base.py +0 -0
  21. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/common_model.py +0 -0
  22. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher/wss_worker.py +0 -0
  23. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/dependency_links.txt +0 -0
  24. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/requires.txt +0 -0
  25. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/top_level.txt +0 -0
  26. {poly_position_watcher-0.2.8 → poly_position_watcher-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poly-position-watcher
3
- Version: 0.2.8
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 (toggle + custom formula)
35
- - Two sizes exposed: `size` for CLOB matched trades, `sellable_size` for on-chain confirmed trades
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 **fully supports fee calculation** and lets you control it:
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` to apply fees using `feeRateBps` from trades/orders
146
- - Customize with `fee_calc_fn` if you need a different formula
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 = 0.25 * (p * (1 - p)) ** 2 * (fee_rate_bps / 1000)`, and `new_size = (1 - fee) * size`.
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 `feeRateBps` from trades/orders |
177
- | `fee_calc_fn` | callable | None | Custom fee function: `(size, price, fee_rate_bps) -> new_size` |
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 (toggle + custom formula)
16
- - Two sizes exposed: `size` for CLOB matched trades, `sellable_size` for on-chain confirmed trades
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 **fully supports fee calculation** and lets you control it:
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` to apply fees using `feeRateBps` from trades/orders
127
- - Customize with `fee_calc_fn` if you need a different formula
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 = 0.25 * (p * (1 - p)) ** 2 * (fee_rate_bps / 1000)`, and `new_size = (1 - fee) * size`.
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 `feeRateBps` from trades/orders |
158
- | `fee_calc_fn` | callable | None | Custom fee function: `(size, price, fee_rate_bps) -> new_size` |
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
 
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.3.0"
@@ -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
- self,
40
- user_address: str,
41
- enable_fee_calc: bool = False,
42
- fee_calc_fn: Callable[[float, float, float], float] | None = None,
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
- existing
130
- and order.size_matched <= existing.size_matched
131
- and order.status == existing.status
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
- self, trades: list[TradeMessage], token_id, outcome: str
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(f"found error trades, total size: {filled_size}: {filled_trades}")
154
- market_slug = next((trade.market_slug for trade in trades if trade.market_slug), "")
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
- self, token_id: str, timeout: float = None
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
- self, order_id: str, timeout: float = None
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
- self,
235
- client,
236
- ws_idle_timeout=60 * 60,
237
- wss_proxies: dict | None = None,
238
- init_positions: bool = False,
239
- enable_http_fallback: bool = False,
240
- http_poll_interval: float = 1.5,
241
- add_init_positions_to_http: bool = False,
242
- enable_fee_calc: bool = False,
243
- fee_calc_fn: Callable[[float, float, float], float] | None = None,
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 fee_calc_fn: Optional custom fee function (size, price, fee_rate_bps) -> new_size
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(self.user_address)
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 = '\n'.join([
311
- f"slug={pos.market_slug}, price={pos.price}, size={pos.size:.4f},"
312
- f"volume={pos.volume:.4f}, token_id={pos.token_id}, outcome={pos.outcome}"
313
- for pos in self.position_store.positions.values()
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(f"Added {len(init_condition_ids)} condition_ids from init_positions to HTTP monitoring")
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
- "slug": pos.market_slug or "",
426
- "outcome": pos.outcome or "",
427
- "token_id": pos.token_id or "",
428
- "price": f"{pos.price:.3f}",
429
- "size": f"{pos.size:.3f}",
430
- "volume": f"{pos.volume:.4f}",
431
- "updated_at": last_update,
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 = ["slug", "outcome", "token_id", "price", "size", "volume", "updated_at"]
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
- "slug": order.market_slug or "",
499
- "outcome": order.outcome or "",
500
- "order_id": order.id or "",
501
- "side": order.side or "",
502
- "price": f"{order.price:.3f}",
503
- "size_matched": f"{order.size_matched:.4f}",
504
- "original_size": f"{order.original_size or 0.0:.4f}",
505
- "status": order.status or "",
506
- "created_at": created_at,
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
- self, token_id: str, timeout: float = None
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
- self, order_id: str, timeout: float = None
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(self, order_ids: list[str] = None, market_ids: list[str] = None):
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("HTTP fallback is not enabled. Enable it in __init__ to use this method.")
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(self, order_ids: list[str] = None, market_ids: list[str] = None):
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(size: float, price: float, fee_rate_bps: float) -> float:
19
- fee_multiplier = fee_rate_bps / 1000 if fee_rate_bps else 0.0
20
- fee = 0.25 * (price * (1 - price)) ** 2 * fee_multiplier
21
- return (1 - fee) * size
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
- fee_calc_fn: Callable[[float, float, float], float] | None = None,
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, price: float, fee_rate_bps: float, trader_side: str | None
45
- ) -> float:
46
- if (
47
- not enable_fee_calc
48
- or fee_rate_bps <= 0
49
- or trader_side != "TAKER"
50
- ):
51
- return size
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, fee_rate_bps)
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, order.price, order.fee_rate_bps, trade.trader_side
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((size, order.price, trade.match_time))
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((-size, order.price, trade.match_time))
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, trade.price, trade.fee_rate_bps, trade.trader_side
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((size, trade.price, trade.match_time))
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((-size, trade.price, trade.match_time))
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
- buy_queue.append([size, price])
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 += (price - lot_price) * lot_size
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 += (price - lot_price) * sell_size
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, price])
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poly-position-watcher
3
- Version: 0.2.8
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 (toggle + custom formula)
35
- - Two sizes exposed: `size` for CLOB matched trades, `sellable_size` for on-chain confirmed trades
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 **fully supports fee calculation** and lets you control it:
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` to apply fees using `feeRateBps` from trades/orders
146
- - Customize with `fee_calc_fn` if you need a different formula
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 = 0.25 * (p * (1 - p)) ** 2 * (fee_rate_bps / 1000)`, and `new_size = (1 - fee) * size`.
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 `feeRateBps` from trades/orders |
177
- | `fee_calc_fn` | callable | None | Custom fee function: `(size, price, fee_rate_bps) -> new_size` |
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "poly-position-watcher"
3
- version = "0.2.8"
3
+ version = "0.3.0"
4
4
  description = "polymarket proxy wallet redeem"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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.2.8"),
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()