metascalp 1.1.0__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.
- metascalp/__init__.py +4 -0
- metascalp/client.py +317 -0
- metascalp/socket.py +457 -0
- metascalp-1.1.0.dist-info/METADATA +233 -0
- metascalp-1.1.0.dist-info/RECORD +7 -0
- metascalp-1.1.0.dist-info/WHEEL +5 -0
- metascalp-1.1.0.dist-info/top_level.txt +1 -0
metascalp/__init__.py
ADDED
metascalp/client.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""MetaScalp REST API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
HTTP_PORT_START = 17845
|
|
9
|
+
HTTP_PORT_END = 17855
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MetaScalpApiError(Exception):
|
|
13
|
+
"""Raised when the MetaScalp API returns an error response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, error: str, path: str):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.error = error
|
|
18
|
+
self.path = path
|
|
19
|
+
super().__init__(f"MetaScalp API error {status} on {path}: {error}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MetaScalpClient:
|
|
23
|
+
"""HTTP REST client for MetaScalp API.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
client = await MetaScalpClient.discover()
|
|
27
|
+
connections = await client.get_connections()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, port: int):
|
|
31
|
+
self.port = port
|
|
32
|
+
self.base_url = f"http://127.0.0.1:{port}"
|
|
33
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
async def discover(cls, timeout: float = 1.0) -> MetaScalpClient:
|
|
37
|
+
"""Scan ports 17845-17855 to find a running MetaScalp instance."""
|
|
38
|
+
timeout_obj = aiohttp.ClientTimeout(total=timeout)
|
|
39
|
+
async with aiohttp.ClientSession(timeout=timeout_obj) as session:
|
|
40
|
+
for port in range(HTTP_PORT_START, HTTP_PORT_END + 1):
|
|
41
|
+
try:
|
|
42
|
+
async with session.get(f"http://127.0.0.1:{port}/ping") as resp:
|
|
43
|
+
if resp.status == 200:
|
|
44
|
+
data = await resp.json()
|
|
45
|
+
if data.get("app") == "MetaScalp":
|
|
46
|
+
return cls(port)
|
|
47
|
+
except (aiohttp.ClientError, OSError):
|
|
48
|
+
continue
|
|
49
|
+
raise ConnectionError(
|
|
50
|
+
f"MetaScalp not found on ports {HTTP_PORT_START}-{HTTP_PORT_END}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
54
|
+
if self._session is None or self._session.closed:
|
|
55
|
+
self._session = aiohttp.ClientSession()
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
async def close(self) -> None:
|
|
59
|
+
"""Close the underlying HTTP session."""
|
|
60
|
+
if self._session and not self._session.closed:
|
|
61
|
+
await self._session.close()
|
|
62
|
+
|
|
63
|
+
async def _get(self, path: str) -> Any:
|
|
64
|
+
session = await self._ensure_session()
|
|
65
|
+
async with session.get(f"{self.base_url}{path}") as resp:
|
|
66
|
+
data = await resp.json()
|
|
67
|
+
if resp.status >= 400:
|
|
68
|
+
raise MetaScalpApiError(resp.status, data.get("error", ""), path)
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
async def _post(self, path: str, body: Any) -> Any:
|
|
72
|
+
session = await self._ensure_session()
|
|
73
|
+
async with session.post(
|
|
74
|
+
f"{self.base_url}{path}", json=body
|
|
75
|
+
) as resp:
|
|
76
|
+
data = await resp.json()
|
|
77
|
+
if resp.status >= 400:
|
|
78
|
+
raise MetaScalpApiError(resp.status, data.get("error", ""), path)
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
async def _put(self, path: str, body: dict) -> Any:
|
|
82
|
+
session = await self._ensure_session()
|
|
83
|
+
async with session.put(f"{self.base_url}{path}", json=body) as resp:
|
|
84
|
+
data = await resp.json()
|
|
85
|
+
if resp.status >= 400:
|
|
86
|
+
raise MetaScalpApiError(resp.status, data.get("error", ""), path)
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
async def _delete(self, path: str) -> Any:
|
|
90
|
+
session = await self._ensure_session()
|
|
91
|
+
async with session.delete(f"{self.base_url}{path}") as resp:
|
|
92
|
+
data = await resp.json()
|
|
93
|
+
if resp.status >= 400:
|
|
94
|
+
raise MetaScalpApiError(resp.status, data.get("error", ""), path)
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
# ---- Discovery ----
|
|
98
|
+
|
|
99
|
+
async def ping(self) -> dict:
|
|
100
|
+
"""Check MetaScalp instance and get version."""
|
|
101
|
+
return await self._get("/ping")
|
|
102
|
+
|
|
103
|
+
# ---- Connections ----
|
|
104
|
+
|
|
105
|
+
async def get_connections(self) -> dict:
|
|
106
|
+
"""List all active exchange connections."""
|
|
107
|
+
return await self._get("/api/connections")
|
|
108
|
+
|
|
109
|
+
async def get_connection(self, connection_id: int) -> dict:
|
|
110
|
+
"""Get a single connection by ID."""
|
|
111
|
+
return await self._get(f"/api/connections/{connection_id}")
|
|
112
|
+
|
|
113
|
+
# ---- Market Data Queries ----
|
|
114
|
+
|
|
115
|
+
async def get_tickers(self, connection_id: int, refresh: bool = False) -> dict:
|
|
116
|
+
"""List available tickers on a connection. Set refresh=True to fetch fresh data from exchange."""
|
|
117
|
+
qs = "?Refresh=true" if refresh else ""
|
|
118
|
+
return await self._get(f"/api/connections/{connection_id}/tickers{qs}")
|
|
119
|
+
|
|
120
|
+
# ---- Trading Data ----
|
|
121
|
+
|
|
122
|
+
async def get_orders(self, connection_id: int, ticker: str) -> dict:
|
|
123
|
+
"""Get open orders for a ticker."""
|
|
124
|
+
return await self._get(
|
|
125
|
+
f"/api/connections/{connection_id}/orders?Ticker={ticker}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def get_positions(self, connection_id: int) -> dict:
|
|
129
|
+
"""Get open positions on a connection."""
|
|
130
|
+
return await self._get(f"/api/connections/{connection_id}/positions")
|
|
131
|
+
|
|
132
|
+
async def get_balance(self, connection_id: int) -> dict:
|
|
133
|
+
"""Get account balances on a connection."""
|
|
134
|
+
return await self._get(f"/api/connections/{connection_id}/balance")
|
|
135
|
+
|
|
136
|
+
# ---- Order Execution ----
|
|
137
|
+
|
|
138
|
+
async def place_order(
|
|
139
|
+
self,
|
|
140
|
+
connection_id: int,
|
|
141
|
+
*,
|
|
142
|
+
ticker: str,
|
|
143
|
+
side: int,
|
|
144
|
+
price: float,
|
|
145
|
+
size: float,
|
|
146
|
+
type: int = 0,
|
|
147
|
+
reduce_only: bool = False,
|
|
148
|
+
) -> dict:
|
|
149
|
+
"""Place an order on the exchange."""
|
|
150
|
+
return await self._post(
|
|
151
|
+
f"/api/connections/{connection_id}/orders",
|
|
152
|
+
{
|
|
153
|
+
"ticker": ticker,
|
|
154
|
+
"side": side,
|
|
155
|
+
"price": price,
|
|
156
|
+
"size": size,
|
|
157
|
+
"type": type,
|
|
158
|
+
"reduceOnly": reduce_only,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def cancel_order(
|
|
163
|
+
self,
|
|
164
|
+
connection_id: int,
|
|
165
|
+
*,
|
|
166
|
+
ticker: str,
|
|
167
|
+
order_id: int,
|
|
168
|
+
type: int = 0,
|
|
169
|
+
) -> dict:
|
|
170
|
+
"""Cancel an existing order."""
|
|
171
|
+
return await self._post(
|
|
172
|
+
f"/api/connections/{connection_id}/orders/cancel",
|
|
173
|
+
{"ticker": ticker, "orderId": order_id, "type": type},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# ---- UI Control ----
|
|
177
|
+
|
|
178
|
+
async def change_ticker(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
ticker_pattern: Optional[str] = None,
|
|
182
|
+
exchange: Optional[int] = None,
|
|
183
|
+
market: Optional[int] = None,
|
|
184
|
+
ticker: Optional[str] = None,
|
|
185
|
+
binding: Optional[str] = None,
|
|
186
|
+
) -> dict:
|
|
187
|
+
"""Switch the active ticker in MetaScalp UI."""
|
|
188
|
+
body: dict[str, Any] = {}
|
|
189
|
+
if ticker_pattern is not None:
|
|
190
|
+
body["tickerPattern"] = ticker_pattern
|
|
191
|
+
if exchange is not None:
|
|
192
|
+
body["exchange"] = exchange
|
|
193
|
+
if market is not None:
|
|
194
|
+
body["market"] = market
|
|
195
|
+
if ticker is not None:
|
|
196
|
+
body["ticker"] = ticker
|
|
197
|
+
if binding is not None:
|
|
198
|
+
body["binding"] = binding
|
|
199
|
+
return await self._post("/api/change-ticker", body)
|
|
200
|
+
|
|
201
|
+
async def open_combo(self, ticker: str) -> dict:
|
|
202
|
+
"""Open a combo layout for a ticker."""
|
|
203
|
+
return await self._post("/api/combo", {"ticker": ticker})
|
|
204
|
+
|
|
205
|
+
# ---- Signal Levels ----
|
|
206
|
+
|
|
207
|
+
async def get_signal_levels(self, connection_id: int, ticker: str) -> dict:
|
|
208
|
+
"""Get signal levels for a ticker on a connection."""
|
|
209
|
+
return await self._get(
|
|
210
|
+
f"/api/connections/{connection_id}/signal-levels?Ticker={ticker}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def place_signal_level(self, connection_id: int, *, ticker: str, price: float) -> dict:
|
|
214
|
+
"""Place a signal level on a connection."""
|
|
215
|
+
return await self._post(
|
|
216
|
+
f"/api/connections/{connection_id}/signal-levels",
|
|
217
|
+
{"Ticker": ticker, "Price": price},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
async def remove_signal_level(self, connection_id: int, signal_level_id: int) -> dict:
|
|
221
|
+
"""Remove a signal level by ID."""
|
|
222
|
+
return await self._delete(
|
|
223
|
+
f"/api/connections/{connection_id}/signal-levels/{signal_level_id}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def remove_all_signal_levels(self, connection_id: int, ticker: str) -> dict:
|
|
227
|
+
"""Remove all signal levels for a ticker on a connection."""
|
|
228
|
+
return await self._delete(
|
|
229
|
+
f"/api/connections/{connection_id}/signal-levels?Ticker={ticker}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def remove_triggered_signal_levels(self) -> dict:
|
|
233
|
+
"""Remove all triggered signal levels."""
|
|
234
|
+
return await self._delete("/api/signal-levels/triggered")
|
|
235
|
+
|
|
236
|
+
# ---- Order Book Settings ----
|
|
237
|
+
|
|
238
|
+
async def get_orderbook_settings(self, connection_id: int, ticker: str) -> dict:
|
|
239
|
+
"""Get order book settings for a ticker on a connection."""
|
|
240
|
+
return await self._get(
|
|
241
|
+
f"/api/connections/{connection_id}/orderbook-settings?Ticker={ticker}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def update_orderbook_settings(self, connection_id: int, ticker: str, **settings) -> dict:
|
|
245
|
+
"""Update order book settings (partial update). Only provided fields are changed."""
|
|
246
|
+
return await self._put(
|
|
247
|
+
f"/api/connections/{connection_id}/orderbook-settings?Ticker={ticker}",
|
|
248
|
+
settings,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# ---- Order Book Snapshot (one-shot fresh REST fetch) ----
|
|
252
|
+
|
|
253
|
+
async def get_order_book_snapshot(
|
|
254
|
+
self,
|
|
255
|
+
connection_id: int,
|
|
256
|
+
ticker: str,
|
|
257
|
+
zoom_index: int = 0,
|
|
258
|
+
depth_levels: int | None = None,
|
|
259
|
+
depth_percent: float | None = None,
|
|
260
|
+
) -> dict:
|
|
261
|
+
"""Fetch a fresh order book snapshot directly from the exchange REST endpoint.
|
|
262
|
+
|
|
263
|
+
Each call performs one exchange REST request — no cache, no subscription side
|
|
264
|
+
effects. Intended as a one-shot complement to subscribe_order_book(..., fetch_snapshot=False):
|
|
265
|
+
subscribe to deltas cheaply, then call this once per ticker when you need to seed
|
|
266
|
+
state.
|
|
267
|
+
|
|
268
|
+
Raises MetaScalpApiError with status 501 for exchanges that don't expose a REST
|
|
269
|
+
snapshot endpoint (e.g. Bybit USDT Perpetual, which only delivers snapshots over WS).
|
|
270
|
+
|
|
271
|
+
The caller is responsible for not exceeding the exchange's rate limit when invoking
|
|
272
|
+
this for many tickers in quick succession.
|
|
273
|
+
|
|
274
|
+
Returns a dict with keys: connectionId, ticker, updateId, asks, bids, bestAsk, bestBid.
|
|
275
|
+
"""
|
|
276
|
+
qs = [f"Ticker={ticker}"]
|
|
277
|
+
if zoom_index > 0:
|
|
278
|
+
qs.append(f"ZoomIndex={zoom_index}")
|
|
279
|
+
if depth_levels is not None:
|
|
280
|
+
qs.append(f"DepthLevels={depth_levels}")
|
|
281
|
+
if depth_percent is not None:
|
|
282
|
+
qs.append(f"DepthPercent={depth_percent}")
|
|
283
|
+
return await self._get(
|
|
284
|
+
f"/api/connections/{connection_id}/orderbook-snapshot?{'&'.join(qs)}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# ---- Clusters ----
|
|
288
|
+
|
|
289
|
+
async def get_cluster_snapshot(
|
|
290
|
+
self,
|
|
291
|
+
connection_id: int,
|
|
292
|
+
ticker: str,
|
|
293
|
+
time_frame: str,
|
|
294
|
+
zoom_index: int = 1,
|
|
295
|
+
columns: int | None = None,
|
|
296
|
+
) -> dict:
|
|
297
|
+
"""Fetch the cluster (volume profile / footprint) snapshot for a ticker at a timeframe.
|
|
298
|
+
|
|
299
|
+
time_frame is one of S30, M1, M5, M10, M15, M30, H1, H4, D1.
|
|
300
|
+
zoom_index > 1 groups price levels into buckets of zoom_index * PriceIncrement.
|
|
301
|
+
|
|
302
|
+
The response always holds 100 time columns, oldest first. By default only the newest
|
|
303
|
+
10 carry data (the cluster backend's default page); pass columns (up to 100) to fill
|
|
304
|
+
more history. Each extra 5 columns is one more backend request, so deep fills take
|
|
305
|
+
longer.
|
|
306
|
+
|
|
307
|
+
Returns a dict with keys: Ticker, TimeFrame, ZoomIndex, PriceIncrement, Columns
|
|
308
|
+
(each column: StartTime, AsksSum, BidsSum, Items[Price, AskSize, BidSize]).
|
|
309
|
+
"""
|
|
310
|
+
qs = [f"Ticker={ticker}", f"TimeFrame={time_frame}"]
|
|
311
|
+
if zoom_index > 1:
|
|
312
|
+
qs.append(f"ZoomIndex={zoom_index}")
|
|
313
|
+
if columns is not None and columns > 0:
|
|
314
|
+
qs.append(f"Columns={columns}")
|
|
315
|
+
return await self._get(
|
|
316
|
+
f"/api/connections/{connection_id}/cluster-snapshot?{'&'.join(qs)}"
|
|
317
|
+
)
|
metascalp/socket.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""MetaScalp WebSocket client for real-time updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
import websockets
|
|
10
|
+
from websockets.client import WebSocketClientProtocol
|
|
11
|
+
|
|
12
|
+
WS_PORT_START = 17845
|
|
13
|
+
WS_PORT_END = 17855
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize(obj: Any) -> Any:
|
|
17
|
+
"""Normalize PascalCase keys from server to camelCase."""
|
|
18
|
+
if obj is None:
|
|
19
|
+
return obj
|
|
20
|
+
if isinstance(obj, list):
|
|
21
|
+
return [_normalize(item) for item in obj]
|
|
22
|
+
if isinstance(obj, dict):
|
|
23
|
+
return {k[0].lower() + k[1:]: _normalize(v) for k, v in obj.items()}
|
|
24
|
+
return obj
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MetaScalpSocket:
|
|
28
|
+
"""WebSocket client for MetaScalp real-time updates.
|
|
29
|
+
|
|
30
|
+
Supports connection-level subscriptions (orders, positions, balances)
|
|
31
|
+
and market data subscriptions (trades, order book) scoped by connectionId + ticker.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
socket = await MetaScalpSocket.discover()
|
|
35
|
+
|
|
36
|
+
@socket.on('trade_update')
|
|
37
|
+
def on_trade(data):
|
|
38
|
+
print(data)
|
|
39
|
+
|
|
40
|
+
socket.subscribe(connection_id)
|
|
41
|
+
socket.subscribe_trades(connection_id, 'BTCUSDT')
|
|
42
|
+
await socket.listen_forever()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, port: int):
|
|
46
|
+
self.port = port
|
|
47
|
+
self._ws: Optional[WebSocketClientProtocol] = None
|
|
48
|
+
self._listeners: dict[str, list[Callable]] = {}
|
|
49
|
+
self._connected = False
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
async def discover(cls, timeout: float = 1.0) -> MetaScalpSocket:
|
|
53
|
+
"""Scan ports 17845-17855 to find the MetaScalp WebSocket server."""
|
|
54
|
+
for port in range(WS_PORT_START, WS_PORT_END + 1):
|
|
55
|
+
try:
|
|
56
|
+
ws = await asyncio.wait_for(
|
|
57
|
+
websockets.connect(f"ws://127.0.0.1:{port}/"),
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
)
|
|
60
|
+
socket = cls(port)
|
|
61
|
+
socket._ws = ws
|
|
62
|
+
socket._connected = True
|
|
63
|
+
return socket
|
|
64
|
+
except (ConnectionRefusedError, OSError, asyncio.TimeoutError):
|
|
65
|
+
continue
|
|
66
|
+
raise ConnectionError(
|
|
67
|
+
f"MetaScalp WebSocket not found on ports {WS_PORT_START}-{WS_PORT_END}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def connected(self) -> bool:
|
|
72
|
+
return self._connected
|
|
73
|
+
|
|
74
|
+
async def connect(self, timeout: float = 5.0) -> None:
|
|
75
|
+
"""Connect to the WebSocket server."""
|
|
76
|
+
self._ws = await asyncio.wait_for(
|
|
77
|
+
websockets.connect(f"ws://127.0.0.1:{self.port}/"),
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
)
|
|
80
|
+
self._connected = True
|
|
81
|
+
|
|
82
|
+
async def disconnect(self) -> None:
|
|
83
|
+
"""Disconnect from the WebSocket server."""
|
|
84
|
+
if self._ws:
|
|
85
|
+
await self._ws.close()
|
|
86
|
+
self._ws = None
|
|
87
|
+
self._connected = False
|
|
88
|
+
|
|
89
|
+
# ---- Connection-level subscriptions ----
|
|
90
|
+
# Use these to receive order, position, balance, and finres updates
|
|
91
|
+
# for ALL tickers on a connection.
|
|
92
|
+
# Events: 'order_update', 'position_update', 'balance_update', 'finres_update'
|
|
93
|
+
|
|
94
|
+
def subscribe(self, connection_id: int) -> None:
|
|
95
|
+
"""Subscribe to order, position, balance, and finres updates for a connection.
|
|
96
|
+
|
|
97
|
+
This covers ALL tickers on the connection.
|
|
98
|
+
|
|
99
|
+
Events you'll receive:
|
|
100
|
+
- 'order_update' — order created/modified/filled/cancelled
|
|
101
|
+
- 'position_update' — position opened/changed/closed
|
|
102
|
+
- 'balance_update' — account balances changed
|
|
103
|
+
- 'finres_update' — financial results recalculated
|
|
104
|
+
"""
|
|
105
|
+
self._send("subscribe", {"connectionId": connection_id})
|
|
106
|
+
|
|
107
|
+
def unsubscribe(self, connection_id: int) -> None:
|
|
108
|
+
"""Unsubscribe from connection-level updates."""
|
|
109
|
+
self._send("unsubscribe", {"connectionId": connection_id})
|
|
110
|
+
|
|
111
|
+
# ---- Market data subscriptions ----
|
|
112
|
+
# Use these to receive real-time market data for a SPECIFIC ticker on a connection.
|
|
113
|
+
# These are independent from subscribe() — you can use one without the other.
|
|
114
|
+
# Events: 'trade_update', 'orderbook_snapshot', 'orderbook_update'
|
|
115
|
+
|
|
116
|
+
def subscribe_trades(self, connection_id: int, ticker: str) -> None:
|
|
117
|
+
"""Subscribe to real-time trade updates for a specific ticker.
|
|
118
|
+
|
|
119
|
+
Independent from subscribe() — only sends trade data for this exact ticker.
|
|
120
|
+
|
|
121
|
+
Event: 'trade_update'
|
|
122
|
+
|
|
123
|
+
Trades are aggregated server-side using the order book's `AddingTicksForAPeriod`
|
|
124
|
+
setting (per-(connection, ticker), default 200 ms). Consecutive same-side trades
|
|
125
|
+
inside the window are merged into one entry — `size` is summed, `price` and `time`
|
|
126
|
+
track the latest merged trade. Set `AddingTicksForAPeriod = 0` in the order book
|
|
127
|
+
settings to receive the raw exchange stream.
|
|
128
|
+
"""
|
|
129
|
+
self._send("trade_subscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
130
|
+
|
|
131
|
+
def unsubscribe_trades(self, connection_id: int, ticker: str) -> None:
|
|
132
|
+
"""Unsubscribe from trade updates for a specific ticker."""
|
|
133
|
+
self._send("trade_unsubscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
134
|
+
|
|
135
|
+
def subscribe_order_book(
|
|
136
|
+
self,
|
|
137
|
+
connection_id: int,
|
|
138
|
+
ticker: str,
|
|
139
|
+
zoom_index: int = 0,
|
|
140
|
+
depth_levels: int | None = None,
|
|
141
|
+
depth_percent: float | None = None,
|
|
142
|
+
fetch_snapshot: bool = True,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Subscribe to order book updates for a specific ticker.
|
|
145
|
+
|
|
146
|
+
When zoom_index is 0 (default), receives full order book + incremental updates.
|
|
147
|
+
When zoom_index > 1, price levels are aggregated into zoomed buckets.
|
|
148
|
+
|
|
149
|
+
Optional filters:
|
|
150
|
+
- depth_levels (must be >= 1): trims the snapshot to the top N price levels per side
|
|
151
|
+
(asks ascending, bids descending), applied AFTER zoom and depth_percent. Filters the
|
|
152
|
+
snapshot ONLY — incremental updates are unaffected.
|
|
153
|
+
- depth_percent (must be > 0): per-side band as a percentage, anchored on best ask /
|
|
154
|
+
best bid (NOT mid). Asks kept where price <= bestAsk * (1 + depth_percent / 100);
|
|
155
|
+
bids where price >= bestBid * (1 - depth_percent / 100). Applies to both the
|
|
156
|
+
snapshot and subsequent updates; the band refreshes from the latest known best ask /
|
|
157
|
+
best bid on each event. If a side's anchor is unknown, that side is not filtered
|
|
158
|
+
(degrades open).
|
|
159
|
+
- fetch_snapshot (default True): when False AND this subscriber is the first to ask for
|
|
160
|
+
the ticker, the exchange REST snapshot fetch is skipped — only the WS delta feed is
|
|
161
|
+
subscribed. Useful for mass-subscribing 100+ tickers without hitting exchange REST
|
|
162
|
+
rate limits. Seed state separately via MetaScalpClient.get_order_book_snapshot()
|
|
163
|
+
when needed. If a later subscriber requests a snapshot, it is fetched lazily and
|
|
164
|
+
delivered to all subscribers.
|
|
165
|
+
bestAsk / bestBid payload fields are never filtered.
|
|
166
|
+
|
|
167
|
+
Events: 'orderbook_snapshot' (once, unless fetch_snapshot=False), then 'orderbook_update'
|
|
168
|
+
"""
|
|
169
|
+
data: dict = {"connectionId": connection_id, "ticker": ticker, "zoomIndex": zoom_index}
|
|
170
|
+
if depth_levels is not None:
|
|
171
|
+
data["depthLevels"] = depth_levels
|
|
172
|
+
if depth_percent is not None:
|
|
173
|
+
data["depthPercent"] = depth_percent
|
|
174
|
+
# Only emit fetchSnapshot when non-default, for compatibility with older servers
|
|
175
|
+
if not fetch_snapshot:
|
|
176
|
+
data["fetchSnapshot"] = False
|
|
177
|
+
self._send("orderbook_subscribe", data)
|
|
178
|
+
|
|
179
|
+
def unsubscribe_order_book(self, connection_id: int, ticker: str) -> None:
|
|
180
|
+
"""Unsubscribe from order book updates for a specific ticker."""
|
|
181
|
+
self._send("orderbook_unsubscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
182
|
+
|
|
183
|
+
def subscribe_mark_price(self, connection_id: int, ticker: str) -> None:
|
|
184
|
+
"""Subscribe to mark price updates for a specific ticker (futures only).
|
|
185
|
+
|
|
186
|
+
No initial snapshot — only live updates as the exchange publishes them.
|
|
187
|
+
Spot connections will not emit any updates even after a successful subscribe.
|
|
188
|
+
|
|
189
|
+
Event: 'mark_price_update'
|
|
190
|
+
"""
|
|
191
|
+
self._send("mark_price_subscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
192
|
+
|
|
193
|
+
def unsubscribe_mark_price(self, connection_id: int, ticker: str) -> None:
|
|
194
|
+
"""Unsubscribe from mark price updates for a specific ticker."""
|
|
195
|
+
self._send("mark_price_unsubscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
196
|
+
|
|
197
|
+
def subscribe_funding(self, connection_id: int, ticker: str) -> None:
|
|
198
|
+
"""Subscribe to funding rate updates for a specific ticker (perpetual futures only).
|
|
199
|
+
|
|
200
|
+
No initial snapshot — only live updates as the exchange publishes them.
|
|
201
|
+
Spot and dated-futures connections will not emit any updates even after a successful subscribe.
|
|
202
|
+
|
|
203
|
+
Event: 'funding_update'
|
|
204
|
+
"""
|
|
205
|
+
self._send("funding_subscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
206
|
+
|
|
207
|
+
def unsubscribe_funding(self, connection_id: int, ticker: str) -> None:
|
|
208
|
+
"""Unsubscribe from funding rate updates for a specific ticker."""
|
|
209
|
+
self._send("funding_unsubscribe", {"connectionId": connection_id, "ticker": ticker})
|
|
210
|
+
|
|
211
|
+
# ---- Notification subscriptions ----
|
|
212
|
+
# App-wide notifications (trades, signal levels, large amounts, screener).
|
|
213
|
+
# Independent from subscribe() — no connectionId required.
|
|
214
|
+
# Events: 'notification_snapshot', 'notification_update'
|
|
215
|
+
|
|
216
|
+
def subscribe_notifications(self) -> None:
|
|
217
|
+
"""Subscribe to app-wide notifications.
|
|
218
|
+
|
|
219
|
+
Receives a snapshot of recent notifications, then live updates.
|
|
220
|
+
Independent from subscribe() — no connectionId required.
|
|
221
|
+
|
|
222
|
+
Events: 'notification_snapshot' (once), then 'notification_update' (continuous)
|
|
223
|
+
"""
|
|
224
|
+
self._send("notification_subscribe", {})
|
|
225
|
+
|
|
226
|
+
def unsubscribe_notifications(self) -> None:
|
|
227
|
+
"""Unsubscribe from notification updates."""
|
|
228
|
+
self._send("notification_unsubscribe", {})
|
|
229
|
+
|
|
230
|
+
# ---- Signal level subscriptions ----
|
|
231
|
+
|
|
232
|
+
def subscribe_signal_levels(self) -> None:
|
|
233
|
+
"""Subscribe to signal level events."""
|
|
234
|
+
self._send("signal_level_subscribe", {})
|
|
235
|
+
|
|
236
|
+
def unsubscribe_signal_levels(self) -> None:
|
|
237
|
+
"""Unsubscribe from signal level events."""
|
|
238
|
+
self._send("signal_level_unsubscribe", {})
|
|
239
|
+
|
|
240
|
+
# ---- MetaBroker analytics streams ----
|
|
241
|
+
# Density map / large trades / liquidations — app-wide feeds relayed from the MetaBroker
|
|
242
|
+
# backend (the same data the terminal's analytics windows show). No connection_id required.
|
|
243
|
+
# One subscription per feed per socket: re-subscribing REPLACES the config.
|
|
244
|
+
|
|
245
|
+
def subscribe_density_map(
|
|
246
|
+
self,
|
|
247
|
+
exchange_markets: list[dict],
|
|
248
|
+
large_coefficient: float | None = None,
|
|
249
|
+
medium_coefficient: float | None = None,
|
|
250
|
+
small_coefficient: float | None = None,
|
|
251
|
+
large_lifetime_minutes: int | None = None,
|
|
252
|
+
medium_lifetime_minutes: int | None = None,
|
|
253
|
+
small_lifetime_minutes: int | None = None,
|
|
254
|
+
included_quote_assets: list[str] | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Subscribe to the MetaBroker density map notifications stream.
|
|
257
|
+
|
|
258
|
+
Order book walls, each reported exactly once on first sight of its id.
|
|
259
|
+
|
|
260
|
+
exchange_markets is required and must be non-empty; each entry is a dict like
|
|
261
|
+
{"exchange": "binance", "market": "futures", "bdsMode": "auto", "bdsValue": 1000000}
|
|
262
|
+
(bdsMode/bdsValue optional — default manual / 1 000 000 USD). All other arguments
|
|
263
|
+
default to the terminal's Density Map window defaults (coefficients 3/2/1,
|
|
264
|
+
lifetimes 5 min, quote assets ["USDT"]; valid assets: USDT, USDC, OTHER).
|
|
265
|
+
Re-subscribing replaces the config without replaying already-notified walls.
|
|
266
|
+
|
|
267
|
+
Events: 'density_map_snapshot' (after the ack; may be empty — it resolves the
|
|
268
|
+
loading state), then 'density_map_update' (continuous).
|
|
269
|
+
"""
|
|
270
|
+
data: dict = {"exchangeMarkets": exchange_markets}
|
|
271
|
+
if large_coefficient is not None:
|
|
272
|
+
data["largeCoefficient"] = large_coefficient
|
|
273
|
+
if medium_coefficient is not None:
|
|
274
|
+
data["mediumCoefficient"] = medium_coefficient
|
|
275
|
+
if small_coefficient is not None:
|
|
276
|
+
data["smallCoefficient"] = small_coefficient
|
|
277
|
+
if large_lifetime_minutes is not None:
|
|
278
|
+
data["largeLifetimeMinutes"] = large_lifetime_minutes
|
|
279
|
+
if medium_lifetime_minutes is not None:
|
|
280
|
+
data["mediumLifetimeMinutes"] = medium_lifetime_minutes
|
|
281
|
+
if small_lifetime_minutes is not None:
|
|
282
|
+
data["smallLifetimeMinutes"] = small_lifetime_minutes
|
|
283
|
+
if included_quote_assets is not None:
|
|
284
|
+
data["includedQuoteAssets"] = included_quote_assets
|
|
285
|
+
self._send("density_map_subscribe", data)
|
|
286
|
+
|
|
287
|
+
def unsubscribe_density_map(self) -> None:
|
|
288
|
+
"""Stop the density map stream (tears down the upstream feed)."""
|
|
289
|
+
self._send("density_map_unsubscribe", {})
|
|
290
|
+
|
|
291
|
+
def subscribe_large_trades(
|
|
292
|
+
self,
|
|
293
|
+
exchange_markets: list[dict],
|
|
294
|
+
aggregation_ms: int | None = None,
|
|
295
|
+
min_amount_usd: float | None = None,
|
|
296
|
+
large_coefficient: float | None = None,
|
|
297
|
+
medium_coefficient: float | None = None,
|
|
298
|
+
small_coefficient: float | None = None,
|
|
299
|
+
included_quote_assets: list[str] | None = None,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Subscribe to the MetaBroker large trades stream.
|
|
302
|
+
|
|
303
|
+
Aggregated trade prints, final and append-only — no snapshot; history starts at
|
|
304
|
+
subscribe time. exchange_markets uses the same shape and defaults as
|
|
305
|
+
subscribe_density_map(). aggregation_ms is 0-60000 (default 500; 0 = every raw
|
|
306
|
+
print individually); quote assets default to ["USDT", "USDC", "OTHER"].
|
|
307
|
+
Re-subscribing replaces the config.
|
|
308
|
+
|
|
309
|
+
Event: 'large_trades_update'
|
|
310
|
+
"""
|
|
311
|
+
data: dict = {"exchangeMarkets": exchange_markets}
|
|
312
|
+
if aggregation_ms is not None:
|
|
313
|
+
data["aggregationMs"] = aggregation_ms
|
|
314
|
+
if min_amount_usd is not None:
|
|
315
|
+
data["minAmountUsd"] = min_amount_usd
|
|
316
|
+
if large_coefficient is not None:
|
|
317
|
+
data["largeCoefficient"] = large_coefficient
|
|
318
|
+
if medium_coefficient is not None:
|
|
319
|
+
data["mediumCoefficient"] = medium_coefficient
|
|
320
|
+
if small_coefficient is not None:
|
|
321
|
+
data["smallCoefficient"] = small_coefficient
|
|
322
|
+
if included_quote_assets is not None:
|
|
323
|
+
data["includedQuoteAssets"] = included_quote_assets
|
|
324
|
+
self._send("large_trades_subscribe", data)
|
|
325
|
+
|
|
326
|
+
def unsubscribe_large_trades(self) -> None:
|
|
327
|
+
"""Stop the large trades stream."""
|
|
328
|
+
self._send("large_trades_unsubscribe", {})
|
|
329
|
+
|
|
330
|
+
def subscribe_liquidations(
|
|
331
|
+
self,
|
|
332
|
+
exchanges: list[str],
|
|
333
|
+
min_notional_usd: float | None = None,
|
|
334
|
+
min_impact_bps: float | None = None,
|
|
335
|
+
asset_class: str | None = None,
|
|
336
|
+
side: str | None = None,
|
|
337
|
+
coin: str | None = None,
|
|
338
|
+
window: str | None = None,
|
|
339
|
+
backfill: int | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Subscribe to the MetaBroker cross-exchange liquidations stream (futures only).
|
|
342
|
+
|
|
343
|
+
No MetaBroker login is needed: the upstream is the screener-v2 hub signed with
|
|
344
|
+
the shared service key.
|
|
345
|
+
|
|
346
|
+
exchanges is required and must be non-empty; valid names: binance, bybit, okx,
|
|
347
|
+
bitget, gate, htx, aster, lighter. asset_class: 'all' | 'crypto' | 'tradfi'
|
|
348
|
+
(default all). side filters by the side of the LIQUIDATED position: 'all' |
|
|
349
|
+
'long' | 'short' (default all). coin is a case-insensitive prefix on the
|
|
350
|
+
resolved coin ('BTC', not 'BTCUSDT'). window ('m5'|'m15'|'h1'|'h4'|'h24',
|
|
351
|
+
default h1) affects totals / top tokens only, never the rows. backfill is the
|
|
352
|
+
snapshot row count, 0-500 (default 200). Re-subscribing replaces the filters
|
|
353
|
+
and the server re-sends a snapshot.
|
|
354
|
+
|
|
355
|
+
Events: 'liquidations_snapshot' (after every subscribe/replace),
|
|
356
|
+
'liquidations_update' (live rows, newest first), 'liquidations_metadata'
|
|
357
|
+
(totals + top tokens, sub-second cadence).
|
|
358
|
+
"""
|
|
359
|
+
data: dict = {"exchanges": exchanges}
|
|
360
|
+
if min_notional_usd is not None:
|
|
361
|
+
data["minNotionalUsd"] = min_notional_usd
|
|
362
|
+
if min_impact_bps is not None:
|
|
363
|
+
data["minImpactBps"] = min_impact_bps
|
|
364
|
+
if asset_class is not None:
|
|
365
|
+
data["assetClass"] = asset_class
|
|
366
|
+
if side is not None:
|
|
367
|
+
data["side"] = side
|
|
368
|
+
if coin is not None:
|
|
369
|
+
data["coin"] = coin
|
|
370
|
+
if window is not None:
|
|
371
|
+
data["window"] = window
|
|
372
|
+
if backfill is not None:
|
|
373
|
+
data["backfill"] = backfill
|
|
374
|
+
self._send("liquidations_subscribe", data)
|
|
375
|
+
|
|
376
|
+
def unsubscribe_liquidations(self) -> None:
|
|
377
|
+
"""Stop the liquidations stream."""
|
|
378
|
+
self._send("liquidations_unsubscribe", {})
|
|
379
|
+
|
|
380
|
+
# ---- Event handling ----
|
|
381
|
+
|
|
382
|
+
def on(self, event: str) -> Callable:
|
|
383
|
+
"""Decorator to register an event listener.
|
|
384
|
+
|
|
385
|
+
Usage:
|
|
386
|
+
@socket.on('trade_update')
|
|
387
|
+
def on_trade(data):
|
|
388
|
+
print(data)
|
|
389
|
+
"""
|
|
390
|
+
def decorator(fn: Callable) -> Callable:
|
|
391
|
+
if event not in self._listeners:
|
|
392
|
+
self._listeners[event] = []
|
|
393
|
+
self._listeners[event].append(fn)
|
|
394
|
+
return fn
|
|
395
|
+
return decorator
|
|
396
|
+
|
|
397
|
+
def add_listener(self, event: str, fn: Callable) -> None:
|
|
398
|
+
"""Register an event listener."""
|
|
399
|
+
if event not in self._listeners:
|
|
400
|
+
self._listeners[event] = []
|
|
401
|
+
self._listeners[event].append(fn)
|
|
402
|
+
|
|
403
|
+
def remove_listener(self, event: str, fn: Callable) -> None:
|
|
404
|
+
"""Remove an event listener."""
|
|
405
|
+
if event in self._listeners:
|
|
406
|
+
self._listeners[event] = [f for f in self._listeners[event] if f is not fn]
|
|
407
|
+
|
|
408
|
+
# ---- Message loop ----
|
|
409
|
+
|
|
410
|
+
async def listen_forever(self) -> None:
|
|
411
|
+
"""Listen for messages and dispatch to registered listeners.
|
|
412
|
+
|
|
413
|
+
Blocks until the connection is closed. Call this after subscribing.
|
|
414
|
+
"""
|
|
415
|
+
if not self._ws:
|
|
416
|
+
raise RuntimeError("Not connected")
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
async for raw in self._ws:
|
|
420
|
+
try:
|
|
421
|
+
data = _normalize(json.loads(raw))
|
|
422
|
+
msg_type = data.get("type", "")
|
|
423
|
+
msg_data = data.get("data")
|
|
424
|
+
self._emit(msg_type, msg_data)
|
|
425
|
+
except json.JSONDecodeError:
|
|
426
|
+
continue
|
|
427
|
+
except websockets.ConnectionClosed:
|
|
428
|
+
pass
|
|
429
|
+
finally:
|
|
430
|
+
self._connected = False
|
|
431
|
+
|
|
432
|
+
async def listen_once(self, timeout: float = 30.0) -> tuple[str, Any]:
|
|
433
|
+
"""Wait for and return a single message as (type, data)."""
|
|
434
|
+
if not self._ws:
|
|
435
|
+
raise RuntimeError("Not connected")
|
|
436
|
+
raw = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
|
|
437
|
+
data = _normalize(json.loads(raw))
|
|
438
|
+
msg_type = data.get("type", "")
|
|
439
|
+
msg_data = data.get("data")
|
|
440
|
+
self._emit(msg_type, msg_data)
|
|
441
|
+
return msg_type, msg_data
|
|
442
|
+
|
|
443
|
+
# ---- Internals ----
|
|
444
|
+
|
|
445
|
+
def _send(self, msg_type: str, data: dict) -> None:
|
|
446
|
+
if not self._ws or not self._connected:
|
|
447
|
+
raise RuntimeError("Not connected")
|
|
448
|
+
asyncio.get_event_loop().create_task(
|
|
449
|
+
self._ws.send(json.dumps({"type": msg_type, "data": data}))
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def _emit(self, event: str, data: Any) -> None:
|
|
453
|
+
for fn in self._listeners.get(event, []):
|
|
454
|
+
try:
|
|
455
|
+
fn(data)
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metascalp
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official SDK for MetaScalp API — trade, stream order book and trades via REST and WebSocket
|
|
5
|
+
Author: MetaScalp
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://metascalp.io
|
|
8
|
+
Project-URL: Repository, https://github.com/MetaScalp/metascalp-sdk
|
|
9
|
+
Keywords: metascalp,trading,api,websocket,orderbook,crypto,market-data
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: aiohttp>=3.8
|
|
18
|
+
Requires-Dist: websockets>=11.0
|
|
19
|
+
|
|
20
|
+
# MetaScalp SDK
|
|
21
|
+
|
|
22
|
+
Official SDK for [MetaScalp](https://metascalp.io) API — connect your trading bots and scripts to the MetaScalp terminal via REST and WebSocket.
|
|
23
|
+
|
|
24
|
+
MetaScalp exposes a local API that lets you query exchange data, execute trades, and stream real-time market data (trades, order book, mark/index price, funding) and account updates (orders, positions, balances) — plus manage signal levels, plain levels, chart annotations, the notification feed and the terminal UI itself, and stream the MetaBroker analytics feeds (density map, large trades, liquidations).
|
|
25
|
+
|
|
26
|
+
## Available SDKs
|
|
27
|
+
|
|
28
|
+
| Language | Directory | Install |
|
|
29
|
+
|----------|-----------|---------|
|
|
30
|
+
| **JavaScript / TypeScript** | [`js/`](./js) | `npm install metascalp` |
|
|
31
|
+
| **Python** | [`python/`](./python) | `pip install metascalp` |
|
|
32
|
+
| **C# / .NET** | [`dotnet/`](./dotnet) | `dotnet add package MetaScalp.Sdk` |
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### JavaScript / TypeScript
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { MetaScalpClient, MetaScalpSocket } from 'metascalp';
|
|
40
|
+
|
|
41
|
+
// REST — discover MetaScalp and query data
|
|
42
|
+
const client = await MetaScalpClient.discover();
|
|
43
|
+
const { connections } = await client.getConnections();
|
|
44
|
+
const conn = connections[0];
|
|
45
|
+
|
|
46
|
+
// Place a limit buy order
|
|
47
|
+
await client.placeOrder(conn.id, {
|
|
48
|
+
ticker: 'BTCUSDT',
|
|
49
|
+
side: 1,
|
|
50
|
+
price: 65000,
|
|
51
|
+
size: 0.01,
|
|
52
|
+
type: 0
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// WebSocket — stream real-time updates
|
|
56
|
+
const socket = await MetaScalpSocket.discover();
|
|
57
|
+
|
|
58
|
+
// Connection-level: orders, positions, balances for ALL tickers on this connection
|
|
59
|
+
socket.subscribe(conn.id);
|
|
60
|
+
socket.on('order_update', (data) => console.log('Order:', data));
|
|
61
|
+
socket.on('position_update', (data) => console.log('Position:', data));
|
|
62
|
+
socket.on('balance_update', (data) => console.log('Balance:', data));
|
|
63
|
+
|
|
64
|
+
// Market data: trades and order book for a SPECIFIC ticker (independent from subscribe)
|
|
65
|
+
socket.subscribeTrades(conn.id, 'BTCUSDT');
|
|
66
|
+
socket.on('trade_update', (data) => console.log('Trade:', data));
|
|
67
|
+
|
|
68
|
+
socket.subscribeOrderBook(conn.id, 'BTCUSDT');
|
|
69
|
+
socket.on('orderbook_snapshot', (data) => console.log('OB Snapshot:', data));
|
|
70
|
+
socket.on('orderbook_update', (data) => console.log('OB Update:', data));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Python
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
from metascalp import MetaScalpClient, MetaScalpSocket
|
|
78
|
+
|
|
79
|
+
async def main():
|
|
80
|
+
# REST
|
|
81
|
+
client = await MetaScalpClient.discover()
|
|
82
|
+
connections = await client.get_connections()
|
|
83
|
+
conn = connections['connections'][0]
|
|
84
|
+
|
|
85
|
+
# Place order
|
|
86
|
+
await client.place_order(conn['id'], ticker='BTCUSDT', side=1, price=65000, size=0.01)
|
|
87
|
+
|
|
88
|
+
# WebSocket
|
|
89
|
+
socket = await MetaScalpSocket.discover()
|
|
90
|
+
|
|
91
|
+
# Connection-level events (from subscribe) — all tickers
|
|
92
|
+
@socket.on('order_update')
|
|
93
|
+
def on_order(data):
|
|
94
|
+
print(f"Order: {data['ticker']} {data['side']} {data['status']}")
|
|
95
|
+
|
|
96
|
+
@socket.on('balance_update')
|
|
97
|
+
def on_balance(data):
|
|
98
|
+
print(f"Balance: {data}")
|
|
99
|
+
|
|
100
|
+
# Market data events (from subscribe_trades / subscribe_order_book) — specific ticker
|
|
101
|
+
@socket.on('trade_update')
|
|
102
|
+
def on_trade(data):
|
|
103
|
+
print(f"Trade: {data}")
|
|
104
|
+
|
|
105
|
+
@socket.on('orderbook_snapshot')
|
|
106
|
+
def on_snapshot(data):
|
|
107
|
+
print(f"Snapshot: {len(data['asks'])} asks, {len(data['bids'])} bids")
|
|
108
|
+
|
|
109
|
+
# Connection-level: orders, positions, balances for ALL tickers
|
|
110
|
+
socket.subscribe(conn['id'])
|
|
111
|
+
|
|
112
|
+
# Market data: trades and order book for a SPECIFIC ticker (independent from subscribe)
|
|
113
|
+
socket.subscribe_trades(conn['id'], 'BTCUSDT')
|
|
114
|
+
socket.subscribe_order_book(conn['id'], 'BTCUSDT')
|
|
115
|
+
|
|
116
|
+
await socket.listen_forever()
|
|
117
|
+
|
|
118
|
+
asyncio.run(main())
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### C# / .NET
|
|
122
|
+
|
|
123
|
+
```csharp
|
|
124
|
+
using MetaScalp.Sdk;
|
|
125
|
+
|
|
126
|
+
// REST
|
|
127
|
+
var client = await MetaScalpClient.DiscoverAsync();
|
|
128
|
+
var connections = await client.GetConnectionsAsync();
|
|
129
|
+
var conn = connections.First();
|
|
130
|
+
|
|
131
|
+
await client.PlaceOrderAsync(conn.Id, new PlaceOrderRequest
|
|
132
|
+
{
|
|
133
|
+
Ticker = "BTCUSDT",
|
|
134
|
+
Side = 1,
|
|
135
|
+
Price = 65000m,
|
|
136
|
+
Size = 0.01m,
|
|
137
|
+
Type = 0
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// WebSocket
|
|
141
|
+
var socket = await MetaScalpSocket.DiscoverAsync();
|
|
142
|
+
|
|
143
|
+
// Connection-level events (from Subscribe) — all tickers
|
|
144
|
+
socket.OnOrderUpdate += (data) => Console.WriteLine($"Order: {data.Ticker} {data.Side} {data.Status}");
|
|
145
|
+
socket.OnBalanceUpdate += (data) => Console.WriteLine($"Balance updated");
|
|
146
|
+
|
|
147
|
+
// Market data events (from SubscribeTrades / SubscribeOrderBook) — specific ticker
|
|
148
|
+
socket.OnTradeUpdate += (data) => Console.WriteLine($"Trade: {data.Ticker} {data.Trades.Count} trades");
|
|
149
|
+
socket.OnOrderBookSnapshot += (data) => Console.WriteLine($"OB: {data.Asks.Count} asks, {data.Bids.Count} bids");
|
|
150
|
+
|
|
151
|
+
// Connection-level: orders, positions, balances for ALL tickers
|
|
152
|
+
socket.Subscribe(conn.Id);
|
|
153
|
+
|
|
154
|
+
// Market data: trades and order book for a SPECIFIC ticker (independent from Subscribe)
|
|
155
|
+
socket.SubscribeTrades(conn.Id, "BTCUSDT");
|
|
156
|
+
socket.SubscribeOrderBook(conn.Id, "BTCUSDT");
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## API Overview
|
|
160
|
+
|
|
161
|
+
Full reference with request/response shapes: [MetaScalp API docs](https://metascalp.github.io/metascalp-sdk/) ([markdown](./docs/MetaScalp-Api.md)).
|
|
162
|
+
|
|
163
|
+
### REST Endpoints
|
|
164
|
+
|
|
165
|
+
| Method | Endpoint | Description |
|
|
166
|
+
|--------|----------|-------------|
|
|
167
|
+
| `GET` | `/ping` | Discover running MetaScalp instance |
|
|
168
|
+
| `GET` | `/api/connections` | List active exchange connections |
|
|
169
|
+
| `GET` | `/api/connections/{id}` | Get single connection details |
|
|
170
|
+
| `GET` | `/api/connections/{id}/tickers` | List available tickers (`?Refresh=true` re-fetches from the exchange) |
|
|
171
|
+
| `GET` | `/api/connections/{id}/orders?Ticker=X` | Get open orders |
|
|
172
|
+
| `GET` | `/api/connections/{id}/positions` | Get open positions |
|
|
173
|
+
| `GET` | `/api/connections/{id}/balance` | Get account balances |
|
|
174
|
+
| `GET` | `/api/connections/{id}/orderbook-snapshot?Ticker=X` | One-shot fresh order book snapshot from the exchange REST endpoint |
|
|
175
|
+
| `GET` | `/api/connections/{id}/cluster-snapshot?Ticker=X&TimeFrame=M5` | Cluster (volume profile) snapshot; `Columns=1..100` fills more history than the default 10 columns |
|
|
176
|
+
| `POST` | `/api/connections/{id}/orders` | Place an order |
|
|
177
|
+
| `POST` | `/api/connections/{id}/orders/cancel` | Cancel an order |
|
|
178
|
+
| `POST` | `/api/connections/{id}/orders/cancel-all` | Cancel all orders for a ticker |
|
|
179
|
+
| `GET/POST/PUT/DELETE` | `/api/connections/{id}/signal-levels[/{slId}]` | Full signal-level CRUD (+ `DELETE /api/signal-levels/triggered`) |
|
|
180
|
+
| `GET/POST/PUT/DELETE` | `/api/connections/{id}/user-levels[/{ulId}]` | Full user (plain) level CRUD |
|
|
181
|
+
| `GET/PUT/POST/DELETE` | `/api/connections/{id}/annotations[/{type}[/{index}]]` | Chart annotations: read all three lists, replace a list, append one, delete by index, clear all |
|
|
182
|
+
| `GET/PUT` | `/api/connections/{id}/orderbook-settings?Ticker=X` | Read / partially update order book settings |
|
|
183
|
+
| `POST` | `/api/notifications` | Inject a custom row into the notification feed |
|
|
184
|
+
| `GET` | `/api/ui/state`, `/api/ui/windows/{windowId}` | Read-only inventory of the open UI (windows, tabs, documents) |
|
|
185
|
+
| `PUT` | `/api/ui/documents/{externalId}/link-number`, `.../ticker` | Set a panel's link group / re-point a panel to another market |
|
|
186
|
+
| `POST` | `/api/change-ticker` | Switch ticker in MetaScalp UI |
|
|
187
|
+
| `POST` | `/api/combo` | Open combo layout |
|
|
188
|
+
|
|
189
|
+
### WebSocket Messages
|
|
190
|
+
|
|
191
|
+
**Connection-level** — subscribe by `connectionId`:
|
|
192
|
+
|
|
193
|
+
| Subscribe | Updates received |
|
|
194
|
+
|-----------|-----------------|
|
|
195
|
+
| `subscribe` | `order_update`, `position_update`, `balance_update`, `finres_update` |
|
|
196
|
+
|
|
197
|
+
**Market data** — subscribe by `connectionId` + `ticker`:
|
|
198
|
+
|
|
199
|
+
| Subscribe | Updates received |
|
|
200
|
+
|-----------|-----------------|
|
|
201
|
+
| `trade_subscribe` | `trade_update` (aggregated ticks carry `highPrice`/`lowPrice`) |
|
|
202
|
+
| `orderbook_subscribe` | `orderbook_snapshot`, `orderbook_update` |
|
|
203
|
+
| `mark_price_subscribe` | `mark_price_update` (futures only) |
|
|
204
|
+
| `index_price_subscribe` | `index_price_update` (futures only) |
|
|
205
|
+
| `funding_subscribe` | `funding_update` (perpetual futures only) |
|
|
206
|
+
| `annotation_subscribe` | one-shot `annotations_snapshot` of the current chart annotations |
|
|
207
|
+
|
|
208
|
+
**App-wide** — no `connectionId` required:
|
|
209
|
+
|
|
210
|
+
| Subscribe | Updates received |
|
|
211
|
+
|-----------|-----------------|
|
|
212
|
+
| `notification_subscribe` | `notification_update` (including custom rows injected via `POST /api/notifications`) |
|
|
213
|
+
| `signal_level_subscribe` | `signal_level_placed/updated/triggered/removed/...` |
|
|
214
|
+
| `user_level_subscribe` | `user_level_placed/updated/removed/...` |
|
|
215
|
+
| `ui_subscribe` | `ui_snapshot`, then `ui_update` on UI changes |
|
|
216
|
+
| `density_map_subscribe` | `density_map_snapshot`, then `density_map_update` — MetaBroker order-book walls, one notification per wall |
|
|
217
|
+
| `large_trades_subscribe` | `large_trades_update` — MetaBroker aggregated large trade prints (append-only, no snapshot) |
|
|
218
|
+
| `liquidations_subscribe` | `liquidations_snapshot`, `liquidations_update`, `liquidations_metadata` — MetaBroker cross-exchange liquidations (no MetaBroker login needed) |
|
|
219
|
+
|
|
220
|
+
> **SDK coverage note.** The js / python / dotnet convenience wrappers currently cover the core surface (connections, orders, positions, balances, market data) plus the MetaBroker analytics streams (density map, large trades, liquidations). The newer families (levels, annotations, notifications, UI) are available through the same clients as plain REST calls / raw WS messages until dedicated wrappers ship.
|
|
221
|
+
|
|
222
|
+
> **Mass-subscribe optimization.** `orderbook_subscribe` accepts an optional `fetchSnapshot` field (default `true`). Pass `false` to skip the exchange REST snapshot fetch when subscribing — useful when subscribing to 100+ tickers at once without hitting exchange REST rate limits. Seed state separately via `GET /api/connections/{id}/orderbook-snapshot` when you need it. A later subscriber that wants a snapshot triggers a lazy fetch that fans out to all listeners.
|
|
223
|
+
|
|
224
|
+
## Connection Details
|
|
225
|
+
|
|
226
|
+
- **Host:** `127.0.0.1` (localhost only)
|
|
227
|
+
- **Port range:** `17845`–`17855` (first available, shared by HTTP and WebSocket)
|
|
228
|
+
- Both REST and WebSocket run on the same port — no separate socket port
|
|
229
|
+
- All SDKs include auto-discovery that scans the port range
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
metascalp/__init__.py,sha256=PWdfEtoNN4Fveo39MGy3oLdqdffdXkuAGrj7JR2c8CE,122
|
|
2
|
+
metascalp/client.py,sha256=M1i5-7x0U1ZYVm12BXlSVN_ZYW8A73ee1aUFof81Ffc,11879
|
|
3
|
+
metascalp/socket.py,sha256=sOM6rKKDE9cbpefY1wu11z2Kiel-SihpZHq080-muqc,19483
|
|
4
|
+
metascalp-1.1.0.dist-info/METADATA,sha256=dV0j2De6mpP56-OGW1h0HUdUbDaeqCceZBK7IrFaDK4,10695
|
|
5
|
+
metascalp-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
metascalp-1.1.0.dist-info/top_level.txt,sha256=pArHJVJ5B00E-WpcoBIHuxmTCr4CLpReZP_74Sv3qj8,10
|
|
7
|
+
metascalp-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metascalp
|