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)]
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 #[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 #[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 #[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 #[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#[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 #[getter]
119 fn transaction_id(&self) -> &str {
120 &self.transaction_id
121 }
122
123 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#[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 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 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 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}