poly-position-watcher 0.3.4__tar.gz → 0.3.6__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 (29) hide show
  1. {poly_position_watcher-0.3.4/poly_position_watcher.egg-info → poly_position_watcher-0.3.6}/PKG-INFO +15 -1
  2. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/README.md +14 -0
  3. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/_version.py +1 -1
  4. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/api_worker.py +166 -60
  5. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/position_service.py +216 -38
  6. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6/poly_position_watcher.egg-info}/PKG-INFO +15 -1
  7. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher.egg-info/SOURCES.txt +1 -0
  8. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/pyproject.toml +1 -1
  9. poly_position_watcher-0.3.6/tests/test_http_fallback_manager.py +69 -0
  10. poly_position_watcher-0.3.6/tests/test_position_model.py +201 -0
  11. poly_position_watcher-0.3.4/tests/test_position_model.py +0 -78
  12. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/LICENSE +0 -0
  13. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/MANIFEST.in +0 -0
  14. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/__init__.py +0 -0
  15. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/common/__init__.py +0 -0
  16. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/common/enums.py +0 -0
  17. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/common/logger.py +0 -0
  18. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/schema/__init__.py +0 -0
  19. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/schema/base.py +0 -0
  20. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/schema/common_model.py +0 -0
  21. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/schema/position_model.py +0 -0
  22. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/trade_calculator.py +0 -0
  23. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher/wss_worker.py +0 -0
  24. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher.egg-info/dependency_links.txt +0 -0
  25. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher.egg-info/requires.txt +0 -0
  26. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/poly_position_watcher.egg-info/top_level.txt +0 -0
  27. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/setup.cfg +0 -0
  28. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/setup.py +0 -0
  29. {poly_position_watcher-0.3.4 → poly_position_watcher-0.3.6}/tests/test_trade_calculator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poly-position-watcher
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: polymarket proxy wallet redeem
5
5
  Home-page: https://github.com/tosmart01/polymarket-position-watcher
6
6
  Author: pinbar
@@ -73,8 +73,14 @@ with PositionWatcherService(
73
73
 
74
74
  # Non-blocking: Get current positions and orders (returns immediately)
75
75
  position: UserPosition = service.get_position("<token_id>")
76
+ strategy_position: UserPosition | None = service.get_position_by_order_ids(["<order_id>"])
77
+ strategy_positions: dict[str, UserPosition] = service.get_positions_by_order_ids(
78
+ ["<order_id_1>", "<order_id_2>"]
79
+ )
76
80
  order: OrderMessage = service.get_order("<order_id>")
77
81
  print(position)
82
+ print(strategy_position)
83
+ print(strategy_positions)
78
84
  print(order)
79
85
  if position:
80
86
  print("size(post-fee):", position.size)
@@ -91,6 +97,12 @@ with PositionWatcherService(
91
97
 
92
98
  # Optional: If you open new positions/orders and want to monitor them via HTTP fallback
93
99
  # service.add_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
100
+ # service.set_http_listen(
101
+ # market_ids=["<condition_id>"],
102
+ # order_ids=["<order_id>"],
103
+ # group="strategy-a",
104
+ # )
105
+ # service.clear_http(group="strategy-a")
94
106
  # service.remove_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
95
107
  # service.clear_http() # Clear all monitoring items, threads continue running
96
108
  ```
@@ -98,6 +110,8 @@ with PositionWatcherService(
98
110
  Important:
99
111
  - When `enable_fee_calc=True`, you must register market fee metadata with `set_market_fee_schedule(...)` or `set_market_fee_schedules(...)`.
100
112
  - `get_position()` does not fetch `/markets` automatically.
113
+ - If you need strategy-level positions, use `get_position_by_order_ids(...)` or `get_positions_by_order_ids(...)`; these resolve `order.associate_trades` first and then fall back to the internal trade index built from live trades.
114
+ - If multiple callers share one watcher, pass `group="..."` to `add_http_listen(...)`, `remove_http_listen(...)`, `set_http_listen(...)`, `set_market_http_listen(...)`, `set_order_http_listen(...)`, or `clear_http(...)` so each caller manages its own HTTP fallback namespace without overwriting others.
101
115
  - If a market is missing `feeSchedule`, fee calculation is skipped for that market and a warning is logged once.
102
116
 
103
117
  Where does `feeSchedule` come from:
@@ -54,8 +54,14 @@ with PositionWatcherService(
54
54
 
55
55
  # Non-blocking: Get current positions and orders (returns immediately)
56
56
  position: UserPosition = service.get_position("<token_id>")
57
+ strategy_position: UserPosition | None = service.get_position_by_order_ids(["<order_id>"])
58
+ strategy_positions: dict[str, UserPosition] = service.get_positions_by_order_ids(
59
+ ["<order_id_1>", "<order_id_2>"]
60
+ )
57
61
  order: OrderMessage = service.get_order("<order_id>")
58
62
  print(position)
63
+ print(strategy_position)
64
+ print(strategy_positions)
59
65
  print(order)
60
66
  if position:
61
67
  print("size(post-fee):", position.size)
@@ -72,6 +78,12 @@ with PositionWatcherService(
72
78
 
73
79
  # Optional: If you open new positions/orders and want to monitor them via HTTP fallback
74
80
  # service.add_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
81
+ # service.set_http_listen(
82
+ # market_ids=["<condition_id>"],
83
+ # order_ids=["<order_id>"],
84
+ # group="strategy-a",
85
+ # )
86
+ # service.clear_http(group="strategy-a")
75
87
  # service.remove_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
76
88
  # service.clear_http() # Clear all monitoring items, threads continue running
77
89
  ```
@@ -79,6 +91,8 @@ with PositionWatcherService(
79
91
  Important:
80
92
  - When `enable_fee_calc=True`, you must register market fee metadata with `set_market_fee_schedule(...)` or `set_market_fee_schedules(...)`.
81
93
  - `get_position()` does not fetch `/markets` automatically.
94
+ - If you need strategy-level positions, use `get_position_by_order_ids(...)` or `get_positions_by_order_ids(...)`; these resolve `order.associate_trades` first and then fall back to the internal trade index built from live trades.
95
+ - If multiple callers share one watcher, pass `group="..."` to `add_http_listen(...)`, `remove_http_listen(...)`, `set_http_listen(...)`, `set_market_http_listen(...)`, `set_order_http_listen(...)`, or `clear_http(...)` so each caller manages its own HTTP fallback namespace without overwriting others.
82
96
  - If a market is missing `feeSchedule`, fee calculation is skipped for that market and a warning is logged once.
83
97
 
84
98
  Where does `feeSchedule` come from:
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.4"
3
+ __version__ = "0.3.6"
@@ -196,34 +196,103 @@ class HttpFallbackManager:
196
196
  Manages HTTP fallback polling with persistent threads.
197
197
  Markets and orders sets can be modified dynamically while threads keep running.
198
198
  """
199
-
199
+
200
+ DEFAULT_GROUP = "__default__"
201
+
200
202
  def __init__(self, service: "PositionWatcherService", http_poll_interval: float = 3):
201
203
  self.service = service
202
204
  self.http_poll_interval = http_poll_interval
203
205
  # Use service's existing APIWorker instance
204
206
  self.api_worker = service.api_worker
205
207
  self._slug_cache: dict[str, str] = {}
206
-
207
- # Thread-safe sets for markets and orders
208
+
209
+ # Thread-safe grouped monitoring state
208
210
  self._lock = threading.RLock()
209
- self.markets: set[str] = set()
210
- self.orders: set[str] = set()
211
-
211
+ self.market_groups: dict[str, set[str]] = {}
212
+ self.order_groups: dict[str, set[str]] = {}
213
+
212
214
  # Thread control
213
215
  self._stop_event = threading.Event()
214
216
  self._trade_thread = None
215
217
  self._order_thread = None
216
218
  self._running = False
217
-
219
+
220
+ @staticmethod
221
+ def _normalize_group(group: str | None) -> str:
222
+ return group or HttpFallbackManager.DEFAULT_GROUP
223
+
224
+ @staticmethod
225
+ def _clean_ids(values: list[str] | None) -> set[str]:
226
+ return {value for value in (values or []) if value}
227
+
228
+ def _aggregated_markets_locked(self) -> set[str]:
229
+ markets: set[str] = set()
230
+ for group_markets in self.market_groups.values():
231
+ markets.update(group_markets)
232
+ return markets
233
+
234
+ def _aggregated_orders_locked(self) -> set[str]:
235
+ order_ids: set[str] = set()
236
+ for group_orders in self.order_groups.values():
237
+ order_ids.update(group_orders)
238
+ return order_ids
239
+
240
+ def add(
241
+ self,
242
+ market_ids: list[str] = None,
243
+ order_ids: list[str] = None,
244
+ group: str | None = None,
245
+ ):
246
+ """Add markets/orders to monitor for a specific group."""
247
+ group_name = self._normalize_group(group)
248
+ with self._lock:
249
+ if clean_market_ids := self._clean_ids(market_ids):
250
+ self.market_groups.setdefault(group_name, set()).update(clean_market_ids)
251
+ if clean_order_ids := self._clean_ids(order_ids):
252
+ self.order_groups.setdefault(group_name, set()).update(clean_order_ids)
253
+ logger.info(
254
+ "Added HTTP monitoring for group {}: {} markets, {} orders",
255
+ group_name,
256
+ len(market_ids or []),
257
+ len(order_ids or []),
258
+ )
259
+
260
+ def set_group(
261
+ self,
262
+ *,
263
+ group: str,
264
+ market_ids: list[str] | None = None,
265
+ order_ids: list[str] | None = None,
266
+ ):
267
+ """Replace monitored markets/orders for a specific group."""
268
+ group_name = self._normalize_group(group)
269
+ clean_market_ids = self._clean_ids(market_ids)
270
+ clean_order_ids = self._clean_ids(order_ids)
271
+ with self._lock:
272
+ if clean_market_ids:
273
+ self.market_groups[group_name] = clean_market_ids
274
+ else:
275
+ self.market_groups.pop(group_name, None)
276
+ if clean_order_ids:
277
+ self.order_groups[group_name] = clean_order_ids
278
+ else:
279
+ self.order_groups.pop(group_name, None)
280
+ logger.info(
281
+ "Set HTTP monitoring group {}: {} markets, {} orders",
282
+ group_name,
283
+ len(clean_market_ids),
284
+ len(clean_order_ids),
285
+ )
286
+
218
287
  def start(self):
219
288
  """Start HTTP polling threads (persistent, runs until stop is called)."""
220
289
  with self._lock:
221
290
  if self._running:
222
291
  return
223
-
292
+
224
293
  self._stop_event.clear()
225
294
  self._running = True
226
-
295
+
227
296
  self._trade_thread = threading.Thread(
228
297
  target=self._trade_loop,
229
298
  daemon=True,
@@ -232,120 +301,157 @@ class HttpFallbackManager:
232
301
  target=self._order_loop,
233
302
  daemon=True,
234
303
  )
235
-
304
+
236
305
  self._trade_thread.start()
237
306
  self._order_thread.start()
238
307
  logger.info("Started HTTP fallback polling threads")
239
-
308
+
240
309
  def stop(self):
241
310
  """Stop HTTP polling threads."""
242
311
  with self._lock:
243
312
  if not self._running:
244
313
  return
245
-
314
+
246
315
  self._stop_event.set()
247
316
  self._running = False
248
-
317
+
249
318
  # Wait for threads to finish
250
319
  if self._trade_thread:
251
320
  self._trade_thread.join(timeout=2)
252
321
  if self._order_thread:
253
322
  self._order_thread.join(timeout=2)
254
-
323
+
255
324
  logger.info("Stopped HTTP fallback polling threads")
256
-
257
- def add(self, market_ids: list[str] = None, order_ids: list[str] = None):
258
- """Add markets/orders to monitor."""
259
- with self._lock:
260
- if market_ids:
261
- self.markets.update(market_ids)
262
- if order_ids:
263
- self.orders.update(order_ids)
264
- logger.info(f"Added HTTP monitoring: {len(market_ids or [])} markets, {len(order_ids or [])} orders")
265
-
266
- def set_markets(self, market_ids: list[str] | None = None):
267
- """Replace monitored markets with the provided list."""
325
+
326
+ def set_markets(self, market_ids: list[str] | None = None, group: str | None = None):
327
+ """Replace monitored markets for a specific group."""
328
+ group_name = self._normalize_group(group)
268
329
  with self._lock:
269
- self.markets = set(market_ids or [])
270
- logger.info(f"Set HTTP monitoring markets: {len(self.markets)} total")
330
+ clean_market_ids = self._clean_ids(market_ids)
331
+ if clean_market_ids:
332
+ self.market_groups[group_name] = clean_market_ids
333
+ else:
334
+ self.market_groups.pop(group_name, None)
335
+ logger.info(
336
+ "Set HTTP monitoring markets for group {}: {} total",
337
+ group_name,
338
+ len(clean_market_ids),
339
+ )
271
340
 
272
- def set_orders(self, order_ids: list[str] | None = None):
273
- """Replace monitored orders with the provided list."""
341
+ def set_orders(self, order_ids: list[str] | None = None, group: str | None = None):
342
+ """Replace monitored orders for a specific group."""
343
+ group_name = self._normalize_group(group)
274
344
  with self._lock:
275
- self.orders = set(order_ids or [])
276
- logger.info(f"Set HTTP monitoring orders: {len(self.orders)} total")
277
-
278
- def remove(self, market_ids: list[str] = None, order_ids: list[str] = None):
279
- """Remove markets/orders from monitoring."""
345
+ clean_order_ids = self._clean_ids(order_ids)
346
+ if clean_order_ids:
347
+ self.order_groups[group_name] = clean_order_ids
348
+ else:
349
+ self.order_groups.pop(group_name, None)
350
+ logger.info(
351
+ "Set HTTP monitoring orders for group {}: {} total",
352
+ group_name,
353
+ len(clean_order_ids),
354
+ )
355
+
356
+ def remove(
357
+ self,
358
+ market_ids: list[str] = None,
359
+ order_ids: list[str] = None,
360
+ group: str | None = None,
361
+ ):
362
+ """Remove markets/orders from monitoring for a specific group."""
363
+ group_name = self._normalize_group(group)
280
364
  with self._lock:
281
- if market_ids:
282
- self.markets -= set(market_ids)
283
- if order_ids:
284
- self.orders -= set(order_ids)
285
- logger.info(f"Removed HTTP monitoring: {len(market_ids or [])} markets, {len(order_ids or [])} orders")
286
-
287
- def clear(self):
288
- """Clear all markets and orders (threads keep running)."""
365
+ if clean_market_ids := self._clean_ids(market_ids):
366
+ group_markets = self.market_groups.get(group_name)
367
+ if group_markets is not None:
368
+ group_markets -= clean_market_ids
369
+ if not group_markets:
370
+ self.market_groups.pop(group_name, None)
371
+ if clean_order_ids := self._clean_ids(order_ids):
372
+ group_orders = self.order_groups.get(group_name)
373
+ if group_orders is not None:
374
+ group_orders -= clean_order_ids
375
+ if not group_orders:
376
+ self.order_groups.pop(group_name, None)
377
+ logger.info(
378
+ "Removed HTTP monitoring for group {}: {} markets, {} orders",
379
+ group_name,
380
+ len(market_ids or []),
381
+ len(order_ids or []),
382
+ )
383
+
384
+ def clear(self, group: str | None = None):
385
+ """Clear monitoring state for one group, or all groups when group is omitted."""
289
386
  with self._lock:
290
- self.markets.clear()
291
- self.orders.clear()
292
- logger.info("Cleared all HTTP monitoring items (threads continue running)")
293
-
387
+ if group is None:
388
+ self.market_groups.clear()
389
+ self.order_groups.clear()
390
+ logger.info("Cleared all HTTP monitoring groups (threads continue running)")
391
+ return
392
+ group_name = self._normalize_group(group)
393
+ self.market_groups.pop(group_name, None)
394
+ self.order_groups.pop(group_name, None)
395
+ logger.info(
396
+ "Cleared HTTP monitoring group {} (threads continue running)",
397
+ group_name,
398
+ )
399
+
294
400
  def _trade_loop(self):
295
401
  """Trade polling loop - runs continuously until stopped."""
296
402
  while not self._stop_event.wait(self.http_poll_interval):
297
403
  try:
298
404
  self._update_missing_market_slugs()
299
405
  with self._lock:
300
- markets = list(self.markets)
301
-
406
+ markets = list(self._aggregated_markets_locked())
407
+
302
408
  if not markets:
303
409
  continue # No markets to poll, continue waiting
304
-
410
+
305
411
  tasks = []
306
412
  for market in markets:
307
413
  task = executor.submit(self.api_worker.fetch_trades, market)
308
414
  task._market_id = market
309
415
  tasks.append(task)
310
-
416
+
311
417
  for task in as_completed(tasks):
312
418
  try:
313
419
  trades = task.result()
314
420
  except Exception as e:
315
421
  logger.error(f"Failed to http fetch trades market {task._market_id}: {e}")
316
422
  continue
317
-
423
+
318
424
  for trade in sorted(trades, key=lambda x: x.event_time):
319
425
  self.service._ingest_trade(trade)
320
426
  except Exception as e:
321
427
  logger.error(f"Error in trade loop: {e}")
322
-
428
+
323
429
  logger.info("Trade polling loop stopped")
324
-
430
+
325
431
  def _order_loop(self):
326
432
  """Order polling loop - runs continuously until stopped."""
327
433
  while not self._stop_event.wait(self.http_poll_interval):
328
434
  try:
329
435
  self._update_missing_market_slugs()
330
436
  with self._lock:
331
- order_ids = list(self.orders)
332
-
437
+ order_ids = list(self._aggregated_orders_locked())
438
+
333
439
  if not order_ids:
334
440
  continue # No orders to poll, continue waiting
335
-
441
+
336
442
  tasks = []
337
443
  for order_id in order_ids:
338
444
  task = executor.submit(self.api_worker.fetch_order, order_id)
339
445
  task._order_id = order_id
340
446
  tasks.append(task)
341
-
447
+
342
448
  for task in as_completed(tasks):
343
449
  try:
344
450
  order = task.result()
345
451
  except Exception as e:
346
452
  logger.error(f"Failed to fetch order {task._order_id}: {e}")
347
453
  continue
348
-
454
+
349
455
  if order is None:
350
456
  exists = self.service.position_store.orders.get(task._order_id)
351
457
  if exists:
@@ -355,7 +461,7 @@ class HttpFallbackManager:
355
461
  self.service._ingest_order(order)
356
462
  except Exception as e:
357
463
  logger.error(f"Error in order loop: {e}")
358
-
464
+
359
465
  logger.info("Order polling loop stopped")
360
466
 
361
467
  def _update_missing_market_slugs(self):
@@ -54,18 +54,27 @@ class PositionStore:
54
54
  if fee_schedule
55
55
  }
56
56
  self.trades_by_token: Dict[str, Dict[str, TradeMessage]] = defaultdict(dict)
57
+ self.trades_by_id: Dict[str, TradeMessage] = {}
58
+ self.trade_ids_by_order: Dict[str, set[str]] = defaultdict(set)
57
59
  self.positions: Dict[str, UserPosition] = {}
58
60
  self.orders: Dict[str, OrderMessage] = {}
59
61
  self._warned_failed_trade_keys: set[tuple[str, str]] = set()
60
62
  self._lock = threading.RLock()
61
63
  self.queue_dict: Dict[str, Queue] = {}
62
64
 
63
- def get_token_id_from_trade(self, trade: TradeMessage) -> tuple[str, str] | None:
64
- if trade.maker_address.upper() == self.user_address.upper():
65
- return trade.outcome, trade.asset_id
65
+ def _is_user_outer_trade(self, trade: TradeMessage) -> bool:
66
+ return trade.maker_address.upper() == self.user_address.upper()
67
+
68
+ def _iter_user_maker_orders(self, trade: TradeMessage):
66
69
  for order in trade.maker_orders:
67
70
  if order.maker_address.upper() == self.user_address.upper():
68
- return order.outcome, order.asset_id
71
+ yield order
72
+
73
+ def get_token_id_from_trade(self, trade: TradeMessage) -> tuple[str, str] | None:
74
+ if self._is_user_outer_trade(trade):
75
+ return trade.outcome, trade.asset_id
76
+ for order in self._iter_user_maker_orders(trade):
77
+ return order.outcome, order.asset_id
69
78
 
70
79
  def _put(self, _id: str, item: UserPosition | OrderMessage) -> None:
71
80
  if _id not in self.queue_dict:
@@ -99,8 +108,10 @@ class PositionStore:
99
108
  existing = trades_map.get(trade.id)
100
109
  if existing and trade.event_time < existing.event_time:
101
110
  return
102
- else:
103
- trades_map[trade.id] = trade
111
+ if existing:
112
+ self._remove_trade_indexes(existing)
113
+ trades_map[trade.id] = trade
114
+ self._index_trade(trade)
104
115
  user_pos = self.build_position(
105
116
  trades=list(trades_map.values()), token_id=token_id, outcome=outcome
106
117
  )
@@ -120,7 +131,10 @@ class PositionStore:
120
131
  outcome, token_id = result
121
132
  trades_map = self.trades_by_token[token_id]
122
133
  for trade in trades:
134
+ if existing := trades_map.get(trade.id):
135
+ self._remove_trade_indexes(existing)
123
136
  trades_map[trade.id] = trade
137
+ self._index_trade(trade)
124
138
  user_pos = self.build_position(
125
139
  trades=list(trades_map.values()), token_id=token_id, outcome=outcome
126
140
  )
@@ -141,7 +155,10 @@ class PositionStore:
141
155
  and order.status == existing.status
142
156
  ):
143
157
  return
144
- if abs(order.size_matched - order.original_size) < 0.5:
158
+ if (
159
+ order.original_size is not None
160
+ and abs(order.size_matched - order.original_size) < 0.5
161
+ ):
145
162
  order.filled = True
146
163
  self.orders[order.id] = order
147
164
  self._put(order.id, order)
@@ -190,9 +207,43 @@ class PositionStore:
190
207
  self.positions[token_id] = user_pos
191
208
  self._put(token_id, user_pos)
192
209
 
193
- def build_position(
194
- self, trades: list[TradeMessage], token_id, outcome: str
210
+ def _iter_user_order_ids_for_trade(self, trade: TradeMessage):
211
+ if self._is_user_outer_trade(trade) and trade.taker_order_id:
212
+ yield trade.taker_order_id
213
+ for order in self._iter_user_maker_orders(trade):
214
+ yield order.order_id
215
+
216
+ def _index_trade(self, trade: TradeMessage) -> None:
217
+ self.trades_by_id[trade.id] = trade
218
+ for order_id in self._iter_user_order_ids_for_trade(trade):
219
+ if order_id:
220
+ self.trade_ids_by_order[order_id].add(trade.id)
221
+
222
+ def _remove_trade_indexes(self, trade: TradeMessage) -> None:
223
+ existing = self.trades_by_id.get(trade.id)
224
+ if existing is trade or existing is not None:
225
+ self.trades_by_id.pop(trade.id, None)
226
+ for order_id in self._iter_user_order_ids_for_trade(trade):
227
+ if not order_id:
228
+ continue
229
+ trade_ids = self.trade_ids_by_order.get(order_id)
230
+ if not trade_ids:
231
+ continue
232
+ trade_ids.discard(trade.id)
233
+ if not trade_ids:
234
+ self.trade_ids_by_order.pop(order_id, None)
235
+
236
+ def _build_user_position(
237
+ self,
238
+ trades: list[TradeMessage],
239
+ token_id: str,
240
+ outcome: str,
241
+ *,
242
+ warn_failed: bool,
195
243
  ) -> UserPosition | None:
244
+ if not trades:
245
+ return None
246
+
196
247
  def _status_is(trade: TradeMessage, status: TradeStatus) -> bool:
197
248
  return trade.status == status or trade.status == status.value
198
249
 
@@ -202,22 +253,23 @@ class PositionStore:
202
253
  i for i in success_trades if _status_is(i, TradeStatus.CONFIRMED)
203
254
  ]
204
255
  has_failed = bool(len(failed_trades))
205
- new_failed_trades = [
206
- trade
207
- for trade in failed_trades
208
- if (token_id, trade.id) not in self._warned_failed_trade_keys
209
- ]
210
- if new_failed_trades:
211
- self._warned_failed_trade_keys.update(
212
- (token_id, trade.id) for trade in new_failed_trades
213
- )
214
- failed_size = sum(i.size for i in new_failed_trades)
215
- failed_trade_ids = [trade.id for trade in new_failed_trades]
216
- logger.warning(
217
- "Found failed trades, total size: {}, ids: {}",
218
- failed_size,
219
- failed_trade_ids,
220
- )
256
+ if warn_failed:
257
+ new_failed_trades = [
258
+ trade
259
+ for trade in failed_trades
260
+ if (token_id, trade.id) not in self._warned_failed_trade_keys
261
+ ]
262
+ if new_failed_trades:
263
+ self._warned_failed_trade_keys.update(
264
+ (token_id, trade.id) for trade in new_failed_trades
265
+ )
266
+ failed_size = sum(i.size for i in new_failed_trades)
267
+ failed_trade_ids = [trade.id for trade in new_failed_trades]
268
+ logger.warning(
269
+ "Found failed trades, total size: {}, ids: {}",
270
+ failed_size,
271
+ failed_trade_ids,
272
+ )
221
273
  market_slug = next(
222
274
  (trade.market_slug for trade in trades if trade.market_slug), ""
223
275
  )
@@ -238,7 +290,7 @@ class PositionStore:
238
290
  fee_calc_fn=self.fee_calc_fn,
239
291
  )
240
292
  sellable_size = confirmed_result.size
241
- current = UserPosition(
293
+ return UserPosition(
242
294
  price=position_result.avg_price,
243
295
  size=position_result.size,
244
296
  original_size=position_result.original_size,
@@ -258,7 +310,16 @@ class PositionStore:
258
310
  market_slug=market_slug,
259
311
  failed_trades=failed_trades,
260
312
  )
261
- return current
313
+
314
+ def build_position(
315
+ self, trades: list[TradeMessage], token_id, outcome: str
316
+ ) -> UserPosition | None:
317
+ return self._build_user_position(
318
+ trades=trades,
319
+ token_id=token_id,
320
+ outcome=outcome,
321
+ warn_failed=True,
322
+ )
262
323
  # if exists_pos := self.positions.get(token_id):
263
324
  # if exists_pos.last_update < current.last_update:
264
325
  # return current
@@ -278,6 +339,53 @@ class PositionStore:
278
339
  def get_order_by_id(self, order_id: str) -> OrderMessage:
279
340
  return self.orders.get(order_id)
280
341
 
342
+ def get_positions_by_order_ids(
343
+ self, order_ids: list[str]
344
+ ) -> Dict[str, UserPosition]:
345
+ with self._lock:
346
+ trade_ids: set[str] = set()
347
+ for order_id in order_ids:
348
+ if order := self.orders.get(order_id):
349
+ trade_ids.update(order.associate_trades or [])
350
+ trade_ids.update(self.trade_ids_by_order.get(order_id, set()))
351
+
352
+ grouped_trades: Dict[str, list[TradeMessage]] = defaultdict(list)
353
+ outcomes: Dict[str, str] = {}
354
+ for trade_id in trade_ids:
355
+ trade = self.trades_by_id.get(trade_id)
356
+ if trade is None:
357
+ continue
358
+ result = self.get_token_id_from_trade(trade)
359
+ if result is None:
360
+ continue
361
+ outcome, token_id = result
362
+ grouped_trades[token_id].append(trade)
363
+ outcomes[token_id] = outcome
364
+
365
+ positions: Dict[str, UserPosition] = {}
366
+ for token_id, trades in grouped_trades.items():
367
+ position = self._build_user_position(
368
+ trades=trades,
369
+ token_id=token_id,
370
+ outcome=outcomes[token_id],
371
+ warn_failed=False,
372
+ )
373
+ if position is not None:
374
+ positions[token_id] = position
375
+ return positions
376
+
377
+ def get_position_by_order_ids(
378
+ self, order_ids: list[str]
379
+ ) -> UserPosition | None:
380
+ positions = self.get_positions_by_order_ids(order_ids)
381
+ if not positions:
382
+ return None
383
+ if len(positions) > 1:
384
+ raise ValueError(
385
+ "order_ids resolve to multiple token positions; use get_positions_by_order_ids instead."
386
+ )
387
+ return next(iter(positions.values()))
388
+
281
389
  def blocking_get_token_position(
282
390
  self, token_id: str, timeout: float = None
283
391
  ) -> UserPosition:
@@ -662,6 +770,16 @@ class PositionWatcherService:
662
770
  def get_order(self, order_id: str) -> OrderMessage:
663
771
  return self.position_store.get_order_by_id(order_id)
664
772
 
773
+ def get_positions_by_order_ids(
774
+ self, order_ids: list[str]
775
+ ) -> Dict[str, UserPosition]:
776
+ return self.position_store.get_positions_by_order_ids(order_ids)
777
+
778
+ def get_position_by_order_ids(
779
+ self, order_ids: list[str]
780
+ ) -> UserPosition | None:
781
+ return self.position_store.get_position_by_order_ids(order_ids)
782
+
665
783
  def blocking_get_position(
666
784
  self, token_id: str, timeout: float = None
667
785
  ) -> UserPosition | None:
@@ -689,14 +807,20 @@ class PositionWatcherService:
689
807
  # -------------------------------------------------------------------------
690
808
  # HTTP Fallback Management (delegates to HttpFallbackManager)
691
809
  # -------------------------------------------------------------------------
810
+ DEFAULT_HTTP_LISTEN_GROUP = HttpFallbackManager.DEFAULT_GROUP
811
+
692
812
  def add_http_listen(
693
- self, order_ids: list[str] = None, market_ids: list[str] = None
813
+ self,
814
+ order_ids: list[str] = None,
815
+ market_ids: list[str] = None,
816
+ group: str = DEFAULT_HTTP_LISTEN_GROUP,
694
817
  ):
695
818
  """
696
819
  Add markets/orders to HTTP fallback polling.
697
820
 
698
821
  :param order_ids: List of order IDs to monitor
699
822
  :param market_ids: List of market (condition) IDs to monitor
823
+ :param group: Optional namespace/group name for this caller
700
824
  """
701
825
  if not self.enable_http_fallback or not self._http_fallback:
702
826
  logger.warning(
@@ -704,47 +828,101 @@ class PositionWatcherService:
704
828
  )
705
829
  return
706
830
 
707
- self._http_fallback.add(market_ids=market_ids, order_ids=order_ids)
831
+ self._http_fallback.add(
832
+ market_ids=market_ids,
833
+ order_ids=order_ids,
834
+ group=group,
835
+ )
708
836
 
709
837
  def remove_http_listen(
710
- self, order_ids: list[str] = None, market_ids: list[str] = None
838
+ self,
839
+ order_ids: list[str] = None,
840
+ market_ids: list[str] = None,
841
+ group: str = DEFAULT_HTTP_LISTEN_GROUP,
711
842
  ):
712
843
  """
713
844
  Remove markets/orders from HTTP fallback polling.
714
845
 
715
846
  :param order_ids: List of order IDs to remove
716
847
  :param market_ids: List of market (condition) IDs to remove
848
+ :param group: Optional namespace/group name for this caller
849
+ """
850
+ if not self.enable_http_fallback or not self._http_fallback:
851
+ return
852
+
853
+ self._http_fallback.remove(
854
+ market_ids=market_ids,
855
+ order_ids=order_ids,
856
+ group=group,
857
+ )
858
+
859
+ def set_http_listen(
860
+ self,
861
+ order_ids: list[str] = None,
862
+ market_ids: list[str] = None,
863
+ group: str = DEFAULT_HTTP_LISTEN_GROUP,
864
+ ):
865
+ """
866
+ Replace HTTP fallback monitoring for one group atomically.
867
+
868
+ :param order_ids: List of order IDs to monitor
869
+ :param market_ids: List of market (condition) IDs to monitor
870
+ :param group: Optional namespace/group name for this caller
717
871
  """
718
872
  if not self.enable_http_fallback or not self._http_fallback:
719
873
  return
720
874
 
721
- self._http_fallback.remove(market_ids=market_ids, order_ids=order_ids)
875
+ self._http_fallback.set_group(
876
+ group=group,
877
+ market_ids=market_ids,
878
+ order_ids=order_ids,
879
+ )
722
880
 
723
- def set_market_http_listen(self, market_ids: list[str] = None):
881
+ def set_market_http_listen(
882
+ self,
883
+ market_ids: list[str] = None,
884
+ group: str = DEFAULT_HTTP_LISTEN_GROUP,
885
+ ):
724
886
  """
725
887
  Replace HTTP fallback market monitoring list.
726
888
 
727
889
  :param market_ids: List of market (condition) IDs to monitor
890
+ :param group: Optional namespace/group name for this caller
728
891
  """
729
892
  if not self.enable_http_fallback or not self._http_fallback:
730
893
  return
731
894
 
732
- self._http_fallback.set_markets(market_ids=market_ids)
895
+ self._http_fallback.set_markets(
896
+ market_ids=market_ids,
897
+ group=group,
898
+ )
733
899
 
734
- def set_order_http_listen(self, order_ids: list[str] = None):
900
+ def set_order_http_listen(
901
+ self,
902
+ order_ids: list[str] = None,
903
+ group: str = DEFAULT_HTTP_LISTEN_GROUP,
904
+ ):
735
905
  """
736
906
  Replace HTTP fallback order monitoring list.
737
907
 
738
908
  :param order_ids: List of order IDs to monitor
909
+ :param group: Optional namespace/group name for this caller
739
910
  """
740
911
  if not self.enable_http_fallback or not self._http_fallback:
741
912
  return
742
913
 
743
- self._http_fallback.set_orders(order_ids=order_ids)
914
+ self._http_fallback.set_orders(
915
+ order_ids=order_ids,
916
+ group=group,
917
+ )
918
+
919
+ def clear_http(self, group: str | None = None):
920
+ """
921
+ Clear HTTP fallback monitoring.
744
922
 
745
- def clear_http(self):
746
- """Clear all HTTP fallback monitoring (threads keep running)."""
923
+ :param group: When provided, clear only that group; otherwise clear all groups.
924
+ """
747
925
  if not self.enable_http_fallback or not self._http_fallback:
748
926
  return
749
927
 
750
- self._http_fallback.clear()
928
+ self._http_fallback.clear(group=group)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poly-position-watcher
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: polymarket proxy wallet redeem
5
5
  Home-page: https://github.com/tosmart01/polymarket-position-watcher
6
6
  Author: pinbar
@@ -73,8 +73,14 @@ with PositionWatcherService(
73
73
 
74
74
  # Non-blocking: Get current positions and orders (returns immediately)
75
75
  position: UserPosition = service.get_position("<token_id>")
76
+ strategy_position: UserPosition | None = service.get_position_by_order_ids(["<order_id>"])
77
+ strategy_positions: dict[str, UserPosition] = service.get_positions_by_order_ids(
78
+ ["<order_id_1>", "<order_id_2>"]
79
+ )
76
80
  order: OrderMessage = service.get_order("<order_id>")
77
81
  print(position)
82
+ print(strategy_position)
83
+ print(strategy_positions)
78
84
  print(order)
79
85
  if position:
80
86
  print("size(post-fee):", position.size)
@@ -91,6 +97,12 @@ with PositionWatcherService(
91
97
 
92
98
  # Optional: If you open new positions/orders and want to monitor them via HTTP fallback
93
99
  # service.add_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
100
+ # service.set_http_listen(
101
+ # market_ids=["<condition_id>"],
102
+ # order_ids=["<order_id>"],
103
+ # group="strategy-a",
104
+ # )
105
+ # service.clear_http(group="strategy-a")
94
106
  # service.remove_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
95
107
  # service.clear_http() # Clear all monitoring items, threads continue running
96
108
  ```
@@ -98,6 +110,8 @@ with PositionWatcherService(
98
110
  Important:
99
111
  - When `enable_fee_calc=True`, you must register market fee metadata with `set_market_fee_schedule(...)` or `set_market_fee_schedules(...)`.
100
112
  - `get_position()` does not fetch `/markets` automatically.
113
+ - If you need strategy-level positions, use `get_position_by_order_ids(...)` or `get_positions_by_order_ids(...)`; these resolve `order.associate_trades` first and then fall back to the internal trade index built from live trades.
114
+ - If multiple callers share one watcher, pass `group="..."` to `add_http_listen(...)`, `remove_http_listen(...)`, `set_http_listen(...)`, `set_market_http_listen(...)`, `set_order_http_listen(...)`, or `clear_http(...)` so each caller manages its own HTTP fallback namespace without overwriting others.
101
115
  - If a market is missing `feeSchedule`, fee calculation is skipped for that market and a warning is logged once.
102
116
 
103
117
  Where does `feeSchedule` come from:
@@ -21,5 +21,6 @@ poly_position_watcher/schema/__init__.py
21
21
  poly_position_watcher/schema/base.py
22
22
  poly_position_watcher/schema/common_model.py
23
23
  poly_position_watcher/schema/position_model.py
24
+ tests/test_http_fallback_manager.py
24
25
  tests/test_position_model.py
25
26
  tests/test_trade_calculator.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "poly-position-watcher"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "polymarket proxy wallet redeem"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from poly_position_watcher.api_worker import APIWorker, HttpFallbackManager
6
+
7
+
8
+ class DummyPositionStore:
9
+ positions = {}
10
+ orders = {}
11
+
12
+
13
+ class DummyService:
14
+ def __init__(self) -> None:
15
+ self.api_worker = object()
16
+ self.position_store = DummyPositionStore()
17
+
18
+
19
+ class HttpFallbackManagerTests(unittest.TestCase):
20
+ def test_default_group_keeps_old_api_compatible(self) -> None:
21
+ manager = HttpFallbackManager(DummyService(), http_poll_interval=1)
22
+
23
+ manager.add(market_ids=["market-1"], order_ids=["order-1"])
24
+
25
+ self.assertEqual(
26
+ manager.market_groups[HttpFallbackManager.DEFAULT_GROUP],
27
+ {"market-1"},
28
+ )
29
+ self.assertEqual(
30
+ manager.order_groups[HttpFallbackManager.DEFAULT_GROUP],
31
+ {"order-1"},
32
+ )
33
+
34
+ def test_group_sets_are_aggregated_across_namespaces(self) -> None:
35
+ manager = HttpFallbackManager(DummyService(), http_poll_interval=1)
36
+
37
+ manager.set_group(group="strategy-a", market_ids=["market-a"], order_ids=["order-a"])
38
+ manager.set_group(group="strategy-b", market_ids=["market-b"], order_ids=["order-b"])
39
+
40
+ self.assertEqual(manager._aggregated_markets_locked(), {"market-a", "market-b"})
41
+ self.assertEqual(manager._aggregated_orders_locked(), {"order-a", "order-b"})
42
+
43
+ def test_clear_one_group_does_not_affect_others(self) -> None:
44
+ manager = HttpFallbackManager(DummyService(), http_poll_interval=1)
45
+
46
+ manager.set_group(group="strategy-a", market_ids=["market-a"], order_ids=["order-a"])
47
+ manager.set_group(group="strategy-b", market_ids=["market-b"], order_ids=["order-b"])
48
+ manager.clear(group="strategy-a")
49
+
50
+ self.assertNotIn("strategy-a", manager.market_groups)
51
+ self.assertNotIn("strategy-a", manager.order_groups)
52
+ self.assertEqual(manager.market_groups["strategy-b"], {"market-b"})
53
+ self.assertEqual(manager.order_groups["strategy-b"], {"order-b"})
54
+
55
+ def test_remove_only_touches_target_group(self) -> None:
56
+ manager = HttpFallbackManager(DummyService(), http_poll_interval=1)
57
+
58
+ manager.add(group="strategy-a", market_ids=["market-a"], order_ids=["order-a"])
59
+ manager.add(group="strategy-b", market_ids=["market-a"], order_ids=["order-a"])
60
+ manager.remove(group="strategy-a", market_ids=["market-a"], order_ids=["order-a"])
61
+
62
+ self.assertNotIn("strategy-a", manager.market_groups)
63
+ self.assertNotIn("strategy-a", manager.order_groups)
64
+ self.assertEqual(manager.market_groups["strategy-b"], {"market-a"})
65
+ self.assertEqual(manager.order_groups["strategy-b"], {"order-a"})
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ from poly_position_watcher.position_service import PositionStore
7
+ from poly_position_watcher.schema.position_model import OrderMessage, TradeMessage, UserPosition
8
+
9
+
10
+ def build_trade(trade_id: str, status: str = "FAILED", size: float = 10.0) -> TradeMessage:
11
+ return TradeMessage(
12
+ type="TRADE",
13
+ event_type="trade",
14
+ asset_id="0xtoken",
15
+ id=trade_id,
16
+ maker_orders=[],
17
+ transaction_hash=f"0xhash-{trade_id}",
18
+ market="0xmarket",
19
+ maker_address="0xuser",
20
+ outcome="YES",
21
+ owner="0xuser",
22
+ price=0.25,
23
+ side="BUY",
24
+ size=size,
25
+ status=status,
26
+ taker_order_id=f"0xorder-{trade_id}",
27
+ timestamp=1,
28
+ match_time=1,
29
+ last_update=1,
30
+ trade_owner="0xuser",
31
+ trader_side="TAKER",
32
+ fee_rate_bps=0,
33
+ market_slug="test-market",
34
+ )
35
+
36
+
37
+ def build_order(
38
+ order_id: str,
39
+ *,
40
+ asset_id: str = "0xtoken",
41
+ associate_trades: list[str] | None = None,
42
+ ) -> OrderMessage:
43
+ return OrderMessage(
44
+ type="update",
45
+ event_type="order",
46
+ asset_id=asset_id,
47
+ associate_trades=associate_trades,
48
+ id=order_id,
49
+ market="0xmarket",
50
+ outcome="YES",
51
+ owner="0xuser",
52
+ price=0.25,
53
+ side="BUY",
54
+ size_matched=10.0,
55
+ timestamp=1,
56
+ status="LIVE",
57
+ market_slug="test-market",
58
+ )
59
+
60
+
61
+ class UserPositionTests(unittest.TestCase):
62
+ def test_failed_trade_ids_are_exposed_in_string_output(self) -> None:
63
+ position = UserPosition(
64
+ price=0.25,
65
+ size=10.0,
66
+ original_size=10.0,
67
+ volume=2.5,
68
+ fee_amount=0.0,
69
+ sellable_size=10.0,
70
+ token_id="0xtoken",
71
+ last_update=1,
72
+ market_id="0xmarket",
73
+ outcome="YES",
74
+ has_failed=True,
75
+ failed_trades=[build_trade("failed-1"), build_trade("failed-2")],
76
+ )
77
+
78
+ self.assertEqual(position.failed_trade_ids, ["failed-1", "failed-2"])
79
+ rendered = str(position)
80
+ self.assertIn("failed_trades: ['failed-1', 'failed-2']", rendered)
81
+ self.assertNotIn("transaction_hash", rendered)
82
+
83
+ def test_failed_trade_warning_logs_once_per_token_and_trade_id(self) -> None:
84
+ store = PositionStore(user_address="0xuser")
85
+ trades = [
86
+ build_trade("confirmed-1", status="CONFIRMED", size=5.0),
87
+ build_trade("failed-1", status="FAILED", size=10.0),
88
+ ]
89
+
90
+ with patch("poly_position_watcher.position_service.logger.warning") as warning:
91
+ store.build_position(trades=trades, token_id="0xtoken", outcome="YES")
92
+ store.build_position(trades=trades, token_id="0xtoken", outcome="YES")
93
+
94
+ warning.assert_called_once_with(
95
+ "Found failed trades, total size: {}, ids: {}",
96
+ 10.0,
97
+ ["failed-1"],
98
+ )
99
+
100
+ def test_get_position_by_order_ids_uses_order_trade_links(self) -> None:
101
+ store = PositionStore(user_address="0xuser")
102
+ trade_a = build_trade("trade-a", status="CONFIRMED", size=3.0)
103
+ trade_a.taker_order_id = "order-a"
104
+ trade_b = build_trade("trade-b", status="CONFIRMED", size=5.0)
105
+ trade_b.taker_order_id = "order-b"
106
+ store.append_trade(trade_a)
107
+ store.append_trade(trade_b)
108
+ store.append_order(build_order("order-a", associate_trades=["trade-a"]))
109
+ store.append_order(build_order("order-b", associate_trades=["trade-b"]))
110
+
111
+ position_a = store.get_position_by_order_ids(["order-a"])
112
+ combined_positions = store.get_positions_by_order_ids(["order-a", "order-b"])
113
+
114
+ self.assertIsNotNone(position_a)
115
+ self.assertEqual(position_a.token_id, "0xtoken")
116
+ self.assertEqual(position_a.size, 3.0)
117
+ self.assertIn("0xtoken", combined_positions)
118
+ self.assertEqual(combined_positions["0xtoken"].size, 8.0)
119
+
120
+ def test_get_position_by_order_ids_falls_back_to_trade_index_when_order_missing_associate_trades(self) -> None:
121
+ store = PositionStore(user_address="0xuser")
122
+ trade = build_trade("trade-a", status="CONFIRMED", size=4.0)
123
+ trade.taker_order_id = "order-a"
124
+ store.append_trade(trade)
125
+ store.append_order(build_order("order-a", associate_trades=None))
126
+
127
+ position = store.get_position_by_order_ids(["order-a"])
128
+
129
+ self.assertIsNotNone(position)
130
+ self.assertEqual(position.size, 4.0)
131
+
132
+ def test_trade_index_only_uses_current_user_related_order_ids(self) -> None:
133
+ store = PositionStore(user_address="0xuser")
134
+ trade = TradeMessage(
135
+ type="TRADE",
136
+ event_type="trade",
137
+ asset_id="0xup-token",
138
+ id="trade-realistic",
139
+ maker_orders=[
140
+ {
141
+ "order_id": "irrelevant-order-1",
142
+ "owner": "other-owner",
143
+ "maker_address": "0xother1",
144
+ "matched_amount": "20",
145
+ "price": "0.45",
146
+ "fee_rate_bps": "1000",
147
+ "asset_id": "0xdown-token",
148
+ "outcome": "Down",
149
+ "side": "BUY",
150
+ },
151
+ {
152
+ "order_id": "user-maker-order",
153
+ "owner": "user-owner",
154
+ "maker_address": "0xuser",
155
+ "matched_amount": "6",
156
+ "price": "0.55",
157
+ "fee_rate_bps": "1000",
158
+ "asset_id": "0xup-token",
159
+ "outcome": "Up",
160
+ "side": "SELL",
161
+ },
162
+ {
163
+ "order_id": "irrelevant-order-2",
164
+ "owner": "other-owner-2",
165
+ "maker_address": "0xother2",
166
+ "matched_amount": "40",
167
+ "price": "0.41",
168
+ "fee_rate_bps": "1000",
169
+ "asset_id": "0xdown-token",
170
+ "outcome": "Down",
171
+ "side": "BUY",
172
+ },
173
+ ],
174
+ transaction_hash="0xhash-realistic",
175
+ market="0xmarket",
176
+ maker_address="0xouter-not-user",
177
+ outcome="Up",
178
+ owner="outer-owner",
179
+ price=0.59,
180
+ side="BUY",
181
+ size=6.0,
182
+ status="CONFIRMED",
183
+ taker_order_id="outer-taker-order",
184
+ timestamp=1,
185
+ match_time=1,
186
+ last_update=1,
187
+ trade_owner="outer-owner",
188
+ trader_side="MAKER",
189
+ fee_rate_bps=1000,
190
+ market_slug="test-market",
191
+ )
192
+ store.append_trade(trade)
193
+
194
+ self.assertEqual(store.get_position_by_order_ids(["user-maker-order"]).size, -6.0)
195
+ self.assertIsNone(store.get_position_by_order_ids(["irrelevant-order-1"]))
196
+ self.assertIsNone(store.get_position_by_order_ids(["irrelevant-order-2"]))
197
+ self.assertIsNone(store.get_position_by_order_ids(["outer-taker-order"]))
198
+
199
+
200
+ if __name__ == "__main__":
201
+ unittest.main()
@@ -1,78 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import unittest
4
- from unittest.mock import patch
5
-
6
- from poly_position_watcher.position_service import PositionStore
7
- from poly_position_watcher.schema.position_model import TradeMessage, UserPosition
8
-
9
-
10
- def build_trade(trade_id: str, status: str = "FAILED", size: float = 10.0) -> TradeMessage:
11
- return TradeMessage(
12
- type="TRADE",
13
- event_type="trade",
14
- asset_id="0xtoken",
15
- id=trade_id,
16
- maker_orders=[],
17
- transaction_hash=f"0xhash-{trade_id}",
18
- market="0xmarket",
19
- maker_address="0xuser",
20
- outcome="YES",
21
- owner="0xuser",
22
- price=0.25,
23
- side="BUY",
24
- size=size,
25
- status=status,
26
- taker_order_id=f"0xorder-{trade_id}",
27
- timestamp=1,
28
- match_time=1,
29
- last_update=1,
30
- trade_owner="0xuser",
31
- trader_side="TAKER",
32
- fee_rate_bps=0,
33
- market_slug="test-market",
34
- )
35
-
36
-
37
- class UserPositionTests(unittest.TestCase):
38
- def test_failed_trade_ids_are_exposed_in_string_output(self) -> None:
39
- position = UserPosition(
40
- price=0.25,
41
- size=10.0,
42
- original_size=10.0,
43
- volume=2.5,
44
- fee_amount=0.0,
45
- sellable_size=10.0,
46
- token_id="0xtoken",
47
- last_update=1,
48
- market_id="0xmarket",
49
- outcome="YES",
50
- has_failed=True,
51
- failed_trades=[build_trade("failed-1"), build_trade("failed-2")],
52
- )
53
-
54
- self.assertEqual(position.failed_trade_ids, ["failed-1", "failed-2"])
55
- rendered = str(position)
56
- self.assertIn("failed_trades: ['failed-1', 'failed-2']", rendered)
57
- self.assertNotIn("transaction_hash", rendered)
58
-
59
- def test_failed_trade_warning_logs_once_per_token_and_trade_id(self) -> None:
60
- store = PositionStore(user_address="0xuser")
61
- trades = [
62
- build_trade("confirmed-1", status="CONFIRMED", size=5.0),
63
- build_trade("failed-1", status="FAILED", size=10.0),
64
- ]
65
-
66
- with patch("poly_position_watcher.position_service.logger.warning") as warning:
67
- store.build_position(trades=trades, token_id="0xtoken", outcome="YES")
68
- store.build_position(trades=trades, token_id="0xtoken", outcome="YES")
69
-
70
- warning.assert_called_once_with(
71
- "Found failed trades, total size: {}, ids: {}",
72
- 10.0,
73
- ["failed-1"],
74
- )
75
-
76
-
77
- if __name__ == "__main__":
78
- unittest.main()