poly-position-watcher 0.2.9__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {poly_position_watcher-0.2.9/poly_position_watcher.egg-info → poly_position_watcher-0.3.0}/PKG-INFO +18 -9
  2. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/README.md +17 -8
  3. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/_version.py +1 -1
  4. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/position_service.py +161 -66
  5. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/trade_calculator.py +84 -26
  6. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0/poly_position_watcher.egg-info}/PKG-INFO +18 -9
  7. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/SOURCES.txt +2 -1
  8. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/pyproject.toml +1 -1
  9. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/setup.py +1 -1
  10. poly_position_watcher-0.3.0/tests/test_trade_calculator.py +217 -0
  11. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/LICENSE +0 -0
  12. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/MANIFEST.in +0 -0
  13. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/__init__.py +0 -0
  14. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/api_worker.py +0 -0
  15. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/__init__.py +0 -0
  16. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/enums.py +0 -0
  17. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/common/logger.py +0 -0
  18. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/__init__.py +0 -0
  19. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/base.py +0 -0
  20. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/common_model.py +0 -0
  21. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/schema/position_model.py +0 -0
  22. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher/wss_worker.py +0 -0
  23. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/dependency_links.txt +0 -0
  24. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/requires.txt +0 -0
  25. {poly_position_watcher-0.2.9 → poly_position_watcher-0.3.0}/poly_position_watcher.egg-info/top_level.txt +0 -0
  26. {poly_position_watcher-0.2.9 → 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.9
3
+ Version: 0.3.0
4
4
  Summary: polymarket proxy wallet redeem
5
5
  Home-page: https://github.com/tosmart01/polymarket-position-watcher
6
6
  Author: pinbar
@@ -31,7 +31,7 @@ Dynamic: requires-python
31
31
 
32
32
  - WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
33
33
  - HTTP polling fallback for reliability
34
- - Optional fee calculation (toggle + custom formula)
34
+ - Optional fee calculation using market `feeSchedule`
35
35
  - Position fields for fill checks:
36
36
  `size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
37
37
  - Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
@@ -65,8 +65,12 @@ with PositionWatcherService(
65
65
  enable_http_fallback=True, # Enable HTTP polling fallback
66
66
  add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
67
67
  enable_fee_calc=True, # Optional: enable fee adjustments
68
- # fee_calc_fn=custom_fee_fn, # Optional: override fee formula
69
68
  ) as service:
69
+ service.set_market_fee_schedule(
70
+ "<condition_id>",
71
+ {"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
72
+ )
73
+
70
74
  # Non-blocking: Get current positions and orders (returns immediately)
71
75
  position: UserPosition = service.get_position("<token_id>")
72
76
  order: OrderMessage = service.get_order("<order_id>")
@@ -147,16 +151,20 @@ service.show_orders(limit=10)
147
151
  ## **⚠️ Fee notice (taker fee / maker rebate)**
148
152
  ---
149
153
 
150
- Some Polymarket markets enable taker fee / maker rebate. This library **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:
151
155
 
152
- - Enable with `enable_fee_calc=True` to apply fees using `feeRateBps` from trades/orders
153
- - 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`
154
159
  - Disable (default) if you prefer pre-fee positions
155
160
  - Returned position fields:
156
161
  `size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
157
162
 
158
163
  Default fee formula (when `fee_calc_fn` is not provided):
159
- `fee = 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.
160
168
 
161
169
  ---
162
170
 
@@ -182,8 +190,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
182
190
  | `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
183
191
  | `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
184
192
  | `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
185
- | `enable_fee_calc` | bool | False | Apply fee adjustments using `feeRateBps` from trades/orders |
186
- | `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)` |
187
196
 
188
197
  ### Environment Variables
189
198
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  - WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
14
14
  - HTTP polling fallback for reliability
15
- - Optional fee calculation (toggle + custom formula)
15
+ - Optional fee calculation using market `feeSchedule`
16
16
  - Position fields for fill checks:
17
17
  `size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
18
18
  - Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
@@ -46,8 +46,12 @@ with PositionWatcherService(
46
46
  enable_http_fallback=True, # Enable HTTP polling fallback
47
47
  add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
48
48
  enable_fee_calc=True, # Optional: enable fee adjustments
49
- # fee_calc_fn=custom_fee_fn, # Optional: override fee formula
50
49
  ) as service:
50
+ service.set_market_fee_schedule(
51
+ "<condition_id>",
52
+ {"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
53
+ )
54
+
51
55
  # Non-blocking: Get current positions and orders (returns immediately)
52
56
  position: UserPosition = service.get_position("<token_id>")
53
57
  order: OrderMessage = service.get_order("<order_id>")
@@ -128,16 +132,20 @@ service.show_orders(limit=10)
128
132
  ## **⚠️ Fee notice (taker fee / maker rebate)**
129
133
  ---
130
134
 
131
- Some Polymarket markets enable taker fee / maker rebate. This library **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:
132
136
 
133
- - Enable with `enable_fee_calc=True` to apply fees using `feeRateBps` from trades/orders
134
- - 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`
135
140
  - Disable (default) if you prefer pre-fee positions
136
141
  - Returned position fields:
137
142
  `size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
138
143
 
139
144
  Default fee formula (when `fee_calc_fn` is not provided):
140
- `fee = 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.
141
149
 
142
150
  ---
143
151
 
@@ -163,8 +171,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
163
171
  | `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
164
172
  | `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
165
173
  | `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
166
- | `enable_fee_calc` | bool | False | Apply fee adjustments using `feeRateBps` from trades/orders |
167
- | `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)` |
168
177
 
169
178
  ### Environment Variables
170
179
 
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.2.9"
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,6 +222,7 @@ class PositionStore:
164
222
  confirmed_trades,
165
223
  user_address=self.user_address,
166
224
  enable_fee_calc=self.enable_fee_calc,
225
+ fee_schedule_by_market=self.market_fee_schedules,
167
226
  fee_calc_fn=self.fee_calc_fn,
168
227
  )
169
228
  sellable_size = confirmed_result.size
@@ -204,12 +263,12 @@ class PositionStore:
204
263
  return self.orders.get(order_id)
205
264
 
206
265
  def blocking_get_token_position(
207
- self, token_id: str, timeout: float = None
266
+ self, token_id: str, timeout: float = None
208
267
  ) -> UserPosition:
209
268
  return self._get(token_id, timeout)
210
269
 
211
270
  def blocking_get_order_by_id(
212
- self, order_id: str, timeout: float = None
271
+ self, order_id: str, timeout: float = None
213
272
  ) -> OrderMessage:
214
273
  return self._get(order_id, timeout)
215
274
 
@@ -233,16 +292,20 @@ class PositionWatcherService:
233
292
  """
234
293
 
235
294
  def __init__(
236
- self,
237
- client,
238
- ws_idle_timeout=60 * 60,
239
- wss_proxies: dict | None = None,
240
- init_positions: bool = False,
241
- enable_http_fallback: bool = False,
242
- http_poll_interval: float = 1.5,
243
- add_init_positions_to_http: bool = False,
244
- enable_fee_calc: bool = False,
245
- 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,
246
309
  ):
247
310
  """
248
311
  :param client: ClobClient instance
@@ -253,8 +316,9 @@ class PositionWatcherService:
253
316
  :param http_poll_interval: HTTP polling interval in seconds
254
317
  :param add_init_positions_to_http: Whether to add condition_ids from init_positions to HTTP monitoring
255
318
  :param enable_fee_calc: Whether to apply fee adjustments in position calc
256
- :param fee_calc_fn: Optional custom fee function (size, price, fee_rate_bps) -> new_size
257
-
319
+ :param market_fee_schedules: Optional mapping of condition_id -> feeSchedule
320
+ :param fee_calc_fn: Optional custom fee function (size, price, side, fee_schedule) -> (new_size, fee_amount)
321
+
258
322
  wss_proxies example: {
259
323
  "http_proxy_host": "127.0.0.1",
260
324
  "http_proxy_port": 8118,
@@ -266,6 +330,7 @@ class PositionWatcherService:
266
330
  self.position_store = PositionStore(
267
331
  self.user_address,
268
332
  enable_fee_calc=enable_fee_calc,
333
+ market_fee_schedules=market_fee_schedules,
269
334
  fee_calc_fn=fee_calc_fn,
270
335
  )
271
336
  self._wss_proxies = wss_proxies or {}
@@ -305,15 +370,19 @@ class PositionWatcherService:
305
370
  init_condition_ids = []
306
371
  if self.init_positions:
307
372
  try:
308
- initialize_trades = self.api_worker.fetch_trades_from_positions(self.user_address)
373
+ initialize_trades = self.api_worker.fetch_trades_from_positions(
374
+ self.user_address
375
+ )
309
376
  if initialize_trades:
310
377
  for token_id, trades in initialize_trades.items():
311
378
  self.position_store.init_trades(trades)
312
- positions_info = '\n'.join([
313
- f"slug={pos.market_slug}, price={pos.price}, size={pos.size:.4f},"
314
- f"volume={pos.volume:.4f}, token_id={pos.token_id}, outcome={pos.outcome}"
315
- for pos in self.position_store.positions.values()
316
- ])
379
+ positions_info = "\n".join(
380
+ [
381
+ f"slug={pos.market_slug}, price={pos.price}, size={pos.size:.4f},"
382
+ f"volume={pos.volume:.4f}, token_id={pos.token_id}, outcome={pos.outcome}"
383
+ for pos in self.position_store.positions.values()
384
+ ]
385
+ )
317
386
  logger.info(f"Initialized positions:\n{positions_info}")
318
387
  except Exception as e:
319
388
  logger.error(f"Failed to initialize positions: {e}")
@@ -328,7 +397,9 @@ class PositionWatcherService:
328
397
  # Add init positions' condition_ids to HTTP monitoring if requested
329
398
  if self.add_init_positions_to_http and init_condition_ids:
330
399
  self._http_fallback.add(market_ids=init_condition_ids)
331
- logger.info(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
+ )
332
403
 
333
404
  return self
334
405
 
@@ -393,6 +464,16 @@ class PositionWatcherService:
393
464
  return position
394
465
  return UserPosition(token_id=token_id, price=0, size=0, volume=0, last_update=0)
395
466
 
467
+ def set_market_fee_schedule(
468
+ self, condition_id: str, fee_schedule: Mapping[str, Any] | None
469
+ ) -> None:
470
+ self.position_store.set_market_fee_schedule(condition_id, fee_schedule)
471
+
472
+ def set_market_fee_schedules(
473
+ self, fee_schedule_map: Mapping[str, Mapping[str, Any] | None]
474
+ ) -> None:
475
+ self.position_store.set_market_fee_schedules(fee_schedule_map)
476
+
396
477
  @staticmethod
397
478
  def _truncate(value: str, limit: int) -> str:
398
479
  if len(value) <= limit:
@@ -402,9 +483,7 @@ class PositionWatcherService:
402
483
  tail = keep - head
403
484
  return f"{value[:head]}...{value[-tail:]}" if keep else "..."
404
485
 
405
- def show_positions(
406
- self, limit: int | None = None, max_width: int = 24
407
- ) -> str:
486
+ def show_positions(self, limit: int | None = None, max_width: int = 24) -> str:
408
487
  """
409
488
  Pretty print current positions and return the rendered table.
410
489
  """
@@ -423,20 +502,30 @@ class PositionWatcherService:
423
502
  if pos.last_update
424
503
  else ""
425
504
  )
426
- rows.append({
427
- "slug": pos.market_slug or "",
428
- "outcome": pos.outcome or "",
429
- "token_id": pos.token_id or "",
430
- "price": f"{pos.price:.3f}",
431
- "size": f"{pos.size:.3f}",
432
- "volume": f"{pos.volume:.4f}",
433
- "updated_at": last_update,
434
- })
505
+ rows.append(
506
+ {
507
+ "slug": pos.market_slug or "",
508
+ "outcome": pos.outcome or "",
509
+ "token_id": pos.token_id or "",
510
+ "price": f"{pos.price:.3f}",
511
+ "size": f"{pos.size:.3f}",
512
+ "volume": f"{pos.volume:.4f}",
513
+ "updated_at": last_update,
514
+ }
515
+ )
435
516
 
436
517
  rows.sort(key=lambda r: abs(float(r["volume"])), reverse=True)
437
518
  if limit:
438
519
  rows = rows[:limit]
439
- headers = ["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
+ ]
440
529
  table = Table(title="Positions", show_lines=False, show_footer=True)
441
530
  table.add_column("slug", style="cyan")
442
531
  table.add_column("outcome", style="magenta")
@@ -473,9 +562,7 @@ class PositionWatcherService:
473
562
  record_console.print(table)
474
563
  return record_console.export_text()
475
564
 
476
- def show_orders(
477
- self, limit: int | None = None, max_width: int = 24
478
- ) -> str:
565
+ def show_orders(self, limit: int | None = None, max_width: int = 24) -> str:
479
566
  """
480
567
  Pretty print current orders and return the rendered table.
481
568
  """
@@ -496,17 +583,19 @@ class PositionWatcherService:
496
583
  if order.timestamp
497
584
  else ""
498
585
  )
499
- rows.append({
500
- "slug": order.market_slug or "",
501
- "outcome": order.outcome or "",
502
- "order_id": order.id or "",
503
- "side": order.side or "",
504
- "price": f"{order.price:.3f}",
505
- "size_matched": f"{order.size_matched:.4f}",
506
- "original_size": f"{order.original_size or 0.0:.4f}",
507
- "status": order.status or "",
508
- "created_at": created_at,
509
- })
586
+ rows.append(
587
+ {
588
+ "slug": order.market_slug or "",
589
+ "outcome": order.outcome or "",
590
+ "order_id": order.id or "",
591
+ "side": order.side or "",
592
+ "price": f"{order.price:.3f}",
593
+ "size_matched": f"{order.size_matched:.4f}",
594
+ "original_size": f"{order.original_size or 0.0:.4f}",
595
+ "status": order.status or "",
596
+ "created_at": created_at,
597
+ }
598
+ )
510
599
  rows.sort(key=lambda r: abs(float(r["size_matched"])), reverse=True)
511
600
  if limit:
512
601
  rows = rows[:limit]
@@ -558,7 +647,7 @@ class PositionWatcherService:
558
647
  return self.position_store.get_order_by_id(order_id)
559
648
 
560
649
  def blocking_get_position(
561
- self, token_id: str, timeout: float = None
650
+ self, token_id: str, timeout: float = None
562
651
  ) -> UserPosition | None:
563
652
  """
564
653
  超时返回None;若无仓位则返回 size=0 的占位 UserPosition
@@ -570,7 +659,7 @@ class PositionWatcherService:
570
659
  return self.get_position(token_id)
571
660
 
572
661
  def blocking_get_order(
573
- self, order_id: str, timeout: float = None
662
+ self, order_id: str, timeout: float = None
574
663
  ) -> OrderMessage | None:
575
664
  """
576
665
  超时返回None(即返回 None,表示没有订单更新)
@@ -584,23 +673,29 @@ class PositionWatcherService:
584
673
  # -------------------------------------------------------------------------
585
674
  # HTTP Fallback Management (delegates to HttpFallbackManager)
586
675
  # -------------------------------------------------------------------------
587
- def add_http_listen(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
+ ):
588
679
  """
589
680
  Add markets/orders to HTTP fallback polling.
590
-
681
+
591
682
  :param order_ids: List of order IDs to monitor
592
683
  :param market_ids: List of market (condition) IDs to monitor
593
684
  """
594
685
  if not self.enable_http_fallback or not self._http_fallback:
595
- logger.warning("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
+ )
596
689
  return
597
690
 
598
691
  self._http_fallback.add(market_ids=market_ids, order_ids=order_ids)
599
692
 
600
- def remove_http_listen(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
+ ):
601
696
  """
602
697
  Remove markets/orders from HTTP fallback polling.
603
-
698
+
604
699
  :param order_ids: List of order IDs to remove
605
700
  :param market_ids: List of market (condition) IDs to remove
606
701
  """
@@ -5,7 +5,7 @@
5
5
  # @File: trade_calculator.py
6
6
  # @Software: PyCharm
7
7
  from collections import deque
8
- from typing import Callable, List
8
+ from typing import Any, Callable, List, Mapping
9
9
 
10
10
  from poly_position_watcher.common.enums import Side
11
11
  from poly_position_watcher.schema.position_model import (
@@ -15,17 +15,41 @@ from poly_position_watcher.schema.position_model import (
15
15
  )
16
16
 
17
17
 
18
- def _default_fee_calc(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
  根据交易记录直接计算用户仓位(带浮点误差修正)
@@ -42,18 +66,24 @@ def calculate_position_from_trades(
42
66
  total_original_size = 0.0
43
67
 
44
68
  def apply_fee(
45
- size: float, price: float, fee_rate_bps: float, trader_side: str | None
69
+ size: float,
70
+ price: float,
71
+ side: Side | str,
72
+ market_id: str | None,
73
+ trader_side: str | None,
46
74
  ) -> tuple[float, float]:
47
- if (
48
- not enable_fee_calc
49
- or fee_rate_bps <= 0
50
- or trader_side != "TAKER"
51
- ):
75
+ fee_schedule = (
76
+ fee_schedule_by_market.get(market_id or "")
77
+ if fee_schedule_by_market
78
+ else None
79
+ )
80
+ if not enable_fee_calc or not fee_schedule:
81
+ return size, 0.0
82
+ taker_only = bool(fee_schedule.get("takerOnly", False))
83
+ if taker_only and trader_side != "TAKER":
52
84
  return size, 0.0
53
85
  calc = fee_calc_fn or _default_fee_calc
54
- size_after_fee = calc(size, price, fee_rate_bps)
55
- fee_amount = (size - size_after_fee) * price
56
- return size_after_fee, fee_amount
86
+ return calc(size, price, side, fee_schedule)
57
87
 
58
88
  total_fee_amount = 0.0
59
89
 
@@ -67,28 +97,54 @@ def calculate_position_from_trades(
67
97
  continue
68
98
  is_maker_order = True
69
99
  size, fee_amount = apply_fee(
70
- order.size, order.price, order.fee_rate_bps, trade.trader_side
100
+ order.size,
101
+ order.price,
102
+ order.side,
103
+ trade.market,
104
+ trade.trader_side,
71
105
  )
72
106
  total_fee_amount += fee_amount
73
107
 
74
108
  if order.side == Side.BUY:
75
- buy_events.append((size, order.price, trade.match_time))
109
+ buy_events.append(
110
+ (size, order.price, trade.match_time, order.size * order.price)
111
+ )
76
112
  total_original_size += order.size
77
113
  else:
78
- sell_events.append((-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
+ )
79
122
  total_original_size -= order.size
80
123
 
81
124
  # taker 部分
82
125
  if not is_maker_order and trade.maker_address.upper() == user_address.upper():
83
126
  size, fee_amount = apply_fee(
84
- trade.size, trade.price, trade.fee_rate_bps, trade.trader_side
127
+ trade.size,
128
+ trade.price,
129
+ trade.side,
130
+ trade.market,
131
+ trade.trader_side,
85
132
  )
86
133
  total_fee_amount += fee_amount
87
134
  if trade.side == Side.BUY:
88
- buy_events.append((size, trade.price, trade.match_time))
135
+ buy_events.append(
136
+ (size, trade.price, trade.match_time, trade.size * trade.price)
137
+ )
89
138
  total_original_size += trade.size
90
139
  else:
91
- sell_events.append((-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
+ )
92
148
  total_original_size -= trade.size
93
149
 
94
150
  # --- 2. 时间排序 ---
@@ -98,34 +154,36 @@ def calculate_position_from_trades(
98
154
  buy_queue = deque()
99
155
  realized_pnl = 0.0
100
156
 
101
- for size, price, _ in all_events:
157
+ for size, price, _, cash_amount in all_events:
102
158
  size = clean(size) # ← 新增:修正
103
159
 
104
160
  if size > 0:
105
161
  # 买入加入队列
106
- buy_queue.append([size, price])
162
+ unit_cost = cash_amount / size if size else price
163
+ buy_queue.append([size, unit_cost])
107
164
 
108
165
  else:
109
166
  # 卖出(size 为负)
110
167
  sell_size = clean(-size)
168
+ sell_price = cash_amount / sell_size if sell_size else price
111
169
 
112
170
  while sell_size > EPS and buy_queue:
113
171
  lot_size, lot_price = buy_queue[0]
114
172
 
115
173
  if lot_size <= sell_size + EPS:
116
174
  # 完全消耗
117
- realized_pnl += (price - lot_price) * lot_size
175
+ realized_pnl += (sell_price - lot_price) * lot_size
118
176
  sell_size -= lot_size
119
177
  buy_queue.popleft()
120
178
  else:
121
179
  # 部分消耗
122
- realized_pnl += (price - lot_price) * sell_size
180
+ realized_pnl += (sell_price - lot_price) * sell_size
123
181
  buy_queue[0][0] = clean(lot_size - sell_size)
124
182
  sell_size = 0.0
125
183
 
126
184
  # 如果还有卖不完 → 变成空头
127
185
  if sell_size > EPS:
128
- buy_queue.appendleft([-sell_size, price])
186
+ buy_queue.appendleft([-sell_size, sell_price])
129
187
 
130
188
  # --- 4. 计算最终持仓 ---
131
189
  total_size = clean(sum(q[0] for q in buy_queue))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poly-position-watcher
3
- Version: 0.2.9
3
+ Version: 0.3.0
4
4
  Summary: polymarket proxy wallet redeem
5
5
  Home-page: https://github.com/tosmart01/polymarket-position-watcher
6
6
  Author: pinbar
@@ -31,7 +31,7 @@ Dynamic: requires-python
31
31
 
32
32
  - WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
33
33
  - HTTP polling fallback for reliability
34
- - Optional fee calculation (toggle + custom formula)
34
+ - Optional fee calculation using market `feeSchedule`
35
35
  - Position fields for fill checks:
36
36
  `size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
37
37
  - Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
@@ -65,8 +65,12 @@ with PositionWatcherService(
65
65
  enable_http_fallback=True, # Enable HTTP polling fallback
66
66
  add_init_positions_to_http=True, # Auto-add condition_ids from init positions to HTTP monitoring
67
67
  enable_fee_calc=True, # Optional: enable fee adjustments
68
- # fee_calc_fn=custom_fee_fn, # Optional: override fee formula
69
68
  ) as service:
69
+ service.set_market_fee_schedule(
70
+ "<condition_id>",
71
+ {"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
72
+ )
73
+
70
74
  # Non-blocking: Get current positions and orders (returns immediately)
71
75
  position: UserPosition = service.get_position("<token_id>")
72
76
  order: OrderMessage = service.get_order("<order_id>")
@@ -147,16 +151,20 @@ service.show_orders(limit=10)
147
151
  ## **⚠️ Fee notice (taker fee / maker rebate)**
148
152
  ---
149
153
 
150
- Some Polymarket markets enable taker fee / maker rebate. This library **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:
151
155
 
152
- - Enable with `enable_fee_calc=True` to apply fees using `feeRateBps` from trades/orders
153
- - 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`
154
159
  - Disable (default) if you prefer pre-fee positions
155
160
  - Returned position fields:
156
161
  `size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount
157
162
 
158
163
  Default fee formula (when `fee_calc_fn` is not provided):
159
- `fee = 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.
160
168
 
161
169
  ---
162
170
 
@@ -182,8 +190,9 @@ The HTTP fallback polling threads run persistently throughout the `with` stateme
182
190
  | `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
183
191
  | `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
184
192
  | `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
185
- | `enable_fee_calc` | bool | False | Apply fee adjustments using `feeRateBps` from trades/orders |
186
- | `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)` |
187
196
 
188
197
  ### Environment Variables
189
198
 
@@ -20,4 +20,5 @@ poly_position_watcher/common/logger.py
20
20
  poly_position_watcher/schema/__init__.py
21
21
  poly_position_watcher/schema/base.py
22
22
  poly_position_watcher/schema/common_model.py
23
- poly_position_watcher/schema/position_model.py
23
+ poly_position_watcher/schema/position_model.py
24
+ tests/test_trade_calculator.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "poly-position-watcher"
3
- version = "0.2.9"
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.9"),
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()