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 ADDED
@@ -0,0 +1,4 @@
1
+ from .client import MetaScalpClient
2
+ from .socket import MetaScalpSocket
3
+
4
+ __all__ = ['MetaScalpClient', 'MetaScalpSocket']
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ metascalp