Skip to main content

nautilus_polymarket/python/
positions.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings for Deposit Wallet position operations.
17
18use std::sync::Arc;
19
20use nautilus_core::{env::get_or_env_var, python::to_pyvalue_err, string::secret::SecretString};
21use nautilus_network::websocket::proxy::ProxyUrl;
22use pyo3::prelude::*;
23use rust_decimal::Decimal;
24
25use crate::{
26    common::credential::{EvmPrivateKey, RelayerApiKey, credential_env_vars},
27    positions::{
28        PolymarketPositionClient, PolymarketPositionOutcome, PolymarketPositionTransaction,
29    },
30};
31
32/// Terminal result of a Polymarket split, merge, or redeem operation.
33#[pyclass(
34    module = "nautilus_trader.adapters.polymarket",
35    name = "PolymarketPositionOutcome",
36    frozen
37)]
38#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")]
39#[derive(Clone, Debug)]
40pub struct PyPolymarketPositionOutcome {
41    inner: PolymarketPositionOutcome,
42}
43
44#[pymethods]
45#[pyo3_stub_gen::derive::gen_stub_pymethods]
46impl PyPolymarketPositionOutcome {
47    /// Relayer terminal status: `confirmed`, `failed`, or `invalid`.
48    #[getter]
49    fn status(&self) -> &'static str {
50        match self.inner {
51            PolymarketPositionOutcome::Confirmed { .. } => "confirmed",
52            PolymarketPositionOutcome::Failed { .. } => "failed",
53            PolymarketPositionOutcome::Invalid { .. } => "invalid",
54        }
55    }
56
57    /// Relayer transaction identifier.
58    #[getter]
59    fn transaction_id(&self) -> &str {
60        match &self.inner {
61            PolymarketPositionOutcome::Confirmed { transaction_id, .. }
62            | PolymarketPositionOutcome::Failed { transaction_id, .. }
63            | PolymarketPositionOutcome::Invalid { transaction_id, .. } => transaction_id,
64        }
65    }
66
67    /// On-chain transaction hash when the Relayer supplied one.
68    #[getter]
69    fn transaction_hash(&self) -> Option<&str> {
70        match &self.inner {
71            PolymarketPositionOutcome::Confirmed {
72                transaction_hash, ..
73            }
74            | PolymarketPositionOutcome::Failed {
75                transaction_hash, ..
76            } => transaction_hash.as_deref(),
77            PolymarketPositionOutcome::Invalid { .. } => None,
78        }
79    }
80
81    /// Relayer error detail when present.
82    #[getter]
83    fn error_msg(&self) -> Option<&str> {
84        match &self.inner {
85            PolymarketPositionOutcome::Failed { error_msg, .. }
86            | PolymarketPositionOutcome::Invalid { error_msg, .. } => error_msg.as_deref(),
87            PolymarketPositionOutcome::Confirmed { .. } => None,
88        }
89    }
90
91    fn __repr__(&self) -> String {
92        format!(
93            "PolymarketPositionOutcome(status='{}', transaction_id='{}', transaction_hash={:?}, error_msg={:?})",
94            self.status(),
95            self.transaction_id(),
96            self.transaction_hash(),
97            self.error_msg(),
98        )
99    }
100}
101
102/// Submitted position operation that can be polled to a terminal Relayer state.
103#[pyclass(
104    module = "nautilus_trader.adapters.polymarket",
105    name = "PolymarketPositionTransaction"
106)]
107#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")]
108#[derive(Debug)]
109pub struct PyPolymarketPositionTransaction {
110    transaction_id: String,
111    inner: Option<PolymarketPositionTransaction>,
112}
113
114#[pymethods]
115#[pyo3_stub_gen::derive::gen_stub_pymethods]
116impl PyPolymarketPositionTransaction {
117    /// Relayer transaction identifier returned at submit time.
118    #[getter]
119    fn transaction_id(&self) -> &str {
120        &self.transaction_id
121    }
122
123    /// Polls the Relayer until the transaction is confirmed, failed, or invalid.
124    fn wait<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
125        let inner = self
126            .inner
127            .take()
128            .ok_or_else(|| to_pyvalue_err("transaction wait() has already been consumed"))?;
129        pyo3_async_runtimes::tokio::future_into_py(py, async move {
130            let outcome = inner.wait().await.map_err(to_pyvalue_err)?;
131            Ok(PyPolymarketPositionOutcome { inner: outcome })
132        })
133    }
134
135    fn __repr__(&self) -> String {
136        match &self.inner {
137            Some(inner) => format!(
138                "PolymarketPositionTransaction(transaction_id='{}')",
139                inner.transaction_id()
140            ),
141            None => format!(
142                "PolymarketPositionTransaction(transaction_id='{}', consumed)",
143                self.transaction_id
144            ),
145        }
146    }
147}
148
149impl From<PolymarketPositionTransaction> for PyPolymarketPositionTransaction {
150    fn from(inner: PolymarketPositionTransaction) -> Self {
151        Self {
152            transaction_id: inner.transaction_id().to_string(),
153            inner: Some(inner),
154        }
155    }
156}
157
158/// Deposit Wallet client for split, merge, and redeem position operations.
159#[pyclass(
160    module = "nautilus_trader.adapters.polymarket",
161    name = "PolymarketPositionClient"
162)]
163#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")]
164#[derive(Debug)]
165pub struct PyPolymarketPositionClient {
166    inner: Arc<PolymarketPositionClient>,
167}
168
169#[pymethods]
170#[pyo3_stub_gen::derive::gen_stub_pymethods]
171impl PyPolymarketPositionClient {
172    #[new]
173    #[pyo3(signature = (
174        private_key=None,
175        funder=None,
176        relayer_api_key=None,
177        relayer_api_key_address=None,
178        base_url_relayer=None,
179        base_url_clob=None,
180        timeout_secs=None,
181        proxy_url=None,
182        base_url_rpc=None
183    ))]
184    #[allow(
185        clippy::too_many_arguments,
186        reason = "Python constructor mirrors optional credential fields"
187    )]
188    fn py_new(
189        private_key: Option<String>,
190        funder: Option<String>,
191        relayer_api_key: Option<String>,
192        relayer_api_key_address: Option<String>,
193        base_url_relayer: Option<String>,
194        base_url_clob: Option<String>,
195        timeout_secs: Option<u64>,
196        proxy_url: Option<String>,
197        base_url_rpc: Option<String>,
198    ) -> PyResult<Self> {
199        let private_key = private_key.map(SecretString::from);
200        let relayer_api_key = relayer_api_key.map(SecretString::from);
201        let (_, _, _, private_key_var, funder_var) = credential_env_vars();
202
203        let private_key = match private_key.filter(|value| !value.expose_secret().trim().is_empty())
204        {
205            Some(value) => value,
206            None => get_or_env_var(None, private_key_var)
207                .map(SecretString::from)
208                .map_err(to_pyvalue_err)?,
209        };
210
211        let private_key =
212            EvmPrivateKey::new(private_key.expose_secret()).map_err(to_pyvalue_err)?;
213
214        let funder = match funder.filter(|value| !value.trim().is_empty()) {
215            Some(value) => value,
216            None => get_or_env_var(None, funder_var).map_err(to_pyvalue_err)?,
217        };
218
219        let relayer_api_key = RelayerApiKey::resolve(relayer_api_key, relayer_api_key_address)
220            .map_err(to_pyvalue_err)?;
221        let proxy_url = proxy_url
222            .filter(|value| !value.trim().is_empty())
223            .map(ProxyUrl::parse)
224            .transpose()
225            .map_err(to_pyvalue_err)?;
226        let mut inner = PolymarketPositionClient::new(
227            &private_key,
228            &funder,
229            relayer_api_key,
230            base_url_relayer,
231            base_url_clob,
232            timeout_secs,
233            proxy_url,
234        )
235        .map_err(to_pyvalue_err)?;
236
237        if let Some(url) = base_url_rpc {
238            inner = inner.with_rpc_url(url);
239        }
240
241        Ok(Self {
242            inner: Arc::new(inner),
243        })
244    }
245
246    /// Splits `amount` pUSD into a complete set of outcome tokens.
247    fn split_position<'py>(
248        &self,
249        py: Python<'py>,
250        condition_id: String,
251        #[pyo3(from_py_with = extract_amount)] amount: Decimal,
252    ) -> PyResult<Bound<'py, PyAny>> {
253        let inner = self.inner.clone();
254        pyo3_async_runtimes::tokio::future_into_py(py, async move {
255            let tx = inner
256                .split_position(&condition_id, amount)
257                .await
258                .map_err(to_pyvalue_err)?;
259            Ok(PyPolymarketPositionTransaction::from(tx))
260        })
261    }
262
263    /// Merges `amount` complete sets of outcome tokens back into pUSD.
264    fn merge_positions<'py>(
265        &self,
266        py: Python<'py>,
267        condition_id: String,
268        #[pyo3(from_py_with = extract_amount)] amount: Decimal,
269    ) -> PyResult<Bound<'py, PyAny>> {
270        let inner = self.inner.clone();
271        pyo3_async_runtimes::tokio::future_into_py(py, async move {
272            let tx = inner
273                .merge_positions(&condition_id, amount)
274                .await
275                .map_err(to_pyvalue_err)?;
276            Ok(PyPolymarketPositionTransaction::from(tx))
277        })
278    }
279
280    /// Redeems both binary outcome balances for a resolved market.
281    fn redeem_positions<'py>(
282        &self,
283        py: Python<'py>,
284        condition_id: String,
285    ) -> PyResult<Bound<'py, PyAny>> {
286        let inner = self.inner.clone();
287        pyo3_async_runtimes::tokio::future_into_py(py, async move {
288            let tx = inner
289                .redeem_positions(&condition_id)
290                .await
291                .map_err(to_pyvalue_err)?;
292            Ok(PyPolymarketPositionTransaction::from(tx))
293        })
294    }
295}
296
297fn extract_amount(value: &Bound<'_, PyAny>) -> PyResult<Decimal> {
298    let amount: Decimal = value.extract()?;
299    if !amount.into_pyobject(value.py())?.eq(value)? {
300        return Err(to_pyvalue_err("amount cannot be represented exactly"));
301    }
302
303    Ok(amount)
304}
305
306#[cfg(test)]
307mod tests {
308    use pyo3::{exceptions::PyValueError, types::PyDict};
309    use rstest::rstest;
310    use rust_decimal_macros::dec;
311
312    use super::*;
313
314    #[rstest]
315    #[case("split_position")]
316    #[case("merge_positions")]
317    fn test_position_amount_rejects_rounding(#[case] method: &str) {
318        Python::initialize();
319        Python::attach(|py| {
320            let kwargs = PyDict::new(py);
321            kwargs
322                .set_item(
323                    "private_key",
324                    "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
325                )
326                .unwrap();
327            kwargs
328                .set_item("funder", "0x1111111111111111111111111111111111111111")
329                .unwrap();
330            kwargs
331                .set_item("relayer_api_key", "dummy-relayer-key")
332                .unwrap();
333            kwargs
334                .set_item(
335                    "relayer_api_key_address",
336                    "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
337                )
338                .unwrap();
339            let client = py
340                .get_type::<PyPolymarketPositionClient>()
341                .call((), Some(&kwargs))
342                .unwrap();
343            let amount = py
344                .import("decimal")
345                .unwrap()
346                .getattr("Decimal")
347                .unwrap()
348                .call1(("1.00000000000000000000000000001",))
349                .unwrap();
350            let error = client
351                .call_method1(
352                    method,
353                    (
354                        "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
355                        amount,
356                    ),
357                )
358                .unwrap_err();
359            assert!(error.is_instance_of::<PyValueError>(py));
360            assert!(
361                error
362                    .to_string()
363                    .contains("amount cannot be represented exactly")
364            );
365        });
366    }
367
368    #[rstest]
369    #[case("1.234567", dec!(1.234567))]
370    #[case("1.00000000000000000000000000000", dec!(1))]
371    #[case("1E-6", dec!(0.000001))]
372    #[case("1E+20", dec!(100000000000000000000))]
373    fn test_extract_amount_preserves_exact_values(#[case] input: &str, #[case] expected: Decimal) {
374        Python::initialize();
375        Python::attach(|py| {
376            let amount = py
377                .import("decimal")
378                .unwrap()
379                .getattr("Decimal")
380                .unwrap()
381                .call1((input,))
382                .unwrap();
383            assert_eq!(extract_amount(&amount).unwrap(), expected);
384        });
385    }
386}