Metadata-Version: 2.4
Name: poly-position-watcher
Version: 0.3.8
Summary: polymarket proxy wallet redeem
Home-page: https://github.com/tosmart01/polymarket-position-watcher
Author: pinbar
Project-URL: Homepage, https://github.com/tosmart01/polymarket-position-watcher
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: loguru>=0.7.3
Requires-Dist: py-clob-client>=0.25.0
Requires-Dist: rich>=14.2.0
Requires-Dist: websocket-client>=1.8.0
Dynamic: author
Dynamic: home-page
Dynamic: license-file
Dynamic: requires-python

# poly-position-watcher

![PyPI](https://img.shields.io/pypi/v/poly-web3)
![Python](https://img.shields.io/pypi/pyversions/poly-web3)
![License](https://img.shields.io/github/license/tosmart01/poly-web3)

[English](README.md) | [中文](README.zh.md)

## Overview

`poly-position-watcher` is built for real trading on Polymarket, where order fills, trade updates, position sync, and sellable on-chain balance may not arrive at the same time.

In practice, `order filled`, `trade confirmed`, `position updated`, and `sellable on-chain` are different states. Strategy code that reacts to only one of them can easily overestimate inventory, mis-time hedges, or place exits before the position is actually ready.

This library acts as an execution reliability layer for Polymarket strategies. It helps you observe and reconcile these states in one place, so your strategy does not mistake "order filled" for "position synchronized" or "position visible" for "actually sellable".

Core capabilities:

- WSS real-time tracking for `TRADE` and `ORDER` (positions + orders)
- HTTP polling fallback for reliability
- Optional fee calculation using market `feeSchedule`
- Position fields for fill checks:
  `size` (post-fee net size), `original_size` (pre-fee net size), `sellable_size` (on-chain confirmed size), `fee_amount` (accumulated fee amount)
- Failed trades are detected and returned on positions (`has_failed`, `failed_trades`)
- Strategy-scoped position queries by `order_ids`:
  `get_position_by_order_ids(...)` and `get_positions_by_order_ids(...)`
- Order-aware fill helpers:
  `get_effective_position_size(...)`, `wait_for_orders_filled(...)`, `wait_for_orders_pos_filled(...)`
- HTTP fallback namespaces via `group=...`, so multiple callers can share one watcher without overwriting each other's monitored order/market sets

**Note: WSS disconnects are auto-detected and reconnected.**

## Installation

```bash
pip install poly-position-watcher
# pip install poly-position-watcher --index-url https://pypi.org/simple
```

If installing from source, clone this repo and run `pip install -e .`.

## Quick start

```python
from py_clob_client.client import ClobClient
from poly_position_watcher import PositionWatcherService, OrderMessage, UserPosition

client = ClobClient(
    base_url="https://clob.polymarket.com",
    key="<wallet-key>",
    secret="<wallet-secret>",
)

with PositionWatcherService(
    client=client,
    init_positions=True,  # Initialize positions via official API
    enable_http_fallback=True,  # Enable HTTP polling fallback
    add_init_positions_to_http=True,  # Auto-add condition_ids from init positions to HTTP monitoring
    enable_fee_calc=True,  # Optional: enable fee adjustments
) as service:
    service.set_market_fee_schedule(
        "<condition_id>",
        {"rate": 0.0175, "exponent": 1, "takerOnly": True, "rebateRate": 0.25},
    )

    # Non-blocking: Get current positions and orders (returns immediately)
    position: UserPosition = service.get_position("<token_id>")
    strategy_position: UserPosition | None = service.get_position_by_order_ids(["<order_id>"])
    strategy_positions: dict[str, UserPosition] = service.get_positions_by_order_ids(
        ["<order_id_1>", "<order_id_2>"]
    )
    effective_size: float = service.get_effective_position_size(
        token_id="<token_id>",
        order_ids=["<order_id_1>", "<order_id_2>"],
    )
    order: OrderMessage = service.get_order("<order_id>")
    fill_result = service.wait_for_orders_filled(
        ["<order_id_1>", "<order_id_2>"],
        any_filled=True,
        timeout=3,
    )
    pos_fill_result = service.wait_for_orders_pos_filled(
        ["<order_id_1>", "<order_id_2>"],
        any_filled=True,
        timeout=3,
    )
    print(position)
    print(strategy_position)
    print(strategy_positions)
    print(effective_size)
    print(fill_result)
    print(fill_result.is_filled("<order_id_1>"))
    print(fill_result.get("<order_id_1>"))
    print(pos_fill_result)
    print(order)
    if position:
        print("size(post-fee):", position.size)
        print("size(pre-fee):", position.original_size)
        print("fee_amount:", position.fee_amount)
    service.show_positions(limit=10)
    service.show_orders(limit=10)
    
    # Blocking: Wait for position/order updates (with timeout)
    position: UserPosition = service.blocking_get_position("<token_id>", timeout=5)
    order: OrderMessage = service.blocking_get_order("<order_id>", timeout=3)
    print(position)
    print(order)
    
    # Optional: If you open new positions/orders and want to monitor them via HTTP fallback
    # service.add_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
    # service.set_http_listen(
    #     market_ids=["<condition_id>"],
    #     order_ids=["<order_id>"],
    #     group="strategy-a",
    # )
    # service.clear_http(group="strategy-a")
    # service.remove_http_listen(market_ids=["<condition_id>"], order_ids=["<order_id>"])
    # service.clear_http()  # Clear all monitoring items, threads continue running
```

Important:
- When `enable_fee_calc=True`, you must register market fee metadata with `set_market_fee_schedule(...)` or `set_market_fee_schedules(...)`.
- `get_position()` does not fetch `/markets` automatically.
- 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.
- If order WS updates may arrive before trade aggregation, use `get_effective_position_size(...)` to compare `position.original_size` and `order.size_matched` safely.
- Use `wait_for_orders_filled(...)` when you care about order fill progress; use `wait_for_orders_pos_filled(...)` when you need the position aggregate (`position.original_size`) to be synchronized before continuing.
- 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.
- If a market is missing `feeSchedule`, fee calculation is skipped for that market and a warning is logged once.

Where does `feeSchedule` come from:
- Fetch a market or event from the Gamma API, then read the market object's `feeSchedule`.
- Your trade payload uses `trade.market` as the market `conditionId`, so register fee metadata with `conditionId` as the key.
- Official docs:
  [Fees](https://docs.polymarket.com/trading/fees),
  [Get event by id](https://docs.polymarket.com/api-reference/events/get-event-by-id),
  [List markets](https://docs.polymarket.com/api-reference/markets/list-markets),
  [Get market by slug](https://docs.polymarket.com/api-reference/markets/get-market-by-slug)

Example: fetch an event and register all nested market fee schedules

```python
import requests

event = requests.get(
    "https://gamma-api.polymarket.com/events/<event_id>",
    timeout=10,
).json()

fee_schedule_map = {
    market["conditionId"]: market.get("feeSchedule")
    for market in event.get("markets", [])
    if market.get("feeSchedule")
}

service.set_market_fee_schedules(fee_schedule_map)
```

Example: fetch a single market and register its fee schedule

```python
import requests

market = requests.get(
    "https://gamma-api.polymarket.com/markets/slug/<market-slug>",
    timeout=10,
).json()

service.set_market_fee_schedule(
    market["conditionId"],
    market.get("feeSchedule"),
)
```



Example output:

```shell
OrderMessage(
  type: 'update',
  event_type: 'order',
  asset_id: '7718951783559279583290056782453440...',
  associate_trades: ['8bf02a75-5...'],
  id: '0x74a71abb9efe59c994e0...',
  market: '0x3b7e9926575eb7fae2...',
  order_owner: None,
  original_size: 37.5,
  outcome: 'Up',
  owner: '',
  price: 0.52,
  side: 'BUY',
  size_matched: 37.5,
  timestamp: 0.0,
  filled: True,
  status: 'MATCHED',
  created_at: datetime.datetime(2025, 12, 8, 9, 44, 50, tzinfo=TzInfo(0))
)
UserPosition(
  price: 0.0,
  size: 0.0,
  original_size: 0.0,
  volume: 0.0,
  fee_amount: 0.0,
  sellable_size: 0.0,
  token_id: '',
  last_update: 0.0,
  market_id: None,
  outcome: None,
  created_at: None,
  has_failed: False,
  failed_trades: []
)
```


**Full example (`examples/example.py`)**

## Pretty printing

```python
service.show_positions(limit=10)
service.show_orders(limit=10)
```

![Positions Table](asset/show_position.png)

## **⚠️ Fee notice (taker fee / maker rebate)**
---

Some Polymarket markets enable taker fee / maker rebate. This library supports fee calculation from market `feeSchedule` data:

- Enable with `enable_fee_calc=True`
- Register `condition_id -> feeSchedule` through `service.set_market_fee_schedule(...)` or `service.set_market_fee_schedules(...)`
- This registration step is required if you want fee-aware positions; the watcher does not auto-fetch `/markets`
- In practice, use the Gamma market/event response's `market.get("feeSchedule")`
- Optionally override the fee handler with `fee_calc_fn`
- Disable (default) if you prefer pre-fee positions
- Returned position fields:
  `size` = post-fee net size, `original_size` = pre-fee net size, `fee_amount` = accumulated fee amount

Default fee formula (when `fee_calc_fn` is not provided):
`fee = size * rate * price * (1 - price)`.

On taker buys, the fee is deducted in shares, so `size` is reduced by `fee / price`.
On taker sells, the fee is charged in USDC, so position size is unchanged and only `fee_amount` increases.

---

## Position Initialization

When `init_positions=True`, the service will:
- Fetch current positions via the official Polymarket API (`/positions`)
- Create fake trades from position data to maintain compatibility with existing trade-based calculations
- Skip positions with `currentValue = 0` (empty positions)
- Optionally add condition IDs to HTTP monitoring if `add_init_positions_to_http=True`

The HTTP fallback polling threads run persistently throughout the `with` statement lifecycle. You can dynamically add/remove markets and orders without restarting threads.

> Note: If you start the watcher before any positions exist, set `init_positions=False`. The HTTP fallback can be enabled independently and will start with empty monitoring sets if needed.

## Configuration

### Service Parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `init_positions` | bool | False | Initialize positions via official Polymarket API on startup |
| `enable_http_fallback` | bool | False | Enable persistent HTTP polling threads as WebSocket fallback |
| `http_poll_interval` | float | 3.0 | HTTP polling interval in seconds |
| `add_init_positions_to_http` | bool | False | Automatically add condition IDs from initialized positions to HTTP monitoring |
| `enable_fee_calc` | bool | False | Apply fee adjustments using registered market `feeSchedule` data |
| `market_fee_schedules` | mapping | None | Optional initial `condition_id -> feeSchedule` mapping |
| `fee_calc_fn` | callable | None | Custom fee function: `(size, price, side, fee_schedule) -> (new_size, fee_amount)` |

### Environment Variables

| Environment variable | Description |
| --- | --- |
| `poly_position_watcher_LOG_LEVEL` | Log level, default `INFO` |

To set a proxy for WebSocket connections, build a dict before creating `PositionWatcherService` and pass it as `wss_proxies`:

```python
PROXY = {"http_proxy_host": "127.0.0.1", "http_proxy_port": 7890}
service = PositionWatcherService(client, wss_proxies=PROXY)
```

## Dependencies

- [`py-clob-client`](https://github.com/Polymarket/py-clob-client)
- [`pydantic`](https://docs.pydantic.dev/)
- [`websocket-client`](https://github.com/websocket-client/websocket-client)
- [`requests`](https://requests.readthedocs.io/en/latest/)

## Layout

```
poly_position_watcher/
├── api_worker.py          # HTTP backfill and context management
├── position_service.py    # Core entry; maintains position/order caches
├── trade_calculator.py    # Position calculation utils
├── wss_worker.py          # WebSocket client implementation
├── common/                # Logging and enums
└── schema/                # Pydantic models
```

## License

MIT
