nautilus_polymarket/python/
positions.rs1use 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#[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 #[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 #[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 #[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 #[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#[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 #[getter]
121 fn transaction_id(&self) -> &str {
122 &self.transaction_id
123 }
124
125 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#[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 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 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 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}