agus1111 commited on
Commit
2c1544f
·
verified ·
1 Parent(s): 9da2f9d

Update autotrack.py

Browse files
Files changed (1) hide show
  1. autotrack.py +404 -283
autotrack.py CHANGED
@@ -1,325 +1,446 @@
1
- # autotrack.py
2
  import os
3
  import re
4
- import math
5
  import asyncio
 
 
6
  from dataclasses import dataclass, field
7
- from datetime import datetime, timezone
8
- from typing import Dict, Optional, List
9
 
10
- import aiohttp
11
  from telethon import TelegramClient, events
12
 
13
  # =========================
14
- # === CONFIG / ENV VARS ===
15
  # =========================
16
- API_ID = int(os.getenv("TELEGRAM_API_ID", "0"))
17
- API_HASH = os.getenv("TELEGRAM_API_HASH", "")
18
- SESSION = os.getenv("TELEGRAM_SESSION", "autotrack.session")
19
 
20
- # Chat tempat botsignal mem-post (sumber) dan tempat reply milestone (biasanya sama)
21
- SOURCE_CHAT = os.getenv("SOURCE_CHAT", "@MidasTouchsignalll")
22
- ANNOUNCE_CHAT = os.getenv("ANNOUNCE_CHAT", SOURCE_CHAT)
23
 
24
- # Milestones (float, dipisah koma) — default: 1.5×, 2×, 3×, 5×, 10×, 20×
25
- MILESTONES_ENV = os.getenv("MILESTONES", "1.5,2,3,5,10,20")
26
- MILESTONES: List[float] = sorted({float(x.strip()) for x in MILESTONES_ENV.split(",") if x.strip()})
27
 
28
- # Mulai arm di 1.5× dihitung dari ENTRY (bisa abaikan; milestone pertama biasanya >= 1.5)
29
- START_MULTIPLIER = float(os.getenv("START_MULTIPLIER", "1.5"))
30
 
31
- # Auto-delete saat turun X% dari entry. 0 = mati. Contoh 0.5 = hapus saat -50%
32
- DROP_DELETE_RATIO = float(os.getenv("DROP_DELETE_RATIO", "0"))
33
 
34
- # Ref Axiom (opsional)
35
- AXIOM_REF = os.getenv("AXIOM_REF", "https://axiom.trade/@1144321")
36
 
37
- # Interval cek harga (detik)
38
- POLL_SEC = int(os.getenv("POLL_SEC", "30"))
39
 
40
- # Aktifkan preview tautan?
41
- LINK_PREVIEW = os.getenv("LINK_PREVIEW", "true").lower() not in {"0", "false", "no"}
42
 
43
- BOT_MARKER = "【MT-AUTOTRACK】"
 
 
44
 
45
- # ===========================
46
- # === REGEX & PARSE UTILS ===
47
- # ===========================
48
- # EVM (0x...) & Solana (base58 32-44) + dukung pump.fun akhiran 'pump'
49
- CA_EVM_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
50
- CA_SOL_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}(?:pump)?$")
51
-
52
- # Cari CA di teks botsignal (baris 'CA: <addr>')
53
- CA_LINE_RE = re.compile(r"(?im)^\s*CA\s*:\s*`?([^\s`]+)`?\s*$")
54
-
55
- # Cari angka Market Cap di teks botsignal: "$213.97K → $325.34K" atau "$213.97K"
56
- MCAP_NUM_RE = re.compile(r"\$([\d.,]+)\s*([KMB])?", re.I)
57
-
58
- def _to_number(amt: str, suffix: Optional[str]) -> float:
59
- """Convert $213.9K/$2.1M/$1.2B to float USD."""
60
- x = float(amt.replace(",", ""))
61
- mult = 1.0
62
- if suffix:
63
- s = suffix.upper()
64
- if s == "K": mult = 1e3
65
- elif s == "M": mult = 1e6
66
- elif s == "B": mult = 1e9
67
- return x * mult
68
-
69
- def _extract_first_mcap_usd(text: str) -> Optional[float]:
70
- m = MCAP_NUM_RE.search(text)
71
- if not m:
72
- return None
73
- return _to_number(m.group(1), m.group(2))
74
-
75
- def _dexs_link_for_ca(ca: str) -> str:
76
- """Universal Dexscreener link. Default ke Ethereum untuk EVM; Solana untuk base58; fallback /token."""
77
- if CA_EVM_RE.fullmatch(ca):
78
- return f"https://dexscreener.com/ethereum/{ca.lower()}"
79
- if CA_SOL_RE.fullmatch(ca):
80
- return f"https://dexscreener.com/solana/{ca}"
81
- # Fallback — Dexscreener juga paham /token/<addr> untuk beberapa chain
82
- return f"https://dexscreener.com/token/{ca}"
83
-
84
- def _fmt_usd(x: float) -> str:
85
- if x >= 1e9: return f"${x/1e9:.2f}B"
86
- if x >= 1e6: return f"${x/1e6:.2f}M"
87
- if x >= 1e3: return f"${x/1e3:.2f}K"
88
- return f"${x:.2f}"
89
-
90
- def _ago_str(dt: datetime) -> str:
91
- s = int((datetime.now(timezone.utc) - dt).total_seconds())
92
- m, s = divmod(s, 60)
93
- return f"{m}m {s}s"
94
-
95
- # ========================
96
- # === DEX API (async) ===
97
- # ========================
98
- DEX_BASE = "https://api.dexscreener.com/latest/dex/tokens"
99
-
100
- async def fetch_mcap_usd(session: aiohttp.ClientSession, ca: str) -> Optional[float]:
101
- """
102
- Ambil marketCap dari Dexscreener. Jika tidak ada, fallback ke FDV.
103
- Return USD float atau None.
104
- """
105
- url = f"{DEX_BASE}/{ca}"
106
- try:
107
- async with session.get(url, timeout=15) as resp:
108
- if resp.status != 200:
109
- return None
110
- data = await resp.json()
111
- except Exception:
112
- return None
113
 
114
- pairs = data.get("pairs") or []
115
- if not pairs:
116
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- # Ambil pair dengan liquidity/volume tertinggi (heuristik sederhana)
119
- best = max(
120
- pairs,
121
- key=lambda p: (
122
- (p.get("liquidity", {}) or {}).get("usd", 0)
123
- + (p.get("volume", {}) or {}).get("h24", 0)
124
- ),
125
- )
126
-
127
- mc = best.get("marketCap")
128
- if mc is None:
129
- mc = best.get("fdv")
130
- if mc is None:
 
 
 
131
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  try:
133
- return float(mc)
134
- except Exception:
 
 
 
 
 
 
 
 
135
  return None
136
 
137
  # =========================
138
- # === TRACKING STRUCTS ===
139
  # =========================
140
  @dataclass
141
  class TrackItem:
142
  ca: str
143
- entry_mcap: float
144
- started_at: datetime
145
- source_msg_id: int # id pesan botsignal yg di-reply
146
- posted: Dict[float, bool] = field(default_factory=dict) # milestone -> posted?
147
- last_announce_id: Optional[int] = None
148
-
149
- def ready_milestones(self, current_mcap: float) -> List[float]:
150
- """Milestone yang belum pernah dipost dan sudah terlewati."""
151
- if not self.entry_mcap or self.entry_mcap <= 0:
152
- return []
153
- mult = current_mcap / self.entry_mcap
154
- return [m for m in MILESTONES if mult >= m and not self.posted.get(m, False)]
155
-
156
- def drop_hit(self, current_mcap: float) -> bool:
157
- if DROP_DELETE_RATIO <= 0:
158
- return False
159
- return current_mcap <= self.entry_mcap * (1 - DROP_DELETE_RATIO)
160
-
161
- # =========================
162
- # === CORE AUTOTRACKER ===
163
- # =========================
164
- class AutoTracker:
165
- def __init__(self, client: TelegramClient):
166
  self.client = client
167
- self.source_chat = SOURCE_CHAT
168
- self.announce_chat = ANNOUNCE_CHAT
169
- self.session = None # aiohttp
170
- self.by_ca: Dict[str, TrackItem] = {} # CA -> TrackItem
171
- self.ca_to_msg: Dict[str, int] = {} # CA -> source message id (botsignal)
172
-
173
- async def start(self):
174
- self.session = aiohttp.ClientSession()
175
- # Listener: tangkap pesan botsignal yang mengandung CA dan entry mcap
176
- @self.client.on(events.NewMessage(chats=self.source_chat))
177
- async def handler(ev):
178
- text = (ev.raw_text or "").strip()
179
-
180
- # Cari CA
181
- ca = None
182
- m = CA_LINE_RE.search(text)
183
- if m:
184
- ca = m.group(1).strip()
185
-
186
- if not ca:
187
- return
188
-
189
- # Normalisasi some pump.fun-style? biarkan apa adanya — Dexscreener menerima addr itu.
190
- entry_mcap = _extract_first_mcap_usd(text) # ambil angka MCAP pertama yg muncul
191
-
192
- # Kalau tidak ada mcap di pesan, jangan arm dulu — tunggu pesan update lain
193
- if not entry_mcap:
194
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
- # Simpan mapping CA -> source message id
197
- self.ca_to_msg[ca] = ev.message.id
198
-
199
- # Arm jika belum ada
200
- if ca not in self.by_ca:
201
- self.by_ca[ca] = TrackItem(
202
- ca=ca,
203
- entry_mcap=entry_mcap,
204
- started_at=datetime.now(timezone.utc),
205
- source_msg_id=ev.message.id,
206
- )
207
- # Optional: kasih jejak kecil di log (tidak kirim ke chat)
208
- print(f"[ARMED] {ca} at {entry_mcap:.2f} USD (msg {ev.message.id})")
209
-
210
- # Loop polling harga
211
- asyncio.create_task(self._poll_loop())
212
-
213
- async def _poll_loop(self):
214
- await self.client.connect()
215
- while True:
216
- try:
217
- await self._tick()
218
- except Exception as e:
219
- print("[POLL_ERR]", e)
220
- await asyncio.sleep(POLL_SEC)
221
-
222
- async def _tick(self):
223
- if not self.by_ca:
224
- return
225
- async with aiohttp.ClientSession() as sess:
226
- for ca, item in list(self.by_ca.items()):
227
- current = await fetch_mcap_usd(sess, ca)
228
- if not current:
229
- continue
230
-
231
- # Auto-delete bila drop
232
- if item.drop_hit(current):
233
- await self._handle_drop_delete(item, current)
234
- # Setelah hapus, hentikan tracking CA ini
235
- self.by_ca.pop(ca, None)
236
- continue
237
-
238
- # Milestones tercapai?
239
- ready = item.ready_milestones(current)
240
- if not ready:
241
- continue
242
-
243
- # Post untuk setiap milestone yang terlewati (urut kecil -> besar)
244
- ready.sort()
245
- for m in ready:
246
- await self._post_milestone(item, m, current)
247
- item.posted[m] = True
248
-
249
- async def _handle_drop_delete(self, item: TrackItem, current_mcap: float):
250
- """Opsional hapus thread ketika drawdown tembus ambang."""
251
  try:
252
- # Hapus pesan milestone terakhir (jika ada)
253
- if item.last_announce_id:
254
- await self.client.delete_messages(self.announce_chat, item.last_announce_id)
255
- # Hapus pesan sumber botsignal
256
- await self.client.delete_messages(self.source_chat, item.source_msg_id)
257
- print(f"[DROP-DELETE] {item.ca} @ {_fmt_usd(current_mcap)} (entry {_fmt_usd(item.entry_mcap)})")
 
 
 
 
 
 
 
 
 
 
258
  except Exception as e:
259
- print("[DROP-DELETE ERR]", e)
260
-
261
- def _milestone_text(self, item: TrackItem, milestone: float, current_mcap: float) -> str:
262
- dexs_link = _dexs_link_for_ca(item.ca)
263
- badge = self._badge_for(milestone)
264
- change_line = f"{_fmt_usd(item.entry_mcap)} → {_fmt_usd(current_mcap)} (~{current_mcap/item.entry_mcap:.2f}×)"
265
- axiom_line = f"🛒 [Trade on Axiom]({AXIOM_REF})" if AXIOM_REF else ""
266
- elapsed = _ago_str(item.started_at)
267
-
268
- lines = [
269
- "💎 [MidasTouch Signal] 💎",
270
- f"{badge}",
271
- f"{change_line}",
272
- f"⏱️ {elapsed} since call",
273
- f"🔎 [Dexscreener]({dexs_link})",
274
- ]
275
- if axiom_line:
276
- lines.append(axiom_line)
277
- lines.append(f"CA: `{item.ca}`")
278
-
279
- return "\n".join(lines)
280
-
281
- def _badge_for(self, m: float) -> str:
282
- if m >= 10:
283
- return f"🚀 **Spectacular Milestone {m:g}×**"
284
- if m >= 5:
285
- return f"🚀 **Milestone {m:g}×**"
286
- if m >= 3:
287
- return f"🔥 Milestone {m:g}×"
288
- if m >= 2:
289
- return f"⚡ Milestone {m:g}×"
290
- return f"🎯 Milestone {m:g}×"
291
-
292
- async def _post_milestone(self, item: TrackItem, milestone: float, current_mcap: float):
293
- text = self._milestone_text(item, milestone, current_mcap)
294
  try:
295
- msg = await self.client.send_message(
296
- self.announce_chat,
297
- f"{text}\n\n{BOT_MARKER}",
298
- reply_to=item.source_msg_id,
299
- link_preview=LINK_PREVIEW, # Penting: preview ON agar link Dexs klik-able & terlihat
300
- )
301
- item.last_announce_id = msg.id
302
- print(f"[MILESTONE] {item.ca} hit {milestone}× → posted (msg {msg.id})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  except Exception as e:
304
- print("[POST_ERR]", e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
  # =========================
307
- # === BOOTSTRAP / MAIN ===
308
  # =========================
309
- def main():
310
- if not API_ID or not API_HASH:
311
- raise SystemExit("Set TELEGRAM_API_ID/TELEGRAM_API_HASH environment variables first.")
 
312
 
313
- client = TelegramClient(SESSION, API_ID, API_HASH)
314
- tracker = AutoTracker(client)
315
 
316
- async def runner():
317
- await tracker.start()
318
- print("[BOOT] autotrack attached ✓")
319
- await client.run_until_disconnected()
320
 
321
- with client:
322
- client.loop.run_until_complete(runner())
 
323
 
324
- if __name__ == "__main__":
325
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # autotrack.py — Unlimited milestones + auto-delete on drawdown + video ≥3× (fixed)
2
  import os
3
  import re
4
+ import time
5
  import asyncio
6
+ import aiohttp
7
+ import sqlite3
8
  from dataclasses import dataclass, field
9
+ from typing import Optional, Dict, Tuple
10
+ from datetime import datetime, timezone, timedelta
11
 
 
12
  from telethon import TelegramClient, events
13
 
14
  # =========================
15
+ # ENV / CONFIG
16
  # =========================
17
+ API_ID = int(os.environ.get("API_ID", "0")) # TIDAK dipakai langsung di file ini
18
+ API_HASH = os.environ.get("API_HASH", "") # — client disuntik dari botsignal
19
+ STRING_SESSION = os.environ.get("STRING_SESSION", "") # —
20
 
21
+ # Announce ke grup kamu (dipakai juga sebagai sumber pesan)
22
+ TARGET_CHAT = os.environ.get("TARGET_CHAT", "https://t.me/MidasTouchsignalll")
 
23
 
24
+ # Polling interval harga
25
+ TRACK_POLL_SECS = int(os.environ.get("TRACK_POLL_SECS", "20"))
 
26
 
27
+ # Ambang reply awal (default 1.5×)
28
+ REPLY_FROM_MULTIPLE = float(os.environ.get("REPLY_FROM_MULTIPLE", "1.5"))
29
 
30
+ # Unlimited mode: selalu lanjut 2×, 3×, 4×, ...
31
+ STOP_WHEN_HIT = False
32
 
33
+ # Abaikan pesan lebih tua dari (startup - buffer)
34
+ BACKFILL_BUFFER_MINUTES = int(os.environ.get("BACKFILL_BUFFER_MINUTES", "3"))
35
 
36
+ # Marker antirecursive: ditambahkan ke semua pengumuman bot (milestone)
37
+ BOT_MARKER = "【MT-AUTOTRACK】"
38
 
39
+ # Lokasi DB milik botsignal (untuk ambil msg_id pesan awal)
40
+ BOTSIGNAL_DB = os.environ.get("BOTSIGNAL_DB", "/tmp/botsignal.db")
41
 
42
+ # NEW: Auto-delete bila drawdown >= threshold (rasio relatif terhadap entry)
43
+ # Contoh: 0.5 berarti -50%. Set 0 untuk mematikan.
44
+ DROP_DELETE_RATIO = float(os.environ.get("DROP_DELETE_RATIO", "0.5"))
45
 
46
+ # (opsional) juga hapus thread balasan bot yang bertanda BOT_MARKER
47
+ DELETE_THREAD_REPLIES = os.environ.get("DELETE_THREAD_REPLIES", "0") == "1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ # >>> VIDEO milestone ≥ 3×
50
+ MILESTONE_VIDEO = os.environ.get("MILESTONE_VIDEO", "Generating_To_The_Moon_Animation.mp4")
51
+ # FIX: gunakan absolute agar aman di Docker/HF
52
+ VIDEO_PATH = os.path.abspath(MILESTONE_VIDEO)
53
+ VIDEO_MIN_MULTIPLE = float(os.environ.get("VIDEO_MIN_MULTIPLE", "3.0"))
54
+
55
+ # =========================
56
+ # HTTP endpoints
57
+ # =========================
58
+ DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
59
+ JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # free (Solana only)
60
+
61
+ # =========================
62
+ # Helpers
63
+ # =========================
64
+ def _fmt_money(x: Optional[float]) -> str:
65
+ if x is None:
66
+ return "?"
67
+ if x >= 1:
68
+ return f"${x:,.4f}"
69
+ return f"${x:.8f}"
70
+
71
+ def _fmt_big(x: Optional[float]) -> str:
72
+ if x is None:
73
+ return "?"
74
+ if x >= 1_000_000_000:
75
+ return f"${x/1_000_000_000:.2f}B"
76
+ if x >= 1_000_000:
77
+ return f"${x/1_000_000:.2f}M"
78
+ if x >= 1_000:
79
+ return f"${x/1_000:.2f}K"
80
+ return f"${x:.0f}"
81
+
82
+ def _milestone_badge(m: float, basis: str) -> str:
83
+ unit = "Market Cap" if basis == "mcap" else "Price"
84
+ if m < 2.0 - 1e-9: return f"🟨 **ARMED — {m:.1f}× {unit}**"
85
+ elif abs(m - 2.0) < 1e-9:return f"🟩 **DOUBLE UP — 2× {unit}!**"
86
+ elif abs(m - 3.0) < 1e-9:return f"🔷 **TRIPLE — 3× {unit}!**"
87
+ elif 3.0 < m < 6.0: return f"🔶 **RALLY — {m:.0f}× {unit}!**"
88
+ elif 6.0 <= m < 10.0: return f"🟥 **OVERDRIVE — {m:.0f}× {unit}!**"
89
+ else: return f"🟣 **GOD CANDLE — {m:.0f}× {unit}!!**"
90
+
91
+ # =========================
92
+ # CA extraction
93
+ # =========================
94
+ CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b")
95
+ CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b")
96
+ CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*(\S+)", re.IGNORECASE)
97
 
98
+ def extract_ca(text: str) -> Optional[str]:
99
+ if not text:
100
+ return None
101
+ m = CA_LABEL_RE.search(text)
102
+ if m:
103
+ cand = m.group(1)
104
+ if CA_EVM_RE.fullmatch(cand): return cand.lower()
105
+ if CA_SOL_RE.fullmatch(cand): return cand
106
+ m2 = CA_EVM_RE.search(text or "")
107
+ if m2: return m2.group(0).lower()
108
+ m3 = CA_SOL_RE.search(text or "")
109
+ if m3: return m3.group(0)
110
+ return None
111
+
112
+ def ca_key_for_db(ca: str) -> Optional[str]:
113
+ if not ca:
114
  return None
115
+ if CA_EVM_RE.fullmatch(ca): return f"ca:evm:{ca.lower()}"
116
+ if CA_SOL_RE.fullmatch(ca): return f"ca:sol:{ca}"
117
+ return None
118
+
119
+ def ensure_db():
120
+ try:
121
+ conn = sqlite3.connect(BOTSIGNAL_DB)
122
+ conn.executescript("""
123
+ CREATE TABLE IF NOT EXISTS last_posted (
124
+ keyword TEXT PRIMARY KEY,
125
+ msg_id INTEGER NOT NULL,
126
+ tier TEXT
127
+ );
128
+ """)
129
+ conn.commit()
130
+ conn.close()
131
+ except Exception as e:
132
+ print(f"[AUTOTRACK] DB init error: {e}")
133
+
134
+ def lookup_origin_msg_id(keyword: str) -> Optional[int]:
135
  try:
136
+ conn = sqlite3.connect(BOTSIGNAL_DB)
137
+ cur = conn.cursor()
138
+ cur.execute("SELECT msg_id FROM last_posted WHERE keyword = ?", (keyword,))
139
+ row = cur.fetchone()
140
+ conn.close()
141
+ if row and isinstance(row[0], int):
142
+ return row[0]
143
+ return None
144
+ except Exception as e:
145
+ print(f"[TRACK][DB] lookup error: {e}")
146
  return None
147
 
148
  # =========================
149
+ # Tracker core (UNLIMITED)
150
  # =========================
151
  @dataclass
152
  class TrackItem:
153
  ca: str
154
+ basis: str = "mcap" # "mcap" or "price"
155
+ entry_price: Optional[float] = None
156
+ entry_mcap: Optional[float] = None
157
+ symbol_hint: Optional[str] = None
158
+ source_link: Optional[str] = None
159
+ poll_secs: int = 20
160
+ next_milestone: float = field(default_factory=lambda: REPLY_FROM_MULTIPLE)
161
+ started_at: float = field(default_factory=time.time)
162
+
163
+ class PriceTracker:
164
+ def __init__(self, client: TelegramClient, announce_chat):
 
 
 
 
 
 
 
 
 
 
 
 
165
  self.client = client
166
+ self.announce_chat = announce_chat
167
+ self._tasks: Dict[str, asyncio.Task] = {}
168
+ self._items: Dict[str, TrackItem] = {}
169
+
170
+ # -------- HTTP helpers --------
171
+ async def _get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 8):
172
+ tout = aiohttp.ClientTimeout(total=timeout)
173
+ async with aiohttp.ClientSession(timeout=tout, headers=headers) as sess:
174
+ async with sess.get(url) as r:
175
+ if r.status != 200:
176
+ return None
177
+ return await r.json()
178
+
179
+ async def _dexscreener_price(self, ca: str) -> Optional[Tuple[Optional[float], Optional[str], Optional[float]]]:
180
+ data = await self._get(DEXSCREENER_TOKEN_URL + ca)
181
+ if not data:
182
+ return None
183
+ pairs = data.get("pairs") or []
184
+ if not pairs:
185
+ return None
186
+ best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
187
+ price = float(best.get("priceUsd")) if best.get("priceUsd") else None
188
+ symbol = ((best.get("baseToken") or {}).get("symbol")) or None
189
+
190
+ mcap = None
191
+ fdv = best.get("fdv")
192
+ if isinstance(fdv, (int, float)) and fdv > 0:
193
+ mcap = float(fdv)
194
+ mc = best.get("marketCap")
195
+ if isinstance(mc, (int, float)) and mc > 0:
196
+ mcap = float(mc)
197
+ return (price, symbol, mcap)
198
+
199
+ async def _jupiter_price(self, ca: str) -> Optional[float]:
200
+ try:
201
+ data = await self._get(JUPITER_URL + ca)
202
+ if not data:
203
+ return None
204
+ d = (data.get("data") or {}).get(ca)
205
+ if not d:
206
+ return None
207
+ p = d.get("price")
208
+ return float(p) if p is not None else None
209
+ except:
210
+ return None
211
+
212
+ async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
213
+ res = await self._dexscreener_price(ca)
214
+ if res:
215
+ price, sym, mcap = res
216
+ if price is None:
217
+ jp = await self._jupiter_price(ca)
218
+ price = jp if jp is not None else None
219
+ return {"price": price, "mcap": mcap, "symbol_hint": sym}
220
+ jp = await self._jupiter_price(ca)
221
+ if jp is not None:
222
+ return {"price": jp, "mcap": None, "symbol_hint": None}
223
+ return None
224
 
225
+ # -------- Announce helpers --------
226
+ def _name(self, item: TrackItem) -> str:
227
+ s = item.symbol_hint or ""
228
+ return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
229
+
230
+ def _milestone_text(self, item: TrackItem, m: float, ratio: float,
231
+ cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
232
+ # NOTE: Dexs link diset ke pola Solana untuk CA sol; untuk EVM bisa kamu tingkatkan bila mau
233
+ dexs_link = f"https://dexscreener.com/solana/{item.ca}" if CA_SOL_RE.fullmatch(item.ca) else f"https://dexscreener.com/ethereum/{item.ca.lower()}"
234
+ axiom_link = "https://axiom.trade/@1144321"
235
+ if item.basis == "mcap":
236
+ change = f"{_fmt_big(item.entry_mcap)} {_fmt_big(cur_mcap)} (~{ratio:.2f}×)"
237
+ else:
238
+ change = f"{_fmt_money(item.entry_price)} → {_fmt_money(cur_price)} (~{ratio:.2f}×)"
239
+ milestone_line = _milestone_badge(m, item.basis)
240
+ elapsed = int(time.time() - item.started_at)
241
+ mm, ss = divmod(elapsed, 60)
242
+ header = "💎 [MidasTouch Signal] 💎\n━━━━━━━━━━━━━━━━"
243
+ footer = "━━━━━━━━━━━━━━━━"
244
+ body = (
245
+ f"{header}\n"
246
+ f"{milestone_line}\n"
247
+ f"{self._name(item)}\n"
248
+ f"{change}\n"
249
+ f"⏱️ {mm}m {ss}s since call\n"
250
+ f"🔎 [Dexscreener]({dexs_link})\n"
251
+ f"🛒 [Trade on Axiom]({axiom_link})\n"
252
+ f"CA: `{item.ca}`\n"
253
+ f"{footer}"
254
+ )
255
+ return body
256
+
257
+ def _next_target_after(self, current_target: float) -> float:
258
+ return 2.0 if current_target < 2.0 else current_target + 1.0
259
+
260
+ async def _delete_origin_and_replies(self, ca: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  try:
262
+ key = ca_key_for_db(ca)
263
+ msg_id = lookup_origin_msg_id(key) if key else None
264
+ if not msg_id:
265
+ return
266
+ await self.client.delete_messages(self.announce_chat, msg_id)
267
+ print(f"[TRACK] Deleted origin message for {ca}")
268
+
269
+ if DELETE_THREAD_REPLIES:
270
+ try:
271
+ async for m in self.client.iter_messages(self.announce_chat, limit=100, reply_to=msg_id):
272
+ txt = (m.message or "") if hasattr(m, "message") else ""
273
+ if BOT_MARKER in (txt or ""):
274
+ await self.client.delete_messages(self.announce_chat, m.id)
275
+ print(f"[TRACK] Deleted thread replies for {ca}")
276
+ except Exception as e:
277
+ print(f"[TRACK] delete thread replies failed: {e}")
278
  except Exception as e:
279
+ print(f"[TRACK] delete origin failed: {e}")
280
+
281
+ # -------- Loop --------
282
+ async def _loop(self, item: TrackItem):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  try:
284
+ snap = await self._get_snapshot(item.ca)
285
+ if not snap:
286
+ print(f"[TRACK] init snapshot failed for {item.ca}")
287
+ return
288
+
289
+ if item.entry_price is None:
290
+ item.entry_price = snap.get("price")
291
+ if item.entry_mcap is None:
292
+ item.entry_mcap = snap.get("mcap")
293
+ if not item.symbol_hint and snap.get("symbol_hint"):
294
+ item.symbol_hint = snap.get("symbol_hint")
295
+
296
+ if item.basis == "mcap" and not item.entry_mcap:
297
+ item.basis = "price"
298
+
299
+ while True:
300
+ try:
301
+ snap = await self._get_snapshot(item.ca)
302
+ if not snap:
303
+ await asyncio.sleep(item.poll_secs)
304
+ continue
305
+
306
+ cur_price = snap.get("price")
307
+ cur_mcap = snap.get("mcap")
308
+ if not item.symbol_hint and snap.get("symbol_hint"):
309
+ item.symbol_hint = snap.get("symbol_hint")
310
+
311
+ if item.basis == "mcap":
312
+ if not (item.entry_mcap and cur_mcap):
313
+ await asyncio.sleep(item.poll_secs); continue
314
+ ratio = (cur_mcap / item.entry_mcap) if item.entry_mcap > 0 else 0.0
315
+ else:
316
+ if not (item.entry_price and cur_price):
317
+ await asyncio.sleep(item.poll_secs); continue
318
+ ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
319
+
320
+ # Auto-delete on drawdown
321
+ if DROP_DELETE_RATIO > 0 and ratio <= DROP_DELETE_RATIO:
322
+ await self._delete_origin_and_replies(item.ca)
323
+ return # stop tracking setelah dihapus
324
+
325
+ # Milestones
326
+ while ratio >= item.next_milestone:
327
+ text = self._milestone_text(item, item.next_milestone, ratio, cur_price, cur_mcap)
328
+ key = ca_key_for_db(item.ca)
329
+ reply_to_id = lookup_origin_msg_id(key) if key else None
330
+ if item.next_milestone >= VIDEO_MIN_MULTIPLE and os.path.isfile(VIDEO_PATH):
331
+ try:
332
+ await self.client.send_file(
333
+ self.announce_chat,
334
+ VIDEO_PATH,
335
+ caption=f"{text}\n\n{BOT_MARKER}",
336
+ reply_to=reply_to_id if reply_to_id else None,
337
+ force_document=False
338
+ )
339
+ except Exception as e:
340
+ print(f"[TRACK] gagal kirim video: {e}")
341
+ else:
342
+ await self.client.send_message(
343
+ self.announce_chat,
344
+ f"{text}\n\n{BOT_MARKER}",
345
+ reply_to=reply_to_id if reply_to_id else None,
346
+ link_preview=False
347
+ )
348
+ item.next_milestone = self._next_target_after(item.next_milestone)
349
+
350
+ await asyncio.sleep(item.poll_secs)
351
+ except Exception as e:
352
+ print(f"[TRACK] loop error: {e}")
353
+ await asyncio.sleep(item.poll_secs)
354
  except Exception as e:
355
+ print(f"[TRACK] fatal loop init error for {item.ca}: {e}")
356
+
357
+ # -------- Public --------
358
+ def is_tracking(self, ca: str) -> bool:
359
+ t = self._tasks.get(ca)
360
+ return bool(t) and not t.done()
361
+
362
+ async def start(self, ca: str, *, basis: str = "mcap",
363
+ entry_price: Optional[float] = None,
364
+ entry_mcap: Optional[float] = None,
365
+ poll_secs: int = 20,
366
+ source_link: Optional[str] = None,
367
+ symbol_hint: Optional[str] = None):
368
+ ca = ca.strip()
369
+ if self.is_tracking(ca):
370
+ return
371
+ item = TrackItem(
372
+ ca=ca,
373
+ basis=basis.lower(),
374
+ entry_price=entry_price,
375
+ entry_mcap=entry_mcap,
376
+ poll_secs=poll_secs,
377
+ source_link=source_link,
378
+ symbol_hint=symbol_hint,
379
+ next_milestone=REPLY_FROM_MULTIPLE,
380
+ )
381
+ self._tasks[ca] = asyncio.create_task(self._loop(item))
382
 
383
  # =========================
384
+ # Setup: attach to existing client from botsignal
385
  # =========================
386
+ client: TelegramClient | None = None
387
+ startup_time_utc: Optional[datetime] = None
388
+ me_user_id: Optional[int] = None
389
+ tracker: PriceTracker | None = None
390
 
391
+ CUTOFF_BUFFER = timedelta(minutes=BACKFILL_BUFFER_MINUTES)
 
392
 
393
+ def _is_old_message(msg_dt: Optional[datetime]) -> bool:
394
+ if not isinstance(msg_dt, datetime):
395
+ return False
396
+ return msg_dt.replace(tzinfo=timezone.utc) < (startup_time_utc - CUTOFF_BUFFER)
397
 
398
+ def _is_bot_own_message(event) -> bool:
399
+ txt = (event.raw_text or "") if hasattr(event, "raw_text") else ""
400
+ return BOT_MARKER in (txt or "")
401
 
402
+ async def on_new_autotrack_message(event):
403
+ try:
404
+ if _is_bot_own_message(event):
405
+ return
406
+ msg = event.message
407
+ if _is_old_message(getattr(msg, "date", None)):
408
+ return
409
+ text = msg.message or (getattr(msg, "raw_text", None) or "")
410
+ ca = extract_ca(text)
411
+ if not ca:
412
+ return
413
+ source_link = None
414
+ try:
415
+ chat = await event.get_chat()
416
+ uname = getattr(chat, "username", None)
417
+ mid = getattr(msg, "id", None)
418
+ if uname and mid:
419
+ source_link = f"https://t.me/{uname}/{mid}"
420
+ except:
421
+ pass
422
+ await tracker.start(
423
+ ca=ca,
424
+ basis="mcap",
425
+ poll_secs=TRACK_POLL_SECS,
426
+ source_link=source_link,
427
+ )
428
+ except Exception as e:
429
+ print(f"[AUTOTRACK] error: {e}")
430
+
431
+ def setup_autotrack(shared_client: TelegramClient, announce_chat: str | None = None):
432
+ """
433
+ Dipanggil dari botsignal.py setelah client dibuat & (idealnya) sebelum start().
434
+ """
435
+ global client, tracker, startup_time_utc
436
+ client = shared_client
437
+ ensure_db()
438
+ ac = announce_chat or TARGET_CHAT
439
+ tracker = PriceTracker(client, announce_chat=ac)
440
+ startup_time_utc = datetime.now(timezone.utc)
441
+ # DAFTARKAN HANDLER DENGAN NAMA BERBEDA (FIX BENTROK)
442
+ client.add_event_handler(on_new_autotrack_message, events.NewMessage(chats=(TARGET_CHAT,)))
443
+ print("[AUTOTRACK] attached to shared client; listening on", ac)
444
+
445
+ # Tidak ada build_client() maupun main() di sini — kita sengaja
446
+ # agar autotrack SELALU menumpang client milik botsignal.