revolut-x-python 1.0.0__tar.gz → 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/.agents/skills/revolut-x-library/SKILL.md +13 -9
  2. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/.agents/skills/revolut-x-library/references/types.md +3 -1
  3. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/CHANGELOG.md +24 -0
  4. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/PKG-INFO +1 -15
  5. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/pyproject.toml +0 -16
  6. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/__init__.py +4 -0
  7. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/_version.py +1 -1
  8. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/helpers.py +92 -1
  9. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/market.py +55 -33
  10. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/types.py +16 -1
  11. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x_python.egg-info/PKG-INFO +1 -15
  12. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x_python.egg-info/SOURCES.txt +0 -1
  13. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/tests/test_helpers.py +72 -0
  14. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/tests/test_market.py +69 -0
  15. revolut_x_python-1.0.0/MANIFEST.in +0 -18
  16. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/LICENSE +0 -0
  17. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/README.md +0 -0
  18. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/01_public_market_data.py +0 -0
  19. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/02_download_candles_stream.py +0 -0
  20. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/03_account_balances_and_history.py +0 -0
  21. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/04_place_and_cancel_orders.py +0 -0
  22. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/05_fee_calculator_and_validation.py +0 -0
  23. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/06_smart_maker_orders.py +0 -0
  24. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/examples/README.md +0 -0
  25. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/requirements.txt +0 -0
  26. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/setup.cfg +0 -0
  27. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/_auth.py +0 -0
  28. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/_http.py +0 -0
  29. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/account.py +0 -0
  30. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/client.py +0 -0
  31. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/exceptions.py +0 -0
  32. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/orders.py +0 -0
  33. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x/py.typed +0 -0
  34. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x_python.egg-info/dependency_links.txt +0 -0
  35. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x_python.egg-info/requires.txt +0 -0
  36. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/src/revolut_x_python.egg-info/top_level.txt +0 -0
  37. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/tests/__init__.py +0 -0
  38. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/tests/test_auth.py +0 -0
  39. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/tests/test_maker_strategy.py +0 -0
  40. {revolut_x_python-1.0.0 → revolut_x_python-1.0.1}/tests/test_orders.py +0 -0
@@ -136,10 +136,11 @@ book = client.get_order_book("BTC-EUR") # → OrderBook
136
136
  candles = client.get_candles("BTC-EUR", Interval.HOUR_1)
137
137
 
138
138
  # Public trades — single page + cursor.
139
- # start_date / end_date are Unix MILLISECONDS (int), never date strings —
140
- # a string like "2024-01-01" raises ValueError.
141
- import time
142
- day_ago = int(time.time() * 1000) - 86_400_000
139
+ # start_date / end_date accept a datetime, a date, an ISO-8601 string, or a Unix
140
+ # timestamp in MILLISECONDS. Numbers are never rescaled, so a seconds timestamp
141
+ # silently means 1970 — prefer a datetime when you are not sure.
142
+ from datetime import datetime, timedelta, timezone
143
+ day_ago = datetime.now(timezone.utc) - timedelta(days=1)
143
144
  trades, next_cursor = client.get_trades("BTC-EUR", start_date=day_ago, limit=100)
144
145
 
145
146
  # Auto-paginating trade iterator
@@ -170,7 +171,10 @@ Each candle is a dict — **not** a list or tuple:
170
171
  - `start` — candle **open** time, `int` Unix **milliseconds**, always **UTC**
171
172
  - `open` / `high` / `low` / `close` / `volume` — **strings**; `volume` is in the
172
173
  base currency. Wrap in `Decimal`, never `float`.
173
- - `since` / `until` are also Unix **milliseconds** (`int`), inclusive on both ends.
174
+ - `since` / `until` are inclusive on both ends and accept a `datetime`, a `date`,
175
+ an ISO-8601 string, or an `int` of Unix **milliseconds**. Naive `datetime`s and
176
+ strings are read as **UTC**; raw numbers are taken as milliseconds as-is
177
+ (`revolut_x.to_timestamp_ms` does the conversion if you need it yourself).
174
178
 
175
179
  Three things that make naive candle code wrong:
176
180
 
@@ -194,7 +198,7 @@ regardless of who runs it.
194
198
 
195
199
  ```python
196
200
  import time
197
- from datetime import datetime, timezone, time as dtime
201
+ from datetime import datetime, timedelta, timezone, time as dtime
198
202
  from decimal import Decimal
199
203
  from revolut_x import RevolutXClient, Interval
200
204
 
@@ -205,10 +209,10 @@ interval = Interval.HOUR_1
205
209
  # otherwise the row set shifts with the machine's local timezone.
206
210
  day = datetime.now(timezone.utc).date() # or date(2026, 10, 10)
207
211
  midnight = datetime.combine(day, dtime.min, tzinfo=timezone.utc)
208
- since = int(midnight.timestamp() * 1000)
209
- until = min(since + 86_400_000 - 1, int(time.time() * 1000))
212
+ until = min(midnight + timedelta(days=1) - timedelta(milliseconds=1),
213
+ datetime.now(timezone.utc))
210
214
 
211
- candles = client.get_candles("BTC-EUR", interval, since=since, until=until)
215
+ candles = client.get_candles("BTC-EUR", interval, since=midnight, until=until)
212
216
 
213
217
  interval_ms = int(interval) * 60_000
214
218
  now_ms = int(time.time() * 1000)
@@ -39,7 +39,9 @@ class Interval(int, Enum):
39
39
  ## TypedDicts (API Responses)
40
40
 
41
41
  > All numeric values are **strings** (not floats) to preserve decimal precision.
42
- > Timestamps are `int` (Unix milliseconds).
42
+ > Timestamps in responses are `int` (Unix milliseconds). On the way *in*, time
43
+ > range arguments are typed `TimeLike` — a `datetime`, a `date`, an ISO-8601
44
+ > string, or an `int` of milliseconds.
43
45
 
44
46
  ### Candle
45
47
 
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ### Added
13
+ - **`datetime` support for time ranges**: `since` / `until` on `get_candles()`
14
+ and `iter_candles()`, and `start_date` / `end_date` on `get_trades()` and
15
+ `iter_trades()`, now accept a `datetime`, a `date`, an ISO-8601 string
16
+ (trailing `Z` included), a digits-only string of 12+ digits (milliseconds, for
17
+ values arriving from JSON or query parameters) or the millisecond `int` they
18
+ always took. A shorter run of digits — `'2024'`, `'20240101'` — raises
19
+ `ValueError` instead of silently resolving to 1970 or to a date, because it is
20
+ ambiguous between the two; note this also makes `'20240101'` behave the same
21
+ on every supported interpreter, where `datetime.fromisoformat` accepted it as
22
+ a basic-format date on 3.11+ but rejected it on 3.10. Naive
23
+ `datetime`s and strings are interpreted as **UTC** rather than the machine's
24
+ local timezone, so the same code selects the same window everywhere. Raw
25
+ numbers are still passed through untouched — seconds are deliberately *not*
26
+ auto-detected, because inferring the unit from the magnitude would silently
27
+ change the meaning of the caller's input.
28
+ - **`to_timestamp_ms()`** (exported from `revolut_x`, together with the
29
+ `TimeLike` type alias) performs that conversion and is reusable in
30
+ application code.
31
+
32
+ Existing millisecond integers keep working unchanged.
33
+
10
34
  ## [1.0.0] - 2026-10-11
11
35
 
12
36
  First release published to **PyPI**. Versions `0.0.1` – `0.1.0` were only ever
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revolut-x-python
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Python SDK for the Revolut X Crypto Exchange REST API (v1.0)
5
5
  Author: Jan Přikryl
6
6
  License-Expression: MIT
@@ -10,20 +10,6 @@ Project-URL: Repository, https://github.com/janprikryl1/revolut-x-python
10
10
  Project-URL: Changelog, https://github.com/janprikryl1/revolut-x-python/blob/main/CHANGELOG.md
11
11
  Project-URL: Issues, https://github.com/janprikryl1/revolut-x-python/issues
12
12
  Keywords: revolut,revolut-x,crypto,exchange,trading,api,sdk,bitcoin
13
- Classifier: Development Status :: 5 - Production/Stable
14
- Classifier: Intended Audience :: Developers
15
- Classifier: Intended Audience :: Financial and Insurance Industry
16
- Classifier: Operating System :: OS Independent
17
- Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3 :: Only
19
- Classifier: Programming Language :: Python :: 3.10
20
- Classifier: Programming Language :: Python :: 3.11
21
- Classifier: Programming Language :: Python :: 3.12
22
- Classifier: Programming Language :: Python :: 3.13
23
- Classifier: Programming Language :: Python :: 3.14
24
- Classifier: Topic :: Office/Business :: Financial :: Investment
25
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
- Classifier: Typing :: Typed
27
13
  Requires-Python: >=3.10
28
14
  Description-Content-Type: text/markdown
29
15
  License-File: LICENSE
@@ -15,22 +15,6 @@ authors = [
15
15
  { name = "Jan Přikryl" },
16
16
  ]
17
17
  keywords = ["revolut", "revolut-x", "crypto", "exchange", "trading", "api", "sdk", "bitcoin"]
18
- classifiers = [
19
- "Development Status :: 5 - Production/Stable",
20
- "Intended Audience :: Developers",
21
- "Intended Audience :: Financial and Insurance Industry",
22
- "Operating System :: OS Independent",
23
- "Programming Language :: Python :: 3",
24
- "Programming Language :: Python :: 3 :: Only",
25
- "Programming Language :: Python :: 3.10",
26
- "Programming Language :: Python :: 3.11",
27
- "Programming Language :: Python :: 3.12",
28
- "Programming Language :: Python :: 3.13",
29
- "Programming Language :: Python :: 3.14",
30
- "Topic :: Office/Business :: Financial :: Investment",
31
- "Topic :: Software Development :: Libraries :: Python Modules",
32
- "Typing :: Typed",
33
- ]
34
18
  dependencies = [
35
19
  "requests>=2.31.0",
36
20
  "cryptography>=41.0.0",
@@ -39,6 +39,7 @@ from revolut_x.types import (
39
39
  OrderResponse,
40
40
  PairConfig,
41
41
  Ticker,
42
+ TimeLike,
42
43
  Trade,
43
44
  )
44
45
  from revolut_x.helpers import (
@@ -51,6 +52,7 @@ from revolut_x.helpers import (
51
52
  build_order,
52
53
  calculate_maker_price,
53
54
  normalize_symbol,
55
+ to_timestamp_ms,
54
56
  )
55
57
  from revolut_x.exceptions import (
56
58
  ApiError,
@@ -76,6 +78,7 @@ __all__ = [
76
78
  "FeeCalculator",
77
79
  "FeeEstimate",
78
80
  "normalize_symbol",
81
+ "to_timestamp_ms",
79
82
  "calculate_maker_price",
80
83
  "build_order",
81
84
  "build_market_order",
@@ -92,6 +95,7 @@ __all__ = [
92
95
  "OrderResponse",
93
96
  "PairConfig",
94
97
  "Ticker",
98
+ "TimeLike",
95
99
  "Trade",
96
100
  # Exceptions
97
101
  "RevolutXError",
@@ -7,4 +7,4 @@ three used to be maintained by hand and had drifted apart (0.0.6 / 0.0.8 /
7
7
  0.1.0 simultaneously).
8
8
  """
9
9
 
10
- __version__ = "1.0.0"
10
+ __version__ = "1.0.1"
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
  import uuid
3
3
  from dataclasses import dataclass
4
+ from datetime import date, datetime, time, timezone
4
5
  from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
5
6
  from typing import Any
6
7
 
7
- from revolut_x.types import OrderSide, OrderType, TimeInForce
8
+ from revolut_x.types import OrderSide, OrderType, TimeInForce, TimeLike
8
9
  from revolut_x.exceptions import OrderValidationError
9
10
 
10
11
 
@@ -35,6 +36,96 @@ def normalize_symbol(symbol: str) -> str:
35
36
  return symbol.replace("/", "-").upper()
36
37
 
37
38
 
39
+ #: Minimum number of digits a digits-only string needs before it is read as a
40
+ #: millisecond timestamp. 12 digits reach back to 1973 and real timestamps have
41
+ #: had 13 since 2001, so anything shorter is almost certainly a date that lost
42
+ #: its separators (``'2024'``, ``'20240101'``) rather than an instant in 1970.
43
+ MIN_MS_DIGITS = 12
44
+
45
+
46
+ def to_timestamp_ms(value: TimeLike) -> int:
47
+ """Converts a point in time to a Unix timestamp in milliseconds.
48
+
49
+ Used by the market data methods so that time ranges can be expressed with
50
+ ``datetime`` objects instead of raw millisecond integers.
51
+
52
+ Args:
53
+ value: One of
54
+
55
+ * ``int`` / ``float`` — already a Unix timestamp in
56
+ **milliseconds**; returned as an int (floats are truncated).
57
+ Seconds are *not* auto-detected — multiply by 1000 yourself.
58
+ * :class:`~datetime.datetime` — timezone-aware values use their own
59
+ offset, naive values are interpreted as **UTC**.
60
+ * :class:`~datetime.date` — midnight UTC on that day.
61
+ * ``str`` — an ISO-8601 timestamp (a trailing ``'Z'`` is accepted);
62
+ naive strings are again interpreted as UTC. A digits-only string
63
+ of at least :data:`MIN_MS_DIGITS` digits is read as milliseconds
64
+ instead, for values arriving from JSON or query parameters.
65
+
66
+ Returns:
67
+ int: The Unix timestamp in milliseconds.
68
+
69
+ Raises:
70
+ TypeError: If the value is not one of the supported types.
71
+ ValueError: If a string is neither a valid ISO-8601 timestamp nor a
72
+ long enough run of digits.
73
+
74
+ Example:
75
+ >>> from datetime import datetime, timezone
76
+ >>> to_timestamp_ms(datetime(2024, 1, 1, tzinfo=timezone.utc))
77
+ 1704067200000
78
+ >>> to_timestamp_ms("2024-01-01")
79
+ 1704067200000
80
+ >>> to_timestamp_ms(1704067200000)
81
+ 1704067200000
82
+ >>> to_timestamp_ms("1704067200000")
83
+ 1704067200000
84
+ """
85
+ if isinstance(value, bool):
86
+ raise TypeError("Timestamp must be a number, datetime, date, or ISO-8601 string")
87
+
88
+ if isinstance(value, (int, float)):
89
+ return int(value)
90
+
91
+ if isinstance(value, str):
92
+ text = value.strip()
93
+
94
+ digits = text[1:] if text[:1] in "+-" else text
95
+ # str.isdigit() also accepts non-ASCII digits that int() cannot parse.
96
+ if digits.isascii() and digits.isdigit():
97
+ if len(digits) >= MIN_MS_DIGITS:
98
+ return int(text)
99
+ raise ValueError(
100
+ f"Ambiguous numeric timestamp string: {value!r}. A digits-only string is "
101
+ f"read as Unix milliseconds and needs at least {MIN_MS_DIGITS} digits — "
102
+ f"anything shorter lands in 1970 and is far more likely a date that lost "
103
+ f"its separators. Pass an int for milliseconds, or a date like "
104
+ f"'2024-01-01'."
105
+ )
106
+
107
+ if text.endswith(("Z", "z")):
108
+ text = f"{text[:-1]}+00:00"
109
+ try:
110
+ value = datetime.fromisoformat(text)
111
+ except ValueError as exc:
112
+ raise ValueError(f"Invalid ISO-8601 timestamp: {value!r}") from exc
113
+
114
+ # datetime is a subclass of date, so it has to be checked first.
115
+ if isinstance(value, datetime):
116
+ if value.tzinfo is None:
117
+ value = value.replace(tzinfo=timezone.utc)
118
+ return int(value.timestamp() * 1000)
119
+
120
+ if isinstance(value, date):
121
+ return int(datetime.combine(value, time.min, tzinfo=timezone.utc).timestamp() * 1000)
122
+
123
+ raise TypeError(
124
+ f"Timestamp must be a number, datetime, date, or ISO-8601 string, "
125
+ f"got {type(value).__name__}"
126
+ )
127
+
128
+
38
129
  class OrderPayloadBuilder:
39
130
  """Builder and validator for Revolut X API order payloads."""
40
131
 
@@ -19,9 +19,11 @@ from revolut_x.types import (
19
19
  OrderBook,
20
20
  PairConfig,
21
21
  Ticker,
22
+ TimeLike,
22
23
  Trade,
23
24
  )
24
25
  from revolut_x.helpers import normalize_symbol as _normalize_symbol
26
+ from revolut_x.helpers import to_timestamp_ms as _to_ms
25
27
 
26
28
  if TYPE_CHECKING:
27
29
  from revolut_x._http import HttpClient
@@ -232,8 +234,8 @@ class MarketMixin:
232
234
  symbol: str,
233
235
  interval: Interval | int = Interval.HOUR_1,
234
236
  *,
235
- since: int | None = None,
236
- until: int | None = None,
237
+ since: TimeLike | None = None,
238
+ until: TimeLike | None = None,
237
239
  ) -> list[Candle]:
238
240
  """Fetch up to 1000 OHLCV candlesticks for a trading pair.
239
241
 
@@ -242,8 +244,11 @@ class MarketMixin:
242
244
  interval: Candle interval — use the :class:`~revolut_x.types.Interval`
243
245
  enum (e.g. ``Interval.MIN_1``, ``Interval.HOUR_1``) or an integer
244
246
  number of minutes.
245
- since: Start time as Unix timestamp in **milliseconds** (inclusive).
246
- until: End time as Unix timestamp in **milliseconds** (inclusive).
247
+ since: Start time, inclusive — a Unix timestamp in
248
+ **milliseconds**, a :class:`~datetime.datetime` /
249
+ :class:`~datetime.date`, or an ISO-8601 string (see
250
+ :func:`~revolut_x.helpers.to_timestamp_ms`).
251
+ until: End time, inclusive — same accepted formats as ``since``.
247
252
 
248
253
  Returns:
249
254
  A list of :class:`~revolut_x.types.Candle` dictionaries with keys:
@@ -263,20 +268,21 @@ class MarketMixin:
263
268
 
264
269
  Example::
265
270
 
271
+ >>> from datetime import datetime, timedelta, timezone
266
272
  >>> from revolut_x import Interval
267
273
  >>> # last 500 hours — comfortably inside the 1000-candle limit
268
- >>> now_ms = int(time.time() * 1000)
274
+ >>> start = datetime.now(timezone.utc) - timedelta(hours=500)
269
275
  >>> candles = client.get_candles("BTC-EUR", Interval.HOUR_1,
270
- ... since=now_ms - 500 * 3_600_000)
276
+ ... since=start)
271
277
  >>> for c in candles[:3]:
272
278
  ... print(f"{c['start']}: O={c['open']} H={c['high']}")
273
279
  """
274
280
  api_symbol = _normalize_symbol(symbol)
275
281
  params: dict[str, Any] = {"interval": int(interval)}
276
282
  if since is not None:
277
- params["since"] = int(since)
283
+ params["since"] = _to_ms(since)
278
284
  if until is not None:
279
- params["until"] = int(until)
285
+ params["until"] = _to_ms(until)
280
286
 
281
287
  _status, data = self._http.request(
282
288
  "GET",
@@ -293,8 +299,8 @@ class MarketMixin:
293
299
  symbol: str,
294
300
  interval: Interval | int = Interval.HOUR_1,
295
301
  *,
296
- since: int | None = None,
297
- until: int | None = None,
302
+ since: TimeLike | None = None,
303
+ until: TimeLike | None = None,
298
304
  ) -> Iterator[Candle]:
299
305
  """Iterate over all candles in a time range, automatically paginating.
300
306
 
@@ -305,8 +311,10 @@ class MarketMixin:
305
311
  Args:
306
312
  symbol: Trading pair (e.g. ``'BTC-EUR'``).
307
313
  interval: Candle interval (see :meth:`get_candles`).
308
- since: Start time as Unix timestamp in **milliseconds**.
309
- until: End time as Unix timestamp in **milliseconds**.
314
+ since: Start time — Unix timestamp in **milliseconds**,
315
+ :class:`~datetime.datetime` / :class:`~datetime.date`, or an
316
+ ISO-8601 string.
317
+ until: End time — same accepted formats as ``since``.
310
318
 
311
319
  Yields:
312
320
  :class:`~revolut_x.types.Candle` dictionaries in chronological
@@ -316,7 +324,7 @@ class MarketMixin:
316
324
  Example::
317
325
 
318
326
  >>> for candle in client.iter_candles("BTC-EUR", Interval.MIN_1,
319
- ... since=1700000000000):
327
+ ... since="2024-01-01"):
320
328
  ... process(candle) # each candle is a dict
321
329
  """
322
330
  interval_ms = int(interval) * 60 * 1000 # interval in milliseconds
@@ -324,19 +332,22 @@ class MarketMixin:
324
332
  # window is inclusive on both ends — so 999 intervals == 1000 candles.
325
333
  max_batch_ms = 999 * interval_ms
326
334
 
327
- if since is None:
335
+ since_ms = _to_ms(since) if since is not None else None
336
+ until_ms = _to_ms(until) if until is not None else None
337
+
338
+ if since_ms is None:
328
339
  # Without a start there is nothing to paginate: a single request
329
340
  # already returns the most recent batch.
330
- yield from self.get_candles(symbol, interval, until=until)
341
+ yield from self.get_candles(symbol, interval, until=until_ms)
331
342
  return
332
343
 
333
- current_since = since
344
+ current_since = since_ms
334
345
 
335
346
  while True:
336
347
  # Walk fixed-size windows forward rather than chaining off the last
337
348
  # returned candle, so that gaps in the series (illiquid pairs with
338
349
  # no trades in an interval) cannot cut the iteration short.
339
- hard_end = until if until is not None else int(time.time() * 1000)
350
+ hard_end = until_ms if until_ms is not None else int(time.time() * 1000)
340
351
  batch_until = min(current_since + max_batch_ms, hard_end)
341
352
  # The API rejects a window where 'until' equals 'since', which is
342
353
  # what a final single-candle window would produce. Always ask for
@@ -346,8 +357,8 @@ class MarketMixin:
346
357
  batch = self.get_candles(
347
358
  symbol, interval, since=current_since, until=batch_until
348
359
  )
349
- if until is not None:
350
- batch = [c for c in batch if int(c.get("start", 0)) <= until]
360
+ if until_ms is not None:
361
+ batch = [c for c in batch if int(c.get("start", 0)) <= until_ms]
351
362
 
352
363
  yield from batch
353
364
 
@@ -364,8 +375,8 @@ class MarketMixin:
364
375
  self,
365
376
  symbol: str,
366
377
  *,
367
- start_date: int | None = None,
368
- end_date: int | None = None,
378
+ start_date: TimeLike | None = None,
379
+ end_date: TimeLike | None = None,
369
380
  cursor: str | None = None,
370
381
  limit: int = MAX_TRADES_PER_PAGE,
371
382
  ) -> tuple[list[Trade], str | None]:
@@ -373,8 +384,11 @@ class MarketMixin:
373
384
 
374
385
  Args:
375
386
  symbol: Trading pair (e.g. ``'BTC-EUR'``).
376
- start_date: Start time as Unix timestamp in **milliseconds**.
377
- end_date: End time as Unix timestamp in **milliseconds**.
387
+ start_date: Start time — Unix timestamp in **milliseconds**,
388
+ :class:`~datetime.datetime` / :class:`~datetime.date`, or an
389
+ ISO-8601 string (see
390
+ :func:`~revolut_x.helpers.to_timestamp_ms`).
391
+ end_date: End time — same accepted formats as ``start_date``.
378
392
  cursor: Pagination cursor from a previous response's
379
393
  ``metadata.next_cursor`` field.
380
394
  limit: Maximum number of trades to return. The exchange accepts
@@ -401,9 +415,9 @@ class MarketMixin:
401
415
  "limit": max(1, min(limit, MAX_TRADES_PER_PAGE)),
402
416
  }
403
417
  if start_date is not None:
404
- params["start_date"] = int(start_date)
418
+ params["start_date"] = _to_ms(start_date)
405
419
  if end_date is not None:
406
- params["end_date"] = int(end_date)
420
+ params["end_date"] = _to_ms(end_date)
407
421
  if cursor:
408
422
  params["cursor"] = cursor
409
423
 
@@ -424,8 +438,8 @@ class MarketMixin:
424
438
  self,
425
439
  symbol: str,
426
440
  *,
427
- start_date: int | None = None,
428
- end_date: int | None = None,
441
+ start_date: TimeLike | None = None,
442
+ end_date: TimeLike | None = None,
429
443
  limit: int = MAX_TRADES_PER_PAGE,
430
444
  ) -> Iterator[Trade]:
431
445
  """Iterate over all public trades in a time range, automatically paginating.
@@ -435,8 +449,10 @@ class MarketMixin:
435
449
 
436
450
  Args:
437
451
  symbol: Trading pair (e.g. ``'BTC-EUR'``).
438
- start_date: Start time as Unix timestamp in **milliseconds**.
439
- end_date: End time as Unix timestamp in **milliseconds**.
452
+ start_date: Start time — Unix timestamp in **milliseconds**,
453
+ :class:`~datetime.datetime` / :class:`~datetime.date`, or an
454
+ ISO-8601 string.
455
+ end_date: End time — same accepted formats as ``start_date``.
440
456
  limit: Page size per request (1–100, clamped into that range).
441
457
 
442
458
  Yields:
@@ -444,17 +460,23 @@ class MarketMixin:
444
460
 
445
461
  Example::
446
462
 
447
- >>> for trade in client.iter_trades("BTC-EUR",
448
- ... start_date=1700000000000):
463
+ >>> from datetime import datetime, timezone
464
+ >>> start = datetime(2024, 1, 1, tzinfo=timezone.utc)
465
+ >>> for trade in client.iter_trades("BTC-EUR", start_date=start):
449
466
  ... print(f"{trade['price']} EUR x {trade['quantity']} BTC")
450
467
  """
468
+ # Convert once so every page sends the same window and a bad value
469
+ # fails on the first iteration rather than halfway through.
470
+ start_ms = _to_ms(start_date) if start_date is not None else None
471
+ end_ms = _to_ms(end_date) if end_date is not None else None
472
+
451
473
  cursor: str | None = None
452
474
 
453
475
  while True:
454
476
  trades, next_cursor = self.get_trades(
455
477
  symbol,
456
- start_date=start_date,
457
- end_date=end_date,
478
+ start_date=start_ms,
479
+ end_date=end_ms,
458
480
  cursor=cursor,
459
481
  limit=limit,
460
482
  )
@@ -7,11 +7,26 @@ This module provides:
7
7
  * **TypedDicts** — :class:`Candle`, :class:`Trade`, :class:`Ticker`,
8
8
  :class:`Balance`, :class:`PairConfig`, etc. for IDE autocompletion
9
9
  on API response data.
10
+ * **Type aliases** — :data:`TimeLike` for time range parameters.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
14
+ from datetime import date, datetime
13
15
  from enum import Enum
14
- from typing import TypedDict
16
+ from typing import TypedDict, Union
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Type aliases
21
+ # ---------------------------------------------------------------------------
22
+
23
+ TimeLike = Union[int, float, datetime, date, str]
24
+ """Anything accepted where the API expects a Unix timestamp in milliseconds.
25
+
26
+ Numbers are taken as milliseconds as-is; :class:`~datetime.datetime` and
27
+ :class:`~datetime.date` objects and ISO-8601 strings are converted by
28
+ :func:`~revolut_x.helpers.to_timestamp_ms`.
29
+ """
15
30
 
16
31
 
17
32
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revolut-x-python
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Python SDK for the Revolut X Crypto Exchange REST API (v1.0)
5
5
  Author: Jan Přikryl
6
6
  License-Expression: MIT
@@ -10,20 +10,6 @@ Project-URL: Repository, https://github.com/janprikryl1/revolut-x-python
10
10
  Project-URL: Changelog, https://github.com/janprikryl1/revolut-x-python/blob/main/CHANGELOG.md
11
11
  Project-URL: Issues, https://github.com/janprikryl1/revolut-x-python/issues
12
12
  Keywords: revolut,revolut-x,crypto,exchange,trading,api,sdk,bitcoin
13
- Classifier: Development Status :: 5 - Production/Stable
14
- Classifier: Intended Audience :: Developers
15
- Classifier: Intended Audience :: Financial and Insurance Industry
16
- Classifier: Operating System :: OS Independent
17
- Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3 :: Only
19
- Classifier: Programming Language :: Python :: 3.10
20
- Classifier: Programming Language :: Python :: 3.11
21
- Classifier: Programming Language :: Python :: 3.12
22
- Classifier: Programming Language :: Python :: 3.13
23
- Classifier: Programming Language :: Python :: 3.14
24
- Classifier: Topic :: Office/Business :: Financial :: Investment
25
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
- Classifier: Typing :: Typed
27
13
  Requires-Python: >=3.10
28
14
  Description-Content-Type: text/markdown
29
15
  License-File: LICENSE
@@ -1,6 +1,5 @@
1
1
  CHANGELOG.md
2
2
  LICENSE
3
- MANIFEST.in
4
3
  README.md
5
4
  pyproject.toml
6
5
  requirements.txt
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from datetime import date, datetime, timedelta, timezone
5
6
  from decimal import Decimal
6
7
  from typing import Any, Dict
7
8
 
@@ -16,6 +17,7 @@ from revolut_x.helpers import (
16
17
  build_market_order,
17
18
  build_order,
18
19
  normalize_symbol,
20
+ to_timestamp_ms,
19
21
  validate_against_pair_rules,
20
22
  )
21
23
  from revolut_x.types import OrderSide, OrderType, TimeInForce
@@ -297,3 +299,73 @@ def test_fee_calculator_taker() -> None:
297
299
  # Trade value 1000.00 * 0.0009 = 0.9000 EUR
298
300
  assert estimate.fee_amount == Decimal("0.9000")
299
301
  assert estimate.fee_currency == "EUR"
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # to_timestamp_ms
306
+ # ---------------------------------------------------------------------------
307
+
308
+ #: 2024-01-01T00:00:00Z
309
+ EPOCH_2024_MS = 1_704_067_200_000
310
+
311
+
312
+ def test_to_timestamp_ms_passes_numbers_through() -> None:
313
+ """Integers are already milliseconds — seconds must not be auto-detected."""
314
+ assert to_timestamp_ms(EPOCH_2024_MS) == EPOCH_2024_MS
315
+ assert to_timestamp_ms(EPOCH_2024_MS / 1000) == EPOCH_2024_MS // 1000
316
+ assert to_timestamp_ms(1_704_067_200_123.9) == 1_704_067_200_123
317
+
318
+
319
+ def test_to_timestamp_ms_converts_aware_datetime() -> None:
320
+ assert to_timestamp_ms(datetime(2024, 1, 1, tzinfo=timezone.utc)) == EPOCH_2024_MS
321
+ # A +02:00 wall clock of 02:00 is the same instant as 00:00 UTC.
322
+ offset = timezone(timedelta(hours=2))
323
+ assert to_timestamp_ms(datetime(2024, 1, 1, 2, tzinfo=offset)) == EPOCH_2024_MS
324
+
325
+
326
+ def test_to_timestamp_ms_treats_naive_datetime_as_utc() -> None:
327
+ """Naive values must not depend on the machine's local timezone."""
328
+ assert to_timestamp_ms(datetime(2024, 1, 1)) == EPOCH_2024_MS
329
+
330
+
331
+ def test_to_timestamp_ms_converts_date_to_midnight_utc() -> None:
332
+ assert to_timestamp_ms(date(2024, 1, 1)) == EPOCH_2024_MS
333
+
334
+
335
+ def test_to_timestamp_ms_parses_iso_strings() -> None:
336
+ assert to_timestamp_ms("2024-01-01") == EPOCH_2024_MS
337
+ assert to_timestamp_ms("2024-01-01T00:00:00") == EPOCH_2024_MS
338
+ assert to_timestamp_ms("2024-01-01T00:00:00+00:00") == EPOCH_2024_MS
339
+ # A trailing 'Z' is not accepted by fromisoformat before Python 3.11.
340
+ assert to_timestamp_ms("2024-01-01T00:00:00Z") == EPOCH_2024_MS
341
+ assert to_timestamp_ms(" 2024-01-01T00:00:00Z ") == EPOCH_2024_MS
342
+ assert to_timestamp_ms("2024-01-01T00:00:00.500Z") == EPOCH_2024_MS + 500
343
+
344
+
345
+ def test_to_timestamp_ms_rejects_bad_input() -> None:
346
+ with pytest.raises(ValueError):
347
+ to_timestamp_ms("last tuesday")
348
+ with pytest.raises(TypeError):
349
+ to_timestamp_ms(None) # type: ignore[arg-type]
350
+ with pytest.raises(TypeError):
351
+ to_timestamp_ms(True) # type: ignore[arg-type]
352
+
353
+
354
+ def test_to_timestamp_ms_accepts_long_digit_strings_as_milliseconds() -> None:
355
+ """Values arriving from JSON or query parameters are strings."""
356
+ assert to_timestamp_ms("1704067200000") == EPOCH_2024_MS
357
+ assert to_timestamp_ms("-1704067200000") == -EPOCH_2024_MS
358
+ # Exactly at the threshold: 1973-03-03, the oldest value still read as ms.
359
+ assert to_timestamp_ms("100000000000") == 100_000_000_000
360
+
361
+
362
+ @pytest.mark.parametrize("value", ["2024", "20240101", "0", "1704067200", "-500"])
363
+ def test_to_timestamp_ms_rejects_short_digit_strings(value: str) -> None:
364
+ """A separatorless date must not be read as an instant in 1970.
365
+
366
+ ``'20240101'`` is valid ISO-8601 basic format, so on Python 3.11+ it used to
367
+ parse as 2024-01-01 here while the PHP SDK read it as 20240101 ms — a silent
368
+ cross-SDK discrepancy, and on Python 3.10 it raised. Both SDKs now refuse it.
369
+ """
370
+ with pytest.raises(ValueError, match="Ambiguous numeric timestamp string"):
371
+ to_timestamp_ms(value)
@@ -1,6 +1,7 @@
1
1
  """Unit tests for MarketMixin in revolut_x.market."""
2
2
 
3
3
  from __future__ import annotations
4
+ from datetime import date, datetime, timedelta, timezone
4
5
  from typing import Any
5
6
  from unittest.mock import MagicMock
6
7
 
@@ -189,3 +190,71 @@ def test_get_ticker_matches_slash_and_dash_symbols() -> None:
189
190
  for requested in ("BTC-EUR", "BTC/EUR", "btc-eur"):
190
191
  client._http.request.return_value = (200, {"data": [{"symbol": "ETH/EUR"}, btc]})
191
192
  assert client.get_ticker(requested) == btc
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # datetime-friendly time ranges
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def test_get_candles_accepts_datetime_bounds() -> None:
200
+ """datetime/str bounds must reach the API as millisecond integers."""
201
+ client = DummyMarketClient()
202
+ client._http.request.return_value = (200, {"data": []})
203
+
204
+ client.get_candles(
205
+ "BTC-EUR",
206
+ INTERVAL_MIN,
207
+ since=datetime(2024, 1, 1, tzinfo=timezone.utc),
208
+ until="2024-01-02",
209
+ )
210
+
211
+ params = client._http.request.call_args.kwargs["params"]
212
+ assert params["since"] == 1_704_067_200_000
213
+ assert params["until"] == 1_704_153_600_000
214
+ assert all(isinstance(params[k], int) for k in ("since", "until"))
215
+
216
+
217
+ def test_get_trades_accepts_datetime_bounds() -> None:
218
+ client = DummyMarketClient()
219
+ client._http.request.return_value = (200, {"data": [], "metadata": {}})
220
+
221
+ client.get_trades(
222
+ "BTC-EUR",
223
+ start_date=date(2024, 1, 1),
224
+ end_date=datetime(2024, 1, 2, tzinfo=timezone.utc),
225
+ )
226
+
227
+ params = client._http.request.call_args.kwargs["params"]
228
+ assert params["start_date"] == 1_704_067_200_000
229
+ assert params["end_date"] == 1_704_153_600_000
230
+
231
+
232
+ def test_iter_candles_paginates_from_a_datetime() -> None:
233
+ """Pagination arithmetic must run on the converted value, not the object."""
234
+ base = 1_704_067_200_000 # 2024-01-01T00:00:00Z
235
+ client = FakeCandleClient(base, 3000)
236
+ since = datetime(2024, 1, 1, tzinfo=timezone.utc)
237
+ until = since + timedelta(minutes=1500)
238
+
239
+ got = _starts(list(client.iter_candles("BTC-EUR", INTERVAL_MIN, since=since, until=until)))
240
+
241
+ assert len(got) == 1501
242
+ assert got[0] == base
243
+ assert got[-1] == base + 1500 * INTERVAL_MS
244
+ assert len(client.requests) > 1, "a 1501-candle range must span several pages"
245
+
246
+
247
+ def test_iter_trades_converts_bounds_once_for_every_page() -> None:
248
+ client = DummyMarketClient()
249
+ pages = [
250
+ (200, {"data": [{"timestamp": 1}], "metadata": {"next_cursor": "c2"}}),
251
+ (200, {"data": [{"timestamp": 2}], "metadata": {}}),
252
+ ]
253
+ client._http.request.side_effect = pages
254
+
255
+ got = list(client.iter_trades("BTC-EUR", start_date="2024-01-01T00:00:00Z"))
256
+
257
+ assert len(got) == 2
258
+ sent = [c.kwargs["params"] for c in client._http.request.call_args_list]
259
+ assert [p["start_date"] for p in sent] == [1_704_067_200_000] * 2
260
+ assert sent[1]["cursor"] == "c2"
@@ -1,18 +0,0 @@
1
- include LICENSE
2
- include README.md
3
- include CHANGELOG.md
4
- include requirements.txt
5
-
6
- # The AI agent skill is an advertised feature of the package, so it travels
7
- # with the sdist instead of being GitHub-only.
8
- recursive-include .agents *.md
9
-
10
- recursive-include tests *.py
11
- recursive-include examples *.py *.md
12
-
13
- global-exclude *.py[cod]
14
- global-exclude .DS_Store
15
- prune docs
16
- prune site
17
- prune dist
18
- prune venv