perp-md 0.1.4__py3-none-any.whl

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.
perp_md/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from perp_md.client import OpenInterestClient
4
+ from perp_md.errors import (
5
+ AdapterUnavailable,
6
+ DataUnavailable,
7
+ InvalidInstrument,
8
+ InvalidResponse,
9
+ PaginationError,
10
+ PerpMdError,
11
+ RequestError,
12
+ )
13
+ from perp_md.history import find_resume_time
14
+ from perp_md.models import (
15
+ ContractDirection,
16
+ HistoryIssue,
17
+ HistoryRange,
18
+ Instrument,
19
+ NativeUnit,
20
+ OpenInterestCapabilities,
21
+ OpenInterestObservation,
22
+ OpenInterestResult,
23
+ ValuationMethod,
24
+ )
25
+
26
+ try:
27
+ __version__ = version("perp-md")
28
+ except PackageNotFoundError:
29
+ __version__ = "0.0.0+uninstalled"
30
+
31
+ __all__ = [
32
+ "AdapterUnavailable",
33
+ "ContractDirection",
34
+ "DataUnavailable",
35
+ "HistoryIssue",
36
+ "HistoryRange",
37
+ "Instrument",
38
+ "InvalidInstrument",
39
+ "InvalidResponse",
40
+ "NativeUnit",
41
+ "OpenInterestCapabilities",
42
+ "OpenInterestClient",
43
+ "OpenInterestObservation",
44
+ "OpenInterestResult",
45
+ "PaginationError",
46
+ "PerpMdError",
47
+ "RequestError",
48
+ "ValuationMethod",
49
+ "find_resume_time",
50
+ ]
@@ -0,0 +1,3 @@
1
+ from perp_md.adapters.base import OpenInterestAdapter
2
+
3
+ __all__ = ["OpenInterestAdapter"]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from perp_md.models import (
6
+ HistoryRange,
7
+ Instrument,
8
+ OpenInterestCapabilities,
9
+ OpenInterestResult,
10
+ )
11
+
12
+
13
+ class OpenInterestAdapter(Protocol):
14
+ def supports(self, instrument: Instrument) -> bool: ...
15
+ def capabilities(self, instrument: Instrument) -> OpenInterestCapabilities: ...
16
+ async def fetch(
17
+ self,
18
+ instrument: Instrument,
19
+ history: HistoryRange | None,
20
+ *,
21
+ include_history: bool,
22
+ ) -> OpenInterestResult: ...
23
+ async def close(self) -> None: ...
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from perp_md.errors import AdapterUnavailable, DataUnavailable, InvalidResponse, PerpMdError, RequestError
11
+ from perp_md.models import (
12
+ HistoryIssue,
13
+ HistoryRange,
14
+ Instrument,
15
+ NativeUnit,
16
+ OpenInterestCapabilities,
17
+ OpenInterestObservation,
18
+ OpenInterestResult,
19
+ ValuationMethod,
20
+ )
21
+ from perp_md.normalization import contract_value_usd, number, verify_multiplier
22
+
23
+
24
+ DEFAULT_EXCHANGE_IDS = {
25
+ "BITFINEX": "bitfinex",
26
+ "BITGET": "bitget",
27
+ "BITMART": "bitmart",
28
+ "COINBASE": "coinbaseinternational",
29
+ "DERIBIT": "deribit",
30
+ "HTX": "htx",
31
+ "KUCOIN": "kucoin",
32
+ "MEXC": "mexc",
33
+ "WHITEBIT": "whitebit",
34
+ "XT": "xt",
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class CcxtAdapter:
40
+ timeout_seconds: float = 10
41
+ exchange_ids: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_EXCHANGE_IDS))
42
+ exchanges: dict[str, Any] = field(default_factory=dict, init=False)
43
+ locks: dict[str, asyncio.Lock] = field(default_factory=dict, init=False)
44
+
45
+ def supports(self, instrument: Instrument) -> bool:
46
+ return instrument.venue in self.exchange_ids
47
+
48
+ def capabilities(self, instrument: Instrument) -> OpenInterestCapabilities:
49
+ required = ("contract_direction", "contract_multiplier") if instrument.venue in {"BITFINEX", "BITGET", "COINBASE", "WHITEBIT"} else ()
50
+ return OpenInterestCapabilities(True, instrument.venue in {"HTX", "OKX"}, 300, required_metadata=required)
51
+
52
+ async def fetch(
53
+ self,
54
+ instrument: Instrument,
55
+ history: HistoryRange | None,
56
+ *,
57
+ include_history: bool,
58
+ ) -> OpenInterestResult:
59
+ try:
60
+ if instrument.venue == "COINBASE":
61
+ return await self._coinbase(instrument)
62
+ if instrument.venue == "WHITEBIT":
63
+ return await self._whitebit(instrument)
64
+ exchange, symbol = await self._market(instrument)
65
+ if not exchange.has.get("fetchOpenInterest"):
66
+ raise DataUnavailable("open interest is not available for this venue")
67
+ payload = await exchange.fetch_open_interest(symbol)
68
+ native = payload.get("openInterestAmount")
69
+ mark: float | None = None
70
+ if payload.get("openInterestValue") is not None:
71
+ value = number(payload["openInterestValue"])
72
+ valuation = ValuationMethod.VENUE_REPORTED
73
+ elif native is not None:
74
+ mark = self._mark(await exchange.fetch_ticker(symbol))
75
+ value = contract_value_usd(instrument, number(native), mark)
76
+ valuation = ValuationMethod.MARK_PRICE
77
+ else:
78
+ raise DataUnavailable("venue omitted open-interest amount and normalized value")
79
+ current = OpenInterestObservation(
80
+ int(payload.get("timestamp") or time.time() * 1000),
81
+ value,
82
+ number(native) if native is not None else None,
83
+ NativeUnit.CONTRACTS if native is not None else None,
84
+ mark,
85
+ valuation,
86
+ )
87
+ if not include_history or not exchange.has.get("fetchOpenInterestHistory"):
88
+ return OpenInterestResult(current)
89
+ try:
90
+ rows = await exchange.fetch_open_interest_history(
91
+ symbol,
92
+ timeframe="5m",
93
+ since=history.start_ms if history else None,
94
+ limit=100,
95
+ )
96
+ end = history.end_ms if history else None
97
+ observations = tuple(
98
+ OpenInterestObservation(
99
+ int(row["timestamp"]),
100
+ number(row["openInterestValue"]),
101
+ valuation=ValuationMethod.VENUE_REPORTED,
102
+ )
103
+ for row in rows
104
+ if row.get("timestamp") is not None
105
+ and row.get("openInterestValue") is not None
106
+ and (end is None or int(row["timestamp"]) <= end)
107
+ )
108
+ return OpenInterestResult(current, tuple(sorted(observations, key=lambda row: row.timestamp_ms)))
109
+ except Exception as exc:
110
+ return OpenInterestResult(current, history_issue=HistoryIssue(
111
+ "history_unavailable", self._summary(exc)
112
+ ))
113
+ except PerpMdError:
114
+ raise
115
+ except Exception as exc:
116
+ raise RequestError("venue adapter request failed") from exc
117
+
118
+ async def close(self) -> None:
119
+ exchanges = list(self.exchanges.values())
120
+ self.exchanges.clear()
121
+ await asyncio.gather(*(exchange.close() for exchange in exchanges), return_exceptions=True)
122
+
123
+ async def _coinbase(self, instrument: Instrument) -> OpenInterestResult:
124
+ exchange, _ = await self._market(instrument)
125
+ payload = await exchange.v1_public_get_instruments()
126
+ if not isinstance(payload, list):
127
+ raise InvalidResponse("venue returned an invalid instrument catalog")
128
+ target = instrument.pair_symbol or instrument.symbol.removesuffix("-INTX")
129
+ rows = [row for row in payload if str(row.get("symbol", "")).upper() == target.upper()]
130
+ if len(rows) != 1:
131
+ raise DataUnavailable("instrument is missing or ambiguous in the venue catalog")
132
+ row = rows[0]
133
+ quote = row.get("quote") or {}
134
+ if row.get("open_interest") is None or quote.get("mark_price") is None:
135
+ raise DataUnavailable("venue omitted open interest or mark price")
136
+ verify_multiplier(instrument, row.get("base_asset_multiplier"))
137
+ native, mark = number(row["open_interest"]), number(quote["mark_price"])
138
+ return OpenInterestResult(OpenInterestObservation(
139
+ self._iso_ms(quote.get("timestamp")),
140
+ contract_value_usd(instrument, native, mark),
141
+ native,
142
+ NativeUnit.CONTRACTS,
143
+ mark,
144
+ ValuationMethod.MARK_PRICE,
145
+ ))
146
+
147
+ async def _whitebit(self, instrument: Instrument) -> OpenInterestResult:
148
+ exchange, _ = await self._market(instrument)
149
+ payload = await exchange.v4_public_get_futures()
150
+ if not isinstance(payload, dict) or not payload.get("success") or not isinstance(payload.get("result"), list):
151
+ raise InvalidResponse("venue returned an invalid futures catalog")
152
+ rows = [row for row in payload["result"] if str(row.get("ticker_id", "")).upper() == instrument.symbol.upper()]
153
+ if len(rows) != 1:
154
+ raise DataUnavailable("instrument is missing or ambiguous in the venue catalog")
155
+ row = rows[0]
156
+ mark_raw = row.get("index_price") or row.get("last_price")
157
+ if row.get("open_interest") is None or mark_raw is None:
158
+ raise DataUnavailable("venue omitted open interest or reference price")
159
+ native, mark = number(row["open_interest"]), number(mark_raw)
160
+ return OpenInterestResult(OpenInterestObservation(
161
+ int(time.time() * 1000),
162
+ contract_value_usd(instrument, native, mark),
163
+ native,
164
+ NativeUnit.CONTRACTS,
165
+ mark,
166
+ ValuationMethod.MARK_PRICE,
167
+ ))
168
+
169
+ async def _market(self, instrument: Instrument) -> tuple[Any, str]:
170
+ exchange_id = self.exchange_ids.get(instrument.venue)
171
+ try:
172
+ ccxt = importlib.import_module("ccxt.async_support")
173
+ except ImportError as exc:
174
+ raise AdapterUnavailable("optional CCXT adapter is not installed") from exc
175
+ if not exchange_id or not hasattr(ccxt, exchange_id):
176
+ raise AdapterUnavailable("no CCXT adapter is configured for this venue")
177
+ async with self.locks.setdefault(instrument.venue, asyncio.Lock()):
178
+ exchange = self.exchanges.get(instrument.venue)
179
+ if exchange is None:
180
+ exchange = getattr(ccxt, exchange_id)({
181
+ "enableRateLimit": True,
182
+ "timeout": int(self.timeout_seconds * 1000),
183
+ })
184
+ try:
185
+ await exchange.load_markets()
186
+ except Exception as exc:
187
+ await exchange.close()
188
+ raise RequestError("venue market catalog failed") from exc
189
+ self.exchanges[instrument.venue] = exchange
190
+ return exchange, resolve_ccxt_symbol(exchange, instrument)
191
+
192
+ @staticmethod
193
+ def _mark(ticker: dict[str, Any]) -> float:
194
+ info = ticker.get("info") if isinstance(ticker.get("info"), dict) else {}
195
+ raw = ticker.get("mark") or info.get("markPrice") or info.get("mark_price") or ticker.get("last")
196
+ if raw is None:
197
+ raise DataUnavailable("venue omitted mark and last price")
198
+ return number(raw)
199
+
200
+ @staticmethod
201
+ def _iso_ms(raw: Any) -> int:
202
+ if raw in (None, ""):
203
+ return int(time.time() * 1000)
204
+ try:
205
+ return int(datetime.fromisoformat(str(raw).replace("Z", "+00:00")).timestamp() * 1000)
206
+ except ValueError as exc:
207
+ raise InvalidResponse("venue returned an invalid observation timestamp") from exc
208
+
209
+ @staticmethod
210
+ def _summary(exc: Exception) -> str:
211
+ detail = str(exc).strip()
212
+ return f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
213
+
214
+
215
+ def resolve_ccxt_symbol(exchange: Any, instrument: Instrument) -> str:
216
+ raw = instrument.symbol
217
+ candidates = exchange.markets_by_id.get(raw, [])
218
+ if not candidates and instrument.venue == "COINBASE":
219
+ candidates = exchange.markets_by_id.get(raw.removesuffix("-INTX"), [])
220
+ if not candidates and instrument.venue == "BITFINEX":
221
+ candidates = exchange.markets_by_id.get(f"t{raw}", [])
222
+ if isinstance(candidates, dict):
223
+ candidates = [candidates]
224
+ contracts = [row for row in candidates if row.get("contract")]
225
+ if len(contracts) == 1:
226
+ return contracts[0]["symbol"]
227
+ matches = [
228
+ row
229
+ for key, values in exchange.markets_by_id.items()
230
+ if str(key).upper() == raw.upper()
231
+ for row in (values if isinstance(values, list) else [values])
232
+ if row.get("contract")
233
+ ]
234
+ if len(matches) == 1:
235
+ return matches[0]["symbol"]
236
+ raise DataUnavailable("venue-native instrument is not uniquely exposed by CCXT")