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 +50 -0
- perp_md/adapters/__init__.py +3 -0
- perp_md/adapters/base.py +23 -0
- perp_md/adapters/ccxt.py +236 -0
- perp_md/adapters/native.py +528 -0
- perp_md/client.py +79 -0
- perp_md/errors.py +29 -0
- perp_md/history.py +23 -0
- perp_md/models.py +124 -0
- perp_md/normalization.py +47 -0
- perp_md/py.typed +1 -0
- perp_md/transport.py +106 -0
- perp_md-0.1.4.dist-info/METADATA +66 -0
- perp_md-0.1.4.dist-info/RECORD +17 -0
- perp_md-0.1.4.dist-info/WHEEL +5 -0
- perp_md-0.1.4.dist-info/licenses/LICENSE +201 -0
- perp_md-0.1.4.dist-info/top_level.txt +1 -0
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
|
+
]
|
perp_md/adapters/base.py
ADDED
|
@@ -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: ...
|
perp_md/adapters/ccxt.py
ADDED
|
@@ -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")
|