mev-bot-watcher 1.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nov1kov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,226 @@
1
+ Metadata-Version: 2.4
2
+ Name: mev-bot-watcher
3
+ Version: 1.7.2
4
+ Summary: CLI tool for analyzing and realtime monitoring of MEV bot profitability on EVM-compatible networks
5
+ Author-email: Nov1kov <spellh1@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Nov1kov/mev-bot-watcher
8
+ Project-URL: Issues, https://github.com/Nov1kov/mev-bot-watcher/issues
9
+ Keywords: mev,ethereum,evm,arbitrage,monitoring,pnl
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business :: Financial
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: PyYAML>=6.0
19
+ Requires-Dist: aiohttp>=3.14
20
+ Requires-Dist: websockets>=16.0
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: croniter>=6.0
23
+ Dynamic: license-file
24
+
25
+ # MEV Bot Watcher
26
+
27
+ [![Tests](https://github.com/Nov1kov/mev-bot-watcher/actions/workflows/tests.yml/badge.svg)](https://github.com/Nov1kov/mev-bot-watcher/actions/workflows/tests.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/mev-bot-watcher)](https://pypi.org/project/mev-bot-watcher/)
29
+ [![Docker](https://img.shields.io/docker/v/nov1kov/mev-watcher?label=docker&sort=semver)](https://hub.docker.com/r/nov1kov/mev-watcher)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
31
+
32
+ [Русская версия](README.ru.md)
33
+
34
+ CLI tool for analyzing and realtime monitoring of MEV bot profitability on EVM-compatible networks (Ethereum, Arbitrum, etc.).
35
+
36
+ Scans historical blocks or subscribes to new ones via WebSocket, finds transactions of a watched address, parses ERC20 Transfer (and WETH Deposit/Withdrawal) events for a set of tracked tokens and calculates P&L converted to USD: `Σ(net per token × price) − gas spent in the native token × native price`.
37
+
38
+ ## Features
39
+
40
+ - **Multi-token P&L in USD** — track the wrapped native token plus any number of `base_tokens` (e.g. USDC/USDT/DAI for stablecoin arbitrage); each token's balance change is valued at its own price and decimals, gas is valued via the native token
41
+ - **Retrospective analysis** — scan a range of blocks, calculate profit per block and total summary
42
+ - **Realtime monitoring** — subscribe to new blocks via WebSocket
43
+ - **Multichain** — multiple networks via config (Ethereum, Arbitrum, etc.)
44
+ - **Telegram notifications** — aggregated reports with configurable interval, USD profit and per-token balances; bot addresses can link to a block explorer via `scanner_url`
45
+ - **Instant loss alerts** — optional per-network `loss_alert_usd`: as soon as a block turns out unprofitable, a separate message is sent with links to the offending transactions
46
+
47
+ ## Configuration
48
+
49
+ Copy `config.example.yaml` to `config.yaml` and fill in your values:
50
+
51
+ ```yaml
52
+ telegram:
53
+ bot_token: 'YOUR_BOT_TOKEN'
54
+ chat_id: 'YOUR_CHAT_ID'
55
+ notify_schedule: '0 * * * *' # cron syntax (every hour)
56
+
57
+ bots:
58
+ ethereum:
59
+ # wrapped_token — the wrapped native token (WETH). Its price values the gas
60
+ # spent, and it is also counted as a profit token.
61
+ wrapped_token: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
62
+ # base_tokens (optional) — extra tokens whose balance change is included in
63
+ # the profit (e.g. stablecoins for arbitrage). Each may have its own decimals.
64
+ base_tokens:
65
+ - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' # USDC
66
+ - '0xdAC17F958D2ee523a2206206994597C13D831ec7' # USDT
67
+ watched_address: '0xYOUR_BOT_ADDRESS'
68
+ # scanner_url (optional) — base URL of the network block explorer. Used to
69
+ # turn the bot address in Telegram notifications into a clickable link.
70
+ # Examples: ethereum — https://etherscan.io/, arbitrum — https://arbiscan.io/
71
+ scanner_url: 'https://etherscan.io/'
72
+ # loss_alert_usd (optional) — send an instant alert as soon as a block's P&L
73
+ # in this network is below -$loss_alert_usd. Omit to disable, 0 for any loss.
74
+ loss_alert_usd: 1.0
75
+ http_rpc_url: 'https://your-rpc-provider.com/api-key'
76
+ ws_rpc_url: 'wss://your-rpc-provider.com/api-key'
77
+ ```
78
+
79
+ Token `symbol`/`decimals` are read from each contract over RPC, and the USD price
80
+ is auto-resolved via CoinGecko by symbol. For tokens that are not the wrapped
81
+ native token, `base_tokens` is optional — omit it for single-token bots.
82
+
83
+ `scanner_url` is optional too: when set, the bot address in Telegram messages
84
+ becomes a clickable link to its page on the block explorer (e.g.
85
+ `https://etherscan.io/address/0x...`); when omitted, the address is shown as
86
+ plain monospace text.
87
+
88
+ ### WebSocket with Basic Auth
89
+
90
+ If your WebSocket endpoint requires HTTP Basic authentication, pass `ws_rpc_url`
91
+ as a nested block with `url`, `login` and `password` fields. Plain string form
92
+ stays supported for endpoints without auth.
93
+
94
+ ```yaml
95
+ bots:
96
+ my_node:
97
+ wrapped_token: '0x...'
98
+ watched_address: '0xYOUR_BOT_ADDRESS'
99
+ http_rpc_url: 'http://user:pass@your-node-ip:8549'
100
+ ws_rpc_url:
101
+ url: 'ws://your-node-ip:8549'
102
+ login: 'your_login'
103
+ password: 'your_password'
104
+ ```
105
+
106
+ ## Telegram notifications
107
+
108
+ Startup message. Per-token balances are fetched via RPC (native `eth_getBalance` + `balanceOf` for each tracked token); USD prices are auto-resolved via CoinGecko:
109
+ ```
110
+ 🚀 MEV Monitor Started
111
+
112
+ • ethereum (WETH — $3,210.50)
113
+ 0xYOUR_BOT_ADDRESS
114
+ 💰 Balance:
115
+ ETH: 1.5000 ($4,815.75)
116
+ USDC: 1000.0000 ($1,000.00)
117
+
118
+ ⏰ Schedule: 0 * * * *
119
+ ```
120
+
121
+ If a token cannot be resolved on CoinGecko, its USD part is omitted and only the amount is shown.
122
+
123
+ Periodic report. Profit is the multi-token P&L converted to USD (token balance
124
+ changes minus gas), and the current per-token balances are refreshed via RPC at
125
+ send time:
126
+ ```
127
+ ✅ ETHEREUM
128
+ 0x1234...5678
129
+ ├ Successful txs: 3/4
130
+ └ Total: $+2.50
131
+ 💰 Balance:
132
+ ETH: 1.4980 ($4,809.32)
133
+ USDC: 1001.7200 ($1,001.72)
134
+ ```
135
+
136
+ ### Instant loss alerts
137
+
138
+ `loss_alert_usd` is set per network, next to that bot's `watched_address` — so
139
+ you can alert on any loss on a cheap L2 and only on large ones on mainnet, or
140
+ leave it out entirely for networks you don't want alerts for.
141
+
142
+ When it is set, every block containing transactions of the watched address is
143
+ priced right after it is read. If its P&L (token balance changes minus gas) is
144
+ below `-loss_alert_usd`, a separate message is sent immediately — without
145
+ waiting for the next scheduled report. The block still counts towards the
146
+ periodic report as usual.
147
+
148
+ Transaction hashes link to the block explorer when `scanner_url` is configured
149
+ (otherwise they are shown as plain monospace text). A block may contain several
150
+ transactions of the bot — all of them are listed:
151
+ ```
152
+ 🚨 LOSS — ETHEREUM $-10.12
153
+ 0x1234...5678
154
+ ├ Block: 21500123
155
+ ├ Txs: 2 (failed: 1)
156
+ ├ WETH: -0.004200 ($-10.50)
157
+ ├ USDC: +1.500000 ($+1.50)
158
+ └ Gas: 0.001500 ETH ($3.75)
159
+ 🔗 0xaa1111...111111, 0xbb2222...222222
160
+ ```
161
+
162
+ `loss_alert_usd: 0` alerts on any loss; a higher value (e.g. `5`) filters out
163
+ noise from cheap failed transactions. USD prices are cached for a minute, so
164
+ frequent blocks do not hammer the CoinGecko API.
165
+
166
+ ## Usage
167
+
168
+ ### Docker
169
+
170
+ Monitor all bots:
171
+ ```bash
172
+ docker run -d -v ./config.yaml:/app/config.yaml nov1kov/mev-watcher
173
+ ```
174
+
175
+ ### Docker Compose
176
+
177
+ Create `docker-compose.yml` next to your `config.yaml`:
178
+
179
+ ```yaml
180
+ services:
181
+ mev-watcher:
182
+ image: nov1kov/mev-watcher
183
+ restart: unless-stopped
184
+ volumes:
185
+ - ./config.yaml:/app/config.yaml
186
+ ```
187
+
188
+ ### pip
189
+
190
+ ```bash
191
+ pip install mev-bot-watcher
192
+ mev-watcher monitor
193
+ mev-watcher analyze -b ethereum -s 18000000
194
+ ```
195
+
196
+ ### From source
197
+
198
+ ```bash
199
+ pip install .
200
+ python -m mev_bot_watcher monitor
201
+ ```
202
+
203
+ ## Commands
204
+
205
+ `monitor` — subscribe to new blocks via WebSocket:
206
+ - `-b, --bot-name <name>` — specific bot (optional; all bots if omitted)
207
+
208
+ `analyze` — retrospective analysis. Exactly one of the block options is required:
209
+ - `-b, --bot-name <name>` — bot to analyze (required)
210
+ - `-s, --start-block <N>` — scan from block N to the latest
211
+ - `-n, --block <N>` — prefetch and analyze a single block (debug mode)
212
+
213
+ `-c, --config <path>` — path to config (default `config.yaml`) for both commands.
214
+
215
+ Examples:
216
+ ```bash
217
+ mev-watcher monitor -b ethereum
218
+ mev-watcher analyze -b ethereum -s 18000000
219
+ mev-watcher analyze -b ethereum -n 18500000
220
+ ```
221
+
222
+ ## Tests
223
+
224
+ ```bash
225
+ python -m unittest discover tests
226
+ ```
@@ -0,0 +1,202 @@
1
+ # MEV Bot Watcher
2
+
3
+ [![Tests](https://github.com/Nov1kov/mev-bot-watcher/actions/workflows/tests.yml/badge.svg)](https://github.com/Nov1kov/mev-bot-watcher/actions/workflows/tests.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/mev-bot-watcher)](https://pypi.org/project/mev-bot-watcher/)
5
+ [![Docker](https://img.shields.io/docker/v/nov1kov/mev-watcher?label=docker&sort=semver)](https://hub.docker.com/r/nov1kov/mev-watcher)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
+
8
+ [Русская версия](README.ru.md)
9
+
10
+ CLI tool for analyzing and realtime monitoring of MEV bot profitability on EVM-compatible networks (Ethereum, Arbitrum, etc.).
11
+
12
+ Scans historical blocks or subscribes to new ones via WebSocket, finds transactions of a watched address, parses ERC20 Transfer (and WETH Deposit/Withdrawal) events for a set of tracked tokens and calculates P&L converted to USD: `Σ(net per token × price) − gas spent in the native token × native price`.
13
+
14
+ ## Features
15
+
16
+ - **Multi-token P&L in USD** — track the wrapped native token plus any number of `base_tokens` (e.g. USDC/USDT/DAI for stablecoin arbitrage); each token's balance change is valued at its own price and decimals, gas is valued via the native token
17
+ - **Retrospective analysis** — scan a range of blocks, calculate profit per block and total summary
18
+ - **Realtime monitoring** — subscribe to new blocks via WebSocket
19
+ - **Multichain** — multiple networks via config (Ethereum, Arbitrum, etc.)
20
+ - **Telegram notifications** — aggregated reports with configurable interval, USD profit and per-token balances; bot addresses can link to a block explorer via `scanner_url`
21
+ - **Instant loss alerts** — optional per-network `loss_alert_usd`: as soon as a block turns out unprofitable, a separate message is sent with links to the offending transactions
22
+
23
+ ## Configuration
24
+
25
+ Copy `config.example.yaml` to `config.yaml` and fill in your values:
26
+
27
+ ```yaml
28
+ telegram:
29
+ bot_token: 'YOUR_BOT_TOKEN'
30
+ chat_id: 'YOUR_CHAT_ID'
31
+ notify_schedule: '0 * * * *' # cron syntax (every hour)
32
+
33
+ bots:
34
+ ethereum:
35
+ # wrapped_token — the wrapped native token (WETH). Its price values the gas
36
+ # spent, and it is also counted as a profit token.
37
+ wrapped_token: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
38
+ # base_tokens (optional) — extra tokens whose balance change is included in
39
+ # the profit (e.g. stablecoins for arbitrage). Each may have its own decimals.
40
+ base_tokens:
41
+ - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' # USDC
42
+ - '0xdAC17F958D2ee523a2206206994597C13D831ec7' # USDT
43
+ watched_address: '0xYOUR_BOT_ADDRESS'
44
+ # scanner_url (optional) — base URL of the network block explorer. Used to
45
+ # turn the bot address in Telegram notifications into a clickable link.
46
+ # Examples: ethereum — https://etherscan.io/, arbitrum — https://arbiscan.io/
47
+ scanner_url: 'https://etherscan.io/'
48
+ # loss_alert_usd (optional) — send an instant alert as soon as a block's P&L
49
+ # in this network is below -$loss_alert_usd. Omit to disable, 0 for any loss.
50
+ loss_alert_usd: 1.0
51
+ http_rpc_url: 'https://your-rpc-provider.com/api-key'
52
+ ws_rpc_url: 'wss://your-rpc-provider.com/api-key'
53
+ ```
54
+
55
+ Token `symbol`/`decimals` are read from each contract over RPC, and the USD price
56
+ is auto-resolved via CoinGecko by symbol. For tokens that are not the wrapped
57
+ native token, `base_tokens` is optional — omit it for single-token bots.
58
+
59
+ `scanner_url` is optional too: when set, the bot address in Telegram messages
60
+ becomes a clickable link to its page on the block explorer (e.g.
61
+ `https://etherscan.io/address/0x...`); when omitted, the address is shown as
62
+ plain monospace text.
63
+
64
+ ### WebSocket with Basic Auth
65
+
66
+ If your WebSocket endpoint requires HTTP Basic authentication, pass `ws_rpc_url`
67
+ as a nested block with `url`, `login` and `password` fields. Plain string form
68
+ stays supported for endpoints without auth.
69
+
70
+ ```yaml
71
+ bots:
72
+ my_node:
73
+ wrapped_token: '0x...'
74
+ watched_address: '0xYOUR_BOT_ADDRESS'
75
+ http_rpc_url: 'http://user:pass@your-node-ip:8549'
76
+ ws_rpc_url:
77
+ url: 'ws://your-node-ip:8549'
78
+ login: 'your_login'
79
+ password: 'your_password'
80
+ ```
81
+
82
+ ## Telegram notifications
83
+
84
+ Startup message. Per-token balances are fetched via RPC (native `eth_getBalance` + `balanceOf` for each tracked token); USD prices are auto-resolved via CoinGecko:
85
+ ```
86
+ 🚀 MEV Monitor Started
87
+
88
+ • ethereum (WETH — $3,210.50)
89
+ 0xYOUR_BOT_ADDRESS
90
+ 💰 Balance:
91
+ ETH: 1.5000 ($4,815.75)
92
+ USDC: 1000.0000 ($1,000.00)
93
+
94
+ ⏰ Schedule: 0 * * * *
95
+ ```
96
+
97
+ If a token cannot be resolved on CoinGecko, its USD part is omitted and only the amount is shown.
98
+
99
+ Periodic report. Profit is the multi-token P&L converted to USD (token balance
100
+ changes minus gas), and the current per-token balances are refreshed via RPC at
101
+ send time:
102
+ ```
103
+ ✅ ETHEREUM
104
+ 0x1234...5678
105
+ ├ Successful txs: 3/4
106
+ └ Total: $+2.50
107
+ 💰 Balance:
108
+ ETH: 1.4980 ($4,809.32)
109
+ USDC: 1001.7200 ($1,001.72)
110
+ ```
111
+
112
+ ### Instant loss alerts
113
+
114
+ `loss_alert_usd` is set per network, next to that bot's `watched_address` — so
115
+ you can alert on any loss on a cheap L2 and only on large ones on mainnet, or
116
+ leave it out entirely for networks you don't want alerts for.
117
+
118
+ When it is set, every block containing transactions of the watched address is
119
+ priced right after it is read. If its P&L (token balance changes minus gas) is
120
+ below `-loss_alert_usd`, a separate message is sent immediately — without
121
+ waiting for the next scheduled report. The block still counts towards the
122
+ periodic report as usual.
123
+
124
+ Transaction hashes link to the block explorer when `scanner_url` is configured
125
+ (otherwise they are shown as plain monospace text). A block may contain several
126
+ transactions of the bot — all of them are listed:
127
+ ```
128
+ 🚨 LOSS — ETHEREUM $-10.12
129
+ 0x1234...5678
130
+ ├ Block: 21500123
131
+ ├ Txs: 2 (failed: 1)
132
+ ├ WETH: -0.004200 ($-10.50)
133
+ ├ USDC: +1.500000 ($+1.50)
134
+ └ Gas: 0.001500 ETH ($3.75)
135
+ 🔗 0xaa1111...111111, 0xbb2222...222222
136
+ ```
137
+
138
+ `loss_alert_usd: 0` alerts on any loss; a higher value (e.g. `5`) filters out
139
+ noise from cheap failed transactions. USD prices are cached for a minute, so
140
+ frequent blocks do not hammer the CoinGecko API.
141
+
142
+ ## Usage
143
+
144
+ ### Docker
145
+
146
+ Monitor all bots:
147
+ ```bash
148
+ docker run -d -v ./config.yaml:/app/config.yaml nov1kov/mev-watcher
149
+ ```
150
+
151
+ ### Docker Compose
152
+
153
+ Create `docker-compose.yml` next to your `config.yaml`:
154
+
155
+ ```yaml
156
+ services:
157
+ mev-watcher:
158
+ image: nov1kov/mev-watcher
159
+ restart: unless-stopped
160
+ volumes:
161
+ - ./config.yaml:/app/config.yaml
162
+ ```
163
+
164
+ ### pip
165
+
166
+ ```bash
167
+ pip install mev-bot-watcher
168
+ mev-watcher monitor
169
+ mev-watcher analyze -b ethereum -s 18000000
170
+ ```
171
+
172
+ ### From source
173
+
174
+ ```bash
175
+ pip install .
176
+ python -m mev_bot_watcher monitor
177
+ ```
178
+
179
+ ## Commands
180
+
181
+ `monitor` — subscribe to new blocks via WebSocket:
182
+ - `-b, --bot-name <name>` — specific bot (optional; all bots if omitted)
183
+
184
+ `analyze` — retrospective analysis. Exactly one of the block options is required:
185
+ - `-b, --bot-name <name>` — bot to analyze (required)
186
+ - `-s, --start-block <N>` — scan from block N to the latest
187
+ - `-n, --block <N>` — prefetch and analyze a single block (debug mode)
188
+
189
+ `-c, --config <path>` — path to config (default `config.yaml`) for both commands.
190
+
191
+ Examples:
192
+ ```bash
193
+ mev-watcher monitor -b ethereum
194
+ mev-watcher analyze -b ethereum -s 18000000
195
+ mev-watcher analyze -b ethereum -n 18500000
196
+ ```
197
+
198
+ ## Tests
199
+
200
+ ```bash
201
+ python -m unittest discover tests
202
+ ```
File without changes
@@ -0,0 +1,3 @@
1
+ from .main import cli
2
+
3
+ cli()
@@ -0,0 +1,119 @@
1
+ import logging
2
+ from typing import Optional, Dict, List, Iterable
3
+
4
+ import aiohttp
5
+
6
+
7
+ COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
8
+
9
+ # Оверрайды для популярных токенов. Нужны для разрешения неоднозначности
10
+ # (у одного тикера может быть много coin id в CoinGecko). Покрывают как
11
+ # нативные токены, так и распространённые стейблкоины (base_tokens).
12
+ _KNOWN_IDS = {
13
+ # native
14
+ "eth": "ethereum",
15
+ "btc": "bitcoin",
16
+ "bnb": "binancecoin",
17
+ "matic": "matic-network",
18
+ "pol": "polygon-ecosystem-token",
19
+ "sol": "solana",
20
+ "avax": "avalanche-2",
21
+ "ftm": "fantom",
22
+ "trx": "tron",
23
+ "near": "near",
24
+ "ada": "cardano",
25
+ "dot": "polkadot",
26
+ "mon": "monad",
27
+ # stablecoins / common base tokens
28
+ "usdc": "usd-coin",
29
+ "usdt": "tether",
30
+ "usde": "ethena-usde",
31
+ "dai": "dai",
32
+ "usds": "usds",
33
+ "frax": "frax",
34
+ "tusd": "true-usd",
35
+ "busd": "binance-usd",
36
+ "usdc.e": "usd-coin",
37
+ }
38
+
39
+
40
+ class CoinGeckoClient:
41
+ """Клиент CoinGecko: резолвит coin id по тикеру и получает цены в USD."""
42
+
43
+ def __init__(self):
44
+ self._session: Optional[aiohttp.ClientSession] = None
45
+ self._symbol_to_ids: Optional[Dict[str, List[str]]] = None
46
+
47
+ async def __aenter__(self):
48
+ self._session = aiohttp.ClientSession()
49
+ return self
50
+
51
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
52
+ if self._session:
53
+ await self._session.close()
54
+ self._session = None
55
+
56
+ async def _ensure_coins_list(self):
57
+ """Одноразовая загрузка списка монет CoinGecko с кешем на процесс."""
58
+ if self._symbol_to_ids is not None:
59
+ return
60
+ url = f"{COINGECKO_BASE_URL}/coins/list"
61
+ async with self._session.get(url) as resp:
62
+ coins = await resp.json()
63
+ mapping: Dict[str, List[str]] = {}
64
+ for coin in coins:
65
+ sym = (coin.get("symbol") or "").lower()
66
+ if not sym:
67
+ continue
68
+ mapping.setdefault(sym, []).append(coin["id"])
69
+ self._symbol_to_ids = mapping
70
+
71
+ async def resolve_id_by_symbol(self, symbol: str) -> Optional[str]:
72
+ """Подбор coingecko id по тикеру.
73
+
74
+ Для wrapped-обёрток (символ начинается с W) первым делом пробуем
75
+ нативный эквивалент: WETH->ETH, WMON->MON и т.д. — это корректно,
76
+ т.к. цены wrapped-токена и нативного равны по смыслу 1:1. Если
77
+ нативный не находится уверенно, падаем обратно к точному поиску
78
+ исходного символа.
79
+ """
80
+ await self._ensure_coins_list()
81
+ sym = symbol.lower()
82
+
83
+ if sym.startswith("w") and len(sym) > 1:
84
+ unwrapped = self._confident_lookup(sym[1:])
85
+ if unwrapped:
86
+ return unwrapped
87
+
88
+ return self._best_effort_lookup(sym)
89
+
90
+ def _confident_lookup(self, sym: str) -> Optional[str]:
91
+ """Возвращает id только если уверены: либо оверрайд, либо id == sym."""
92
+ if sym in _KNOWN_IDS:
93
+ return _KNOWN_IDS[sym]
94
+ for cid in self._symbol_to_ids.get(sym) or []:
95
+ if cid == sym:
96
+ return cid
97
+ return None
98
+
99
+ def _best_effort_lookup(self, sym: str) -> Optional[str]:
100
+ """Точное совпадение по тикеру, даже если однозначности нет."""
101
+ confident = self._confident_lookup(sym)
102
+ if confident:
103
+ return confident
104
+ candidates = self._symbol_to_ids.get(sym) or []
105
+ return candidates[0] if candidates else None
106
+
107
+ async def get_prices_usd(self, ids: Iterable[str]) -> Dict[str, float]:
108
+ """Получение цен сразу пачкой (CoinGecko поддерживает ids через запятую)."""
109
+ unique = sorted({cid for cid in ids if cid})
110
+ if not unique:
111
+ return {}
112
+ url = f"{COINGECKO_BASE_URL}/simple/price?ids={','.join(unique)}&vs_currencies=usd"
113
+ async with self._session.get(url) as resp:
114
+ data = await resp.json()
115
+ return {cid: payload["usd"] for cid, payload in data.items() if "usd" in payload}
116
+
117
+ async def get_price_usd(self, coingecko_id: str) -> Optional[float]:
118
+ prices = await self.get_prices_usd([coingecko_id])
119
+ return prices.get(coingecko_id)