Skip to content

Commit 1739968

Browse files
committed
feat(automations): improve check new feed script
1 parent 6e03ae6 commit 1739968

File tree

1 file changed

+112
-60
lines changed

1 file changed

+112
-60
lines changed

automations/check_new_feed.py

Lines changed: 112 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
import re
34
import sys
45
from dataclasses import dataclass
56
from pathlib import Path
@@ -24,8 +25,21 @@ def get(cls, name: str) -> "NetworkConfig":
2425
return cls.by_name[name]
2526

2627

28+
# Configuration
29+
ISSUES_FILE: Final[Path] = Path("issues.md")
30+
MAX_MARKET_CAP_RANK: Final[int] = 100
31+
MIN_USD_VOLUME: Final[int] = 100_000_000
32+
GITHUB_NEUTRAL_EXIT: Final[int] = 78
33+
HEADER_TEMPLATE: Final[str] = """---
34+
title: "[auto_req]: Potential New Feeds"
35+
assignees:
36+
- dineshpinto
37+
labels:
38+
- enhancement
39+
---
40+
"""
41+
_VOLUME_RE = re.compile(r"^\$?\s*([\d,]+(?:\.\d{1,2})?)$")
2742
REGISTRY_ADDRESS: Final[str] = "0xaD67FE66660Fb8dFE9d6b1b4240d8650e30F6019"
28-
2943
NETWORKS: list[NetworkConfig] = [
3044
NetworkConfig(
3145
name="FlareMainnet",
@@ -48,20 +62,8 @@ def get(cls, name: str) -> "NetworkConfig":
4862
explorer_api_url="https://coston-explorer.flare.network/api",
4963
),
5064
]
51-
5265
NetworkConfig.by_name = {net.name: net for net in NETWORKS}
5366

54-
55-
# Configuration
56-
ISSUES_FILE: Final[Path] = Path("issues.md")
57-
MAX_MARKET_CAP_RANK: Final[int] = 100
58-
HEADER_TEMPLATE: Final[str] = """---
59-
title: "[auto_req]: Potential New Feeds"
60-
assignees: dineshpinto
61-
labels: "enhancement"
62-
---
63-
"""
64-
6567
# Logging
6668
logging.basicConfig(
6769
encoding="utf-8",
@@ -76,12 +78,10 @@ class ExplorerError(RuntimeError):
7678

7779

7880
@retry(stop=stop_after_attempt(3), wait=wait_exponential_jitter(initial=1, max=8))
79-
def _get(
80-
session: requests.Session, **kwargs: dict[str, str] | str
81-
) -> requests.Response:
81+
def _get(session: requests.Session, url: str, **kwargs: Any) -> requests.Response: # noqa: ANN401
8282
"""`requests.get` with automatic retry and logging."""
83-
logger.debug("GET %s", kwargs.get("url") or kwargs.get("url"))
84-
resp = session.get(**kwargs, timeout=10) # pyright: ignore[reportArgumentType]
83+
logger.debug("GET %s params=%s", url, kwargs.get("params"))
84+
resp = session.get(url, timeout=10, **kwargs)
8585
resp.raise_for_status()
8686
return resp
8787

@@ -104,83 +104,135 @@ def get_contract_abi(
104104
raise ExplorerError(msg) from exc
105105

106106

107-
def decode_feed_name(feed: bytes) -> str:
107+
def _decode_feed_name(feed: bytes) -> str:
108108
"""Convert feed bytes (returned by contract) to a plain symbol."""
109-
return feed[1:].decode().rstrip("\x00").split("/")[0]
109+
110+
if not feed:
111+
return ""
112+
raw = feed[1:]
113+
return raw.decode(errors="ignore").rstrip("\x00").split("/")[0]
110114

111115

112116
def get_current_feeds(contract: Contract) -> set[str]:
113-
"""Return a **set** of existing feed symbols."""
114117
feeds: list[bytes] = contract.functions.fetchAllCurrentFeeds().call()[0]
115-
return {decode_feed_name(f) for f in feeds}
118+
return {_decode_feed_name(f).lower() for f in feeds}
116119

117120

118121
def prettify_coin(coin: dict[str, Any]) -> str:
119122
"""Convert `coin` dict into a readable block of Markdown."""
120123
d = coin.get("data", {})
121-
change_24h = d.get("price_change_percentage_24h", {}).get("usd", "N/A")
124+
pct_24 = d.get("price_change_percentage_24h", {}).get("usd")
125+
change_24h = f"{round(pct_24, 2)}" if isinstance(pct_24, (int, float)) else "N/A"
122126
total_vol = d.get("total_volume", "N/A")
123127

124128
return (
125-
f"name: {coin.get('name', 'N/A')}\n"
126-
f"symbol: {coin.get('symbol', 'N/A')}\n"
127-
f"coingecko_id: {coin.get('id', 'N/A')}\n"
128-
f"price_change_percentage_24h: {change_24h}\n"
129-
f"total_volume: {total_vol}\n"
130-
f"coingecko_link: https://www.coingecko.com/en/coins/{coin.get('id', '')}\n"
129+
f"Name: {coin.get('name', 'N/A')}\n"
130+
f"Symbol: {coin.get('symbol', 'N/A')}\n"
131+
f"Coingecko ID: {coin.get('id', 'N/A')}\n"
132+
f"24h Price Change: {change_24h}%\n"
133+
f"Total Volume: {total_vol}\n"
134+
f"Link: https://www.coingecko.com/en/coins/{coin.get('id', '')}\n"
131135
)
132136

133137

134138
def parse_volume(vol_str: str) -> int:
135-
"""Parse volume string and return it as an integer."""
136-
if not vol_str[0] == "$":
137-
raise ValueError(f"Invalid volume string: {vol_str}")
138-
return int(vol_str.strip("$").replace(",", ""))
139+
"""Parse a USD volume string like '$123,456.78' -> 123456 (floor)."""
140+
m = _VOLUME_RE.match(vol_str.strip())
141+
if not m:
142+
msg = f"Invalid volume string: {vol_str!r}"
143+
raise ValueError(msg)
144+
num = m.group(1).replace(",", "")
145+
return int(float(num))
139146

140147

141148
def write_issue(coins: list[dict[str, Any]]) -> None:
142-
"""Write the Markdown issue file."""
143-
body = ["Coins matching criteria:\n"]
144-
body += [f"## {c['name']}\n{prettify_coin(c)}\n" for c in coins]
145-
ISSUES_FILE.write_text(HEADER_TEMPLATE + "\n".join(body), encoding="utf-8")
149+
lines = [
150+
"Coins matching [FIP.08](https://proposals.flare.network/FIP/FIP_8.html) criteria:\n"
151+
]
152+
lines += [f"## {c.get('name', 'N/A')}\n{prettify_coin(c)}\n" for c in coins]
153+
content = HEADER_TEMPLATE + "\n".join(lines)
154+
tmp = ISSUES_FILE.with_suffix(".tmp")
155+
tmp.write_text(
156+
content if content.endswith("\n") else content + "\n", encoding="utf-8"
157+
)
158+
tmp.replace(ISSUES_FILE)
146159
logger.info("Wrote %d coin(s) to %s", len(coins), ISSUES_FILE)
147160

148161

162+
@retry(stop=stop_after_attempt(3), wait=wait_exponential_jitter(initial=1, max=8))
163+
def get_trending(cg: CoinGeckoAPI) -> list[dict[str, Any]]:
164+
data = cg.get_search_trending() # pyright: ignore[reportUnknownMemberType]
165+
return data.get("coins", [])
166+
167+
168+
def _safe_rank(c: dict[str, Any]) -> float:
169+
return c.get("item", {}).get("market_cap_rank") or float("inf")
170+
171+
172+
def _safe_vol(c: dict[str, Any]) -> int:
173+
v = c.get("item", {}).get("data", {}).get("total_volume")
174+
return parse_volume(v) if isinstance(v, str) else 0
175+
176+
177+
def filter_candidates(
178+
trending: list[dict[str, Any]],
179+
current_feeds: set[str],
180+
max_mcap_rank: int,
181+
min_volume: int,
182+
) -> list[dict[str, Any]]:
183+
return [
184+
c["item"]
185+
for c in trending
186+
if _safe_rank(c) < max_mcap_rank
187+
and _safe_vol(c) > min_volume
188+
and c.get("item", {}).get("symbol", "").lower() not in current_feeds
189+
]
190+
191+
149192
def main() -> None:
150193
cg = CoinGeckoAPI()
151-
session = requests.Session()
152194

153195
rpc_url = NetworkConfig.get("FlareMainnet").rpc_url
154-
w3 = Web3(Web3.HTTPProvider(rpc_url))
196+
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 10}))
155197
if not w3.is_connected():
156198
msg = f"Cannot reach RPC at {rpc_url}"
157199
raise ConnectionError(msg)
158-
logger.info("Connected to RPC %s", rpc_url)
200+
logger.info("Connected to %s", rpc_url, extra={"network": "FlareMainnet"})
159201

160-
registry = w3.eth.contract(
161-
address=Web3.to_checksum_address(REGISTRY_ADDRESS),
162-
abi=get_contract_abi(REGISTRY_ADDRESS, session),
163-
)
164-
updater_addr = registry.functions.getContractAddressByName("FastUpdater").call()
165-
fast_updater = w3.eth.contract(
166-
address=Web3.to_checksum_address(updater_addr),
167-
abi=get_contract_abi(updater_addr, session),
168-
)
169-
logger.info("FastUpdater contract %s", fast_updater.address)
202+
with requests.Session() as session:
203+
registry = w3.eth.contract(
204+
address=Web3.to_checksum_address(REGISTRY_ADDRESS),
205+
abi=get_contract_abi(REGISTRY_ADDRESS, session),
206+
)
207+
updater_addr = registry.functions.getContractAddressByName("FastUpdater").call()
208+
fast_updater = w3.eth.contract(
209+
address=Web3.to_checksum_address(updater_addr),
210+
abi=get_contract_abi(updater_addr, session),
211+
)
212+
logger.info("FastUpdater %s", fast_updater.address)
170213

171214
current_feeds = get_current_feeds(fast_updater)
215+
logger.info("Existing feeds: %d", len(current_feeds))
216+
217+
trending = get_trending(cg)
218+
logger.info(
219+
"Found potential coins %s",
220+
[c.get("item", {}).get("name", "N/A") for c in trending],
221+
)
222+
223+
candidates = sorted(
224+
filter_candidates(
225+
trending,
226+
current_feeds,
227+
max_mcap_rank=MAX_MARKET_CAP_RANK,
228+
min_volume=MIN_USD_VOLUME,
229+
),
230+
key=lambda x: (x.get("market_cap_rank") or float("inf"), x.get("name", "")),
231+
)
232+
logger.info("Filtered candidates: %s", [c["name"] for c in candidates])
172233

173-
trending = cg.get_search_trending()["coins"] # pyright: ignore[reportUnknownMemberType]
174-
logger.info("Found potential coins %s", [f"{c['item']['name']}" for c in trending])
175-
candidates = [
176-
c["item"]
177-
for c in trending
178-
if c["item"].get("market_cap_rank", float("inf")) < MAX_MARKET_CAP_RANK
179-
and parse_volume(c["item"]["data"]["total_volume"]) > 100_000_000
180-
and c["item"]["symbol"] not in current_feeds
181-
]
182234
if not candidates:
183-
sys.exit(78) # Skips GitHub workflow 'if' steps
235+
sys.exit(GITHUB_NEUTRAL_EXIT)
184236
write_issue(candidates)
185237

186238

0 commit comments

Comments
 (0)