perexchange 1.0.0__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.
@@ -0,0 +1,2 @@
1
+ exclude tests/*
2
+ prune tests
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: perexchange
3
+ Version: 1.0.0
4
+ Summary: Library for fetching Peruvian exchange rates
5
+ Author-email: David Duran <dadch1404@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: beautifulsoup4>=4.14.2
10
+ Requires-Dist: httpx[http2]>=0.27.2
11
+ Requires-Dist: lxml>=6.0.2
12
+
13
+ # [pkg]: perexchange
14
+
15
+ Core library for fetching PEN-USD exchange rates from Peruvian exchange houses. To install
16
+ it, run:
17
+
18
+ ```bash
19
+ pip install perexchange
20
+ ```
21
+
22
+ The library provides a single async function that fetches rates from multiple sources
23
+ concurrently:
24
+
25
+ ```python
26
+ import asyncio
27
+ import perexchange as px
28
+
29
+ async def main():
30
+ rates = await px.fetch_rates()
31
+ best = min(rates, key=lambda r: r.buy_price)
32
+ print(f"{best.name}: S/{best.buy_price}")
33
+
34
+ asyncio.run(main())
35
+ ```
36
+
37
+ ## Fetching rates
38
+
39
+ Call `fetch_rates()` to retrieve current rates. By default it queries all available
40
+ sources: cambioseguro, cuantoestaeldolar, tkambio, tucambista, and westernunion. The
41
+ function returns a list of `ExchangeRate` objects.
42
+
43
+ ```python
44
+ rates = await px.fetch_rates()
45
+ ```
46
+
47
+ You can fetch from specific sources by passing a list of house names. This is useful when
48
+ you only need rates from certain providers or want to reduce latency:
49
+
50
+ ```python
51
+ rates = await px.fetch_rates(houses=["tkambio", "tucambista"])
52
+ ```
53
+
54
+ The function accepts timeout and retry parameters. Timeout applies per house, not to the
55
+ entire operation. Retries only trigger on network errors, not parsing failures:
56
+
57
+ ```python
58
+ rates = await px.fetch_rates(timeout=15.0, max_retries=5)
59
+ ```
60
+
61
+ Failed sources are silently skipped. The function returns whatever rates it successfully
62
+ fetched, or an empty list if everything fails. Network errors trigger automatic retries
63
+ with exponential backoff. Parsing errors fail immediately.
64
+
65
+ ## Working with rates
66
+
67
+ Each `ExchangeRate` contains the house name, buy and sell prices, and a UTC timestamp. Buy
68
+ price is what you pay in soles to buy dollars. Sell price is what you receive in soles
69
+ when selling dollars. The object is a frozen dataclass:
70
+
71
+ ```python
72
+ rate = rates[0]
73
+ name = rate.name
74
+ buy = rate.buy_price
75
+ sell = rate.sell_price
76
+ when = rate.timestamp
77
+ spread = rate.spread
78
+ ```
79
+
80
+ The spread property returns the difference between sell and buy prices. Some sources
81
+ return multiple tiers with different rates based on transaction amount, like
82
+ `tkambio_5000` and `tkambio_10000`.
83
+
84
+ Find the best rates by sorting or filtering the list:
85
+
86
+ ```python
87
+ best_buy = min(rates, key=lambda r: r.buy_price)
88
+ best_sell = max(rates, key=lambda r: r.sell_price)
89
+ recent = [r for r in rates if (datetime.now(timezone.utc) - r.timestamp).seconds < 300]
90
+ ```
91
+
92
+ ## Error handling
93
+
94
+ Invalid house names raise `ValueError` immediately. All other failures are silent. Check
95
+ the returned list to see what succeeded:
96
+
97
+ ```python
98
+ try:
99
+ rates = await px.fetch_rates(houses=["nonexistent"])
100
+ except ValueError as e:
101
+ print(f"Unknown house: {e}")
102
+
103
+ rates = await px.fetch_rates()
104
+ if not rates:
105
+ print("All sources failed")
106
+ ```
107
+
108
+ The library doesn't distinguish between different failure types. A house might fail due to
109
+ network issues, API changes, or parsing errors. Failed requests don't pollute your results
110
+ or logs.
@@ -0,0 +1,16 @@
1
+ """
2
+ Fetch PEN-USD exchange rates from Peruvian exchange houses.
3
+
4
+ Example:
5
+ >>> import perexchange as px
6
+ >>> rates = await px.fetch_rates()
7
+ >>> best = min(rates, key=lambda r: r.buy_price)
8
+ >>> print(f"{best.name}: S/{best.buy_price}")
9
+ """
10
+
11
+ from perexchange.core import fetch_rates
12
+ from perexchange.models import ExchangeRate
13
+
14
+
15
+ __version__ = "1.0.0"
16
+ __all__ = ["ExchangeRate", "fetch_rates"]
@@ -0,0 +1,70 @@
1
+ import asyncio
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import httpx
6
+
7
+ from perexchange.models import ExchangeRate
8
+ from perexchange.scrapers import get_scrapers
9
+ from perexchange.scrapers.base import ExchangeRateScraper
10
+
11
+
12
+ async def fetch_rates(
13
+ houses: Sequence[str] | None = None,
14
+ *,
15
+ timeout: float = 10.0,
16
+ max_retries: int = 3,
17
+ ) -> list[ExchangeRate]:
18
+ """
19
+ Fetch current exchange rates from Peruvian exchange houses.
20
+
21
+ Args:
22
+ houses: Specific house names to fetch. If None, fetches all.
23
+ Available: cambiafx, cambioseguro, chapacambio, cuantoestaeldolar, dollarhouse,
24
+ instakash, srcambio, tkambio, tucambista, westernunion, yanki
25
+ timeout: Request timeout per house (seconds)
26
+ max_retries: Retry attempts for failed requests
27
+
28
+ Returns:
29
+ List of ExchangeRate objects. Empty list if all houses fail.
30
+
31
+ Example:
32
+ >>> rates = await fetch_rates()
33
+ >>> rates = await fetch_rates(houses=["tkambio", "cambioseguro"])
34
+ >>> best = min(rates, key=lambda r: r.buy_price)
35
+ >>> print(f"Best: {best.name} at S/{best.buy_price}")
36
+
37
+ Note:
38
+ Failed houses are silently skipped. Network errors are retried,
39
+ parsing errors fail immediately.
40
+ """
41
+ scrapers = get_scrapers(houses)
42
+
43
+ tasks = [_safe_fetch(scraper, timeout, max_retries) for scraper in scrapers]
44
+ results = await asyncio.gather(*tasks)
45
+
46
+ all_rates = [rate for result in results for rate in result]
47
+
48
+ # Deduplicate by name, keeping the most recent rate for each house.
49
+ # This handles cases where cuantoestaeldolar (an aggregator) returns rates for houses
50
+ # we also scrape directly (e.g., chapacambio). We prioritize the most recent timestamp,
51
+ # which is typically from direct scrapers since they have fresher data.
52
+ # This is a temporary measure until cuantoestaeldolar is replaced with individual scrapers.
53
+ seen: dict[str, ExchangeRate] = {}
54
+ for rate in all_rates:
55
+ if rate.name not in seen or rate.timestamp > seen[rate.name].timestamp:
56
+ seen[rate.name] = rate
57
+
58
+ return list(seen.values())
59
+
60
+
61
+ async def _safe_fetch(
62
+ scraper: ExchangeRateScraper,
63
+ timeout: float,
64
+ max_retries: int,
65
+ ) -> list[ExchangeRate]:
66
+ """Fetch from one scraper, return empty list on failure."""
67
+ try:
68
+ return await scraper(timeout=timeout, max_retries=max_retries)
69
+ except (httpx.HTTPError, ValueError):
70
+ return []
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class ExchangeRate:
7
+ name: str
8
+ buy_price: float # Price to BUY dollars (sell soles)
9
+ sell_price: float # Price to SELL dollars (buy soles)
10
+ timestamp: datetime
11
+
12
+ @property
13
+ def spread(self) -> float:
14
+ """Difference between buy and sell price."""
15
+ return self.sell_price - self.buy_price
16
+
17
+ def __repr__(self) -> str:
18
+ return (
19
+ f"ExchangeRate({self.name!r}, "
20
+ f"buy={self.buy_price:.4f}, sell={self.sell_price:.4f})"
21
+ )
@@ -0,0 +1,68 @@
1
+ from collections.abc import Sequence
2
+
3
+ from perexchange.scrapers.base import ExchangeRateScraper
4
+ from perexchange.scrapers.cambiafx import fetch_cambiafx
5
+ from perexchange.scrapers.cambioseguro import fetch_cambioseguro
6
+ from perexchange.scrapers.chapacambio import fetch_chapacambio
7
+ from perexchange.scrapers.cuantoestaeldolar import fetch_cuantoestaeldolar
8
+ from perexchange.scrapers.dollarhouse import fetch_dollarhouse
9
+ from perexchange.scrapers.instakash import fetch_instakash
10
+ from perexchange.scrapers.srcambio import fetch_srcambio
11
+ from perexchange.scrapers.tkambio import fetch_tkambio
12
+ from perexchange.scrapers.tucambista import fetch_tucambista
13
+ from perexchange.scrapers.westernunion import fetch_westernunion
14
+ from perexchange.scrapers.yanki import fetch_yanki
15
+
16
+
17
+ _SCRAPERS: dict[str, ExchangeRateScraper] = {
18
+ "cambioseguro": fetch_cambioseguro,
19
+ "cambiafx": fetch_cambiafx,
20
+ "chapacambio": fetch_chapacambio,
21
+ "cuantoestaeldolar": fetch_cuantoestaeldolar,
22
+ "dollarhouse": fetch_dollarhouse,
23
+ "instakash": fetch_instakash,
24
+ "srcambio": fetch_srcambio,
25
+ "tkambio": fetch_tkambio,
26
+ "tucambista": fetch_tucambista,
27
+ "westernunion": fetch_westernunion,
28
+ "yanki": fetch_yanki,
29
+ }
30
+
31
+
32
+ def get_scrapers(houses: Sequence[str] | None = None) -> list[ExchangeRateScraper]:
33
+ """
34
+ Get scrapers for specified houses, or all if None.
35
+
36
+ Raises:
37
+ ValueError: If a house name is not recognized
38
+ """
39
+ if houses is None:
40
+ return list(_SCRAPERS.values())
41
+
42
+ scrapers = []
43
+ for house in houses:
44
+ house_lower = house.lower()
45
+ if house_lower not in _SCRAPERS:
46
+ available = ", ".join(sorted(_SCRAPERS.keys()))
47
+ msg = f"Unknown house: {house!r}. Available: {available}"
48
+ raise ValueError(msg)
49
+ scrapers.append(_SCRAPERS[house_lower])
50
+
51
+ return scrapers
52
+
53
+
54
+ __all__ = [
55
+ "ExchangeRateScraper",
56
+ "fetch_cambiafx",
57
+ "fetch_cambioseguro",
58
+ "fetch_chapacambio",
59
+ "fetch_cuantoestaeldolar",
60
+ "fetch_dollarhouse",
61
+ "fetch_instakash",
62
+ "fetch_srcambio",
63
+ "fetch_tkambio",
64
+ "fetch_tucambista",
65
+ "fetch_westernunion",
66
+ "fetch_yanki",
67
+ "get_scrapers",
68
+ ]
@@ -0,0 +1,87 @@
1
+ import asyncio
2
+
3
+ from collections.abc import AsyncGenerator, Awaitable, Callable
4
+ from contextlib import asynccontextmanager
5
+ from typing import Protocol, TypeVar
6
+
7
+ import httpx
8
+
9
+ from perexchange.models import ExchangeRate
10
+
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ class ExchangeRateScraper(Protocol):
16
+ """Protocol defining the interface all scrapers must implement."""
17
+
18
+ def __call__(
19
+ self,
20
+ timeout: float = 10.0,
21
+ max_retries: int = 3,
22
+ retry_delay: float = 0.5,
23
+ ) -> Awaitable[list[ExchangeRate]]: # fmt: skip
24
+ ...
25
+
26
+
27
+ @asynccontextmanager
28
+ async def get_http_client(timeout: float) -> AsyncGenerator[httpx.AsyncClient, None]:
29
+ """
30
+ Create HTTP client with connection pooling.
31
+ """
32
+ async with httpx.AsyncClient(
33
+ timeout=timeout,
34
+ limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
35
+ http2=True,
36
+ ) as client:
37
+ yield client
38
+
39
+
40
+ async def fetch_with_retry(
41
+ fetch_fn: Callable[[httpx.AsyncClient], Awaitable[T]],
42
+ timeout: float,
43
+ max_retries: int,
44
+ retry_delay: float,
45
+ error_context: str,
46
+ ) -> T:
47
+ """
48
+ Execute fetch function with exponential backoff retry logic.
49
+
50
+ Args:
51
+ fetch_fn: Async function that takes an httpx.AsyncClient and returns data
52
+ timeout: Request timeout in seconds
53
+ max_retries: Maximum number of retry attempts
54
+ retry_delay: Base delay between retries (doubles each attempt)
55
+ error_context: URL or context string for error messages
56
+
57
+ Returns:
58
+ Result from fetch_fn
59
+
60
+ Raises:
61
+ ValueError: On parsing errors (fails immediately, no retry)
62
+ httpx.HTTPError: On network errors after all retries exhausted
63
+ """
64
+ async with get_http_client(timeout) as client:
65
+ last_error = None
66
+
67
+ for attempt in range(max_retries):
68
+ try:
69
+ return await fetch_fn(client)
70
+
71
+ except httpx.HTTPError as e:
72
+ last_error = e
73
+ if attempt < max_retries - 1:
74
+ await asyncio.sleep(retry_delay * (2**attempt))
75
+ continue
76
+
77
+ except (ValueError, KeyError, TypeError, AttributeError, IndexError) as e:
78
+ msg = (
79
+ f"Failed to parse exchange rates from {error_context}. "
80
+ "The structure may have changed."
81
+ )
82
+ raise ValueError(msg) from e
83
+
84
+ if last_error is None:
85
+ msg = "Failed to fetch rates: no attempts were made"
86
+ raise ValueError(msg)
87
+ raise last_error
@@ -0,0 +1,54 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from perexchange.models import ExchangeRate
7
+ from perexchange.scrapers.base import fetch_with_retry
8
+
9
+
10
+ URL = "https://apiluna.cambiafx.pe/api/BackendPizarra/getTcCustomerNoAuth?idParCurrency=1&codePromo=CED"
11
+
12
+
13
+ async def fetch_cambiafx(
14
+ timeout: float = 10.0,
15
+ max_retries: int = 3,
16
+ retry_delay: float = 0.5,
17
+ ) -> list[ExchangeRate]:
18
+ async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
19
+ response = await client.get(URL)
20
+ response.raise_for_status()
21
+ return _parse_json(response.json())
22
+
23
+ return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
24
+
25
+
26
+ def _parse_json(response_data: list[dict[str, Any]]) -> list[ExchangeRate]:
27
+ if not response_data:
28
+ msg = "No exchange rates data"
29
+ raise ValueError(msg)
30
+
31
+ timestamp = datetime.now(timezone.utc)
32
+ rates = []
33
+
34
+ # Take the first rate from the array
35
+ data = response_data[0]
36
+ try:
37
+ buy_price = float(data["tcBuy"])
38
+ sell_price = float(data["tcSale"])
39
+ if buy_price > 0 and sell_price > 0:
40
+ rate = ExchangeRate(
41
+ name="cambiafx",
42
+ buy_price=buy_price,
43
+ sell_price=sell_price,
44
+ timestamp=timestamp,
45
+ )
46
+ rates.append(rate)
47
+ except (KeyError, ValueError, TypeError):
48
+ pass
49
+
50
+ if not rates:
51
+ msg = "No valid exchange rates parsed"
52
+ raise ValueError(msg)
53
+
54
+ return rates
@@ -0,0 +1,73 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from perexchange.models import ExchangeRate
7
+ from perexchange.scrapers.base import fetch_with_retry
8
+
9
+
10
+ URL = "https://api.cambioseguro.com/api/v1.1/config/rates"
11
+
12
+
13
+ async def fetch_cambioseguro(
14
+ timeout: float = 10.0,
15
+ max_retries: int = 3,
16
+ retry_delay: float = 0.5,
17
+ ) -> list[ExchangeRate]:
18
+ async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
19
+ response = await client.get(URL)
20
+ response.raise_for_status()
21
+ return _parse_json(response.json())
22
+
23
+ return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
24
+
25
+
26
+ def _parse_json(response_data: dict[str, Any]) -> list[ExchangeRate]:
27
+ timestamp = datetime.now(timezone.utc)
28
+ rates = []
29
+ data = response_data.get("data", {})
30
+
31
+ rate_configs = [
32
+ ("cambioseguro", "purchase_price", "sale_price"),
33
+ (
34
+ "cambioseguro_comparative",
35
+ "purchase_price_comparative",
36
+ "sale_price_comparative",
37
+ ),
38
+ ("cambioseguro_paralelo", "purchase_price_paralelo", "sale_price_paralelo"),
39
+ ]
40
+
41
+ for name, buy_key, sell_key in rate_configs:
42
+ rate = _try_create_rate(data, name, buy_key, sell_key, timestamp)
43
+ if rate:
44
+ rates.append(rate)
45
+
46
+ if not rates:
47
+ msg = "No valid exchange rates parsed"
48
+ raise ValueError(msg)
49
+
50
+ return rates
51
+
52
+
53
+ def _try_create_rate(
54
+ data: dict[str, Any],
55
+ name: str,
56
+ buy_key: str,
57
+ sell_key: str,
58
+ timestamp: datetime,
59
+ ) -> ExchangeRate | None:
60
+ """Try to create a rate from data, return None if invalid."""
61
+ try:
62
+ buy_price = float(data[buy_key])
63
+ sell_price = float(data[sell_key])
64
+ if buy_price > 0 and sell_price > 0:
65
+ return ExchangeRate(
66
+ name=name,
67
+ buy_price=buy_price,
68
+ sell_price=sell_price,
69
+ timestamp=timestamp,
70
+ )
71
+ except (KeyError, ValueError, TypeError):
72
+ pass
73
+ return None
@@ -0,0 +1,61 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from perexchange.models import ExchangeRate
7
+ from perexchange.scrapers.base import fetch_with_retry
8
+
9
+
10
+ URL = "https://chapacambio.com/wp-json/chapacambio/tasas"
11
+
12
+
13
+ async def fetch_chapacambio(
14
+ timeout: float = 10.0,
15
+ max_retries: int = 3,
16
+ retry_delay: float = 0.5,
17
+ ) -> list[ExchangeRate]:
18
+ async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
19
+ response = await client.get(URL)
20
+ response.raise_for_status()
21
+ return _parse_json(response.json())
22
+
23
+ return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
24
+
25
+
26
+ def _parse_json(response_data: list[dict[str, Any]]) -> list[ExchangeRate]:
27
+ rates = []
28
+ for item in response_data:
29
+ rate = _try_create_rate(item)
30
+ if rate:
31
+ rates.append(rate)
32
+
33
+ if not rates:
34
+ msg = "No valid exchange rates parsed"
35
+ raise ValueError(msg)
36
+
37
+ return rates
38
+
39
+
40
+ def _try_create_rate(data: dict[str, Any]) -> ExchangeRate | None:
41
+ """Try to create a rate from data, return None if invalid."""
42
+ try:
43
+ buy_price = float(data["MontoCompra"])
44
+ sell_price = float(data["MontoVenta"])
45
+ if buy_price > 0 and sell_price > 0:
46
+ timestamp_str = data.get("updateAt")
47
+ if timestamp_str:
48
+ timestamp = datetime.fromisoformat(timestamp_str).replace(
49
+ tzinfo=timezone.utc
50
+ )
51
+ else:
52
+ timestamp = datetime.now(timezone.utc)
53
+ return ExchangeRate(
54
+ name="chapacambio",
55
+ buy_price=buy_price,
56
+ sell_price=sell_price,
57
+ timestamp=timestamp,
58
+ )
59
+ except (KeyError, ValueError, TypeError):
60
+ pass
61
+ return None
@@ -0,0 +1,94 @@
1
+ from datetime import datetime, timezone
2
+
3
+ import httpx
4
+
5
+ from bs4 import BeautifulSoup
6
+ from bs4.element import Tag
7
+
8
+ from perexchange.models import ExchangeRate
9
+ from perexchange.scrapers.base import fetch_with_retry
10
+
11
+
12
+ URL = "https://cuantoestaeldolar.pe/cambio-de-dolar-online"
13
+
14
+
15
+ async def fetch_cuantoestaeldolar(
16
+ timeout: float = 10.0,
17
+ max_retries: int = 3,
18
+ retry_delay: float = 0.5,
19
+ ) -> list[ExchangeRate]:
20
+ async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
21
+ response = await client.get(URL)
22
+ response.raise_for_status()
23
+ return _parse_html(response.text)
24
+
25
+ return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
26
+
27
+
28
+ def _parse_html(html_content: str) -> list[ExchangeRate]:
29
+ soup = BeautifulSoup(html_content, "lxml")
30
+ change_buttons = soup.find_all("a", string="CAMBIAR") # type: ignore[call-overload]
31
+
32
+ if not change_buttons:
33
+ msg = "No exchange houses found in HTML"
34
+ raise ValueError(msg)
35
+
36
+ timestamp = datetime.now(timezone.utc)
37
+ rates = []
38
+
39
+ for button in change_buttons:
40
+ try:
41
+ rate = _extract_rate_from_card(button, timestamp)
42
+ if rate:
43
+ rates.append(rate)
44
+ except (AttributeError, ValueError, TypeError, IndexError):
45
+ continue
46
+
47
+ if not rates:
48
+ msg = "No valid exchange rates parsed"
49
+ raise ValueError(msg)
50
+
51
+ return rates
52
+
53
+
54
+ def _extract_rate_from_card(button: Tag, timestamp: datetime) -> ExchangeRate | None:
55
+ parent = button.find_parent("div")
56
+ if not parent:
57
+ return None
58
+ card = parent.find_parent("div")
59
+ if not card:
60
+ return None
61
+
62
+ img_tag = card.find("img")
63
+ if not img_tag:
64
+ return None
65
+ alt_attr = img_tag.get("alt")
66
+ name = alt_attr.strip() if isinstance(alt_attr, str) else None
67
+ if not name:
68
+ return None
69
+
70
+ buy_block = card.select_one('div[class*="_content_buy__"]')
71
+ sell_block = card.select_one('div[class*="_content_sale__"]')
72
+
73
+ if not buy_block or not sell_block:
74
+ return None
75
+
76
+ buy_rate_elem = buy_block.find(
77
+ "p", class_=lambda c: c and c.startswith("ValueCurrency_item_cost__")
78
+ )
79
+ buy_price = float(buy_rate_elem.text) if buy_rate_elem else None
80
+
81
+ sell_rate_elem = sell_block.find(
82
+ "p", class_=lambda c: c and c.startswith("ValueCurrency_item_cost__")
83
+ )
84
+ sell_price = float(sell_rate_elem.text) if sell_rate_elem else None
85
+
86
+ if not buy_price or not sell_price or buy_price <= 0 or sell_price <= 0:
87
+ return None
88
+
89
+ return ExchangeRate(
90
+ name=name,
91
+ buy_price=buy_price,
92
+ sell_price=sell_price,
93
+ timestamp=timestamp,
94
+ )