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