| """ |
| modules/command_handlers.py — Garden Angel Command Handlers v17.36 |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| v17.36 (2026-07-28, operator review) — vs v17.28: |
| |
| FEATURE — six commands for the Solana engine's new subsystems. Kept |
| separate from /doctor rather than folded into it: /doctor answers |
| "can it trade right now", these answer "why is it behaving like |
| this", and one message carrying both is one nobody reads on a phone. |
| |
| /routes per-route win rate mined from data/trade_journal.csv, and |
| which routes are currently pruned WITH the numbers that |
| pruned them. The answer to "why did SOL->BONK stop |
| appearing" is a line of arithmetic, not a shrug. |
| /whatif replay a proposed config change against recorded history |
| BEFORE redeploying. `/whatif haircut=15` reproduces the |
| v1.47 disaster (USDC->SOL->USDC: 34 signals -> 0) from |
| real rows in about a second. |
| /jito the dynamic tip auction: live γ, tip floor, landing rate, |
| bundles landed vs priced out, engine list. |
| /venues live probe of the direct-DEX fallback adapters, run FROM |
| the box that would be calling them — so "did Orca move |
| its endpoint" is one command, not a debugging session. |
| /resume clear the trading circuit breaker after an auto-pause. |
| /pumpfun pump.fun verification API status + credential probe. |
| |
| FEATURE — /doctor gained the three checks that most often explain a |
| silent bot: the trading circuit breaker's state (as a ⛔ problem when |
| open, since it is the single likeliest reason a correctly-configured |
| bot is not executing), key custody (a plaintext key is a ⚠️ warning, |
| a key/wallet mismatch is a ⛔ problem), and the effective vs |
| configured profit floor when volatility has raised it. |
| |
| v17.28 (2026-07-27, operator request — "add doctor command at telegram to |
| know what's the problem", modeled on their separate Rust bot's /doctor whose |
| one-line verdict "✅ no problems found — configuration can trade." ended a |
| whole class of why-no-trade back-and-forth) — vs v17.27: |
| |
| FEATURE — /doctor: plain-language diagnosis of the Solana leg. Splits |
| findings into ⛔ problems (things that BLOCK trading: execution |
| blockers from SolanaArbEngine._execution_blockers(), a stale scan |
| loop, an empty fee wallet, a mis-set leg-2 haircut big enough to eat |
| every edge — the exact config bug fixed 2026-07-27), ⚠️ warnings |
| (rate-limit cooling, warm-up, unreadable balance), and ℹ️ state (min |
| profit, drift buffer, learned flash fee, last signal's real outcome). |
| Read-only by construction: every check reads state that already |
| exists, plus at most ONE best-effort getBalance RPC — zero Jupiter |
| calls, so running it never spends rate-limit budget or perturbs an |
| in-flight scan. Ends with the Rust bot's exact verdict line when |
| nothing is wrong, so the operator learns to trust one sentence. |
| |
| v17.27 (2026-07-18, operator forwarded more "🤖" replies with the same shape |
| as v17.25's — fabricated numbers, invented DEX-fee comparisons, a made-up |
| Camelot/SushiSwap triangle strategy — then asked to "fix his mind ... make |
| him small agent") — vs v17.25: |
| |
| FEATURE — Solana gets the exact same small, grounded, operator-approved |
| agent the ARB /ai_idea/ai_do → /approve flow already is (v17.25 fixed |
| what /ai's free-form context KNOWS; this gives Solana an action |
| surface with the same safety shape as ARB's, not just better facts): |
| |
| /ai_idea_sol — new command, mirrors _cmd_ai_idea() exactly: calls |
| qwen_client.propose_solana_route() (schema-limited to constants. |
| SOLANA_TOKENS symbols), grounds both symbols via SolanaArbEngine. |
| verify_solana_mint() before ever showing the operator anything, |
| stores the proposal as a chain="SOL"-tagged self._pending_ai_pair. |
| |
| /ai_do sol <request> — a leading "sol" token routes to a Solana- |
| specific action set (pause/resume/set_min_profit/add_route/ |
| remove_route — no set_loan, no add_pair's 0x-address shape, see |
| qwen_client.py's matching v1.22 entry) built from the Solana leg's |
| own real numbers (routes, floor, paused, last near-miss), instead |
| of always defaulting to ARB. |
| |
| _cmd_approve()/_cmd_reject() branch on the pending pair's chain tag: |
| SOL calls SolanaArbEngine.add_route() and persists to the new |
| ai_solana_routes.json (bot.py loads/re-adds these at every startup, |
| mirroring ai_pairs.json's own _ai_approved_pairs() exactly) instead |
| of Scanner.add_pair()/ai_pairs.json. |
| |
| _apply_ai_action() branches the same way for pause/resume/ |
| set_min_profit/add_route/remove_route, targeting self._solana |
| instead of self._scanner when the action's chain tag says SOL. |
| New SolanaArbEngine.paused flag (solana_arb.py's own matching |
| v1.44 entry) gives Solana a real pause switch for the first time — |
| it never had one before this. |
| |
| None of this changes how /ai (ask()) itself answers open questions — |
| that free-form path has no schema to ground against by design, and |
| stays exactly as risky as any open Q&A always is. What's new is a |
| second, narrow, verified path standing next to it for anything that |
| should actually change what the bot watches or does. |
| |
| v17.25 (2026-07-17, operator forwarded "🤖" AI replies confidently citing a |
| "$500k Solana loan"/"$250 Solana fee"/"$1.50 floor" — numbers that exist |
| NOWHERE in this deployment's Solana config (real probe $10k, real Kamino fee |
| 0 bps, real floor $2 or $1 in mint mode); the ARB-only loan_amount/ |
| loan_fee_pct WERE in /ai's context with no chain label, and the Solana leg |
| had no numbers there at all, so the model reused ARB figures for Solana |
| claims and invented the rest) — vs v17.24: |
| |
| FIX (grounding) — _cmd_ai()'s context now includes the Solana leg's REAL |
| numbers whenever self._solana is attached: min_profit floor, probe |
| size, the real-send-is-a-tiny-fraction-of-probe caveat (the exact |
| mechanism behind every "real size too thin" outcome the operator sees |
| on the dashboard), flash fee bps, and the latest scan's best route |
| with its actual gap to floor (SolanaOpportunity.near_miss_line — the |
| same grounded line the near-miss Telegram report already uses). The |
| ARB loan/fee entries are now explicitly labeled ARBITRUM-ONLY so they |
| can't be silently reused as Solana figures again. Pairs with |
| qwen_client.py's own v1.19 (venue-facts + say-when-not-in-context |
| prompt rules, and the matching context-cap raise so this longer |
| context isn't truncated). |
| |
| v17.24 (2026-07-17, operator report — "we need to make the ai like agent |
| suggestion and fix and help the bot it's still not work", screenshot |
| showing two consecutive /ai_idea calls both replying "No usable suggestion |
| this time" with no explanation) — vs v17.23: |
| |
| FIX (root cause) — _cmd_ai_idea() accepted or rejected propose_pair()'s |
| candidate address using only a 0x…40-hex REGEX check (inside |
| qwen_client.py) — never checked whether a contract actually exists |
| there. A single LLM call either hit that regex or didn't, with no |
| retry and no visibility into which validation step failed, so a bad |
| call meant the operator had to keep manually resending /ai_idea with |
| no better odds each time and no idea why. |
| |
| NEW (grounding) — now retries up to _AI_IDEA_MAX_ATTEMPTS=3 times |
| server-side per /ai_idea, and each candidate is cross-checked against |
| the live chain via scanner.py's new verify_erc20_token() (v18.46): |
| confirms a contract actually exists at the address and reads its REAL |
| symbol()/decimals(), overriding the AI's guessed decimals and flagging |
| a symbol mismatch if the on-chain symbol doesn't match what the AI |
| claimed. Failure messages now show the actual reason (from |
| qwen_client.py's own v1.18 last_error fix, or this file's own |
| verify_erc20_token rejection reason) instead of one generic line. |
| |
| v17.23 (2026-07-17, operator report — a real /ai answer cited "flash-loan |
| fees (typically 0.09% on Arbitrum, or $450)" on a $500k loan; this |
| deployment's REAL configured rate, per the Scanner's own startup log, is |
| 0.05% ($250)) — vs v17.22: |
| |
| FIX (grounding) — _cmd_ai()'s context string never included the |
| scanner's own loan_fee_pct either, same gap loan_amount had before |
| v17.22's fix, so the model had no real fee rate to check its own |
| claim against and fell back to a generic "typical Aave-style fee" |
| figure. Added `flash-loan fee: X% (this deployment's REAL configured |
| rate...)` to the context. Pairs with qwen_client.py's own v1.17 entry, |
| which adds the matching general-purpose "prefer given real numbers |
| over memorized typical figures" prompt guardrail. |
| |
| v17.22 (2026-07-17, operator report — a real /ai answer recommended |
| lowering the profit floor to catch a "0.15-0.25% spread," a claim that |
| doesn't reconcile with the bot's actual loan size) — vs v17.21: |
| |
| FIX (grounding) — _cmd_ai()'s context string never included the |
| scanner's own loan_amount, so the model had no way to check a spread/ |
| dollar claim it made against the real trade size before suggesting a |
| floor change. Added `loan size per trade: $X` to the context. Pairs |
| with qwen_client.py's own v1.14 entry, which adds the matching prompt |
| guardrail that actually uses this number. |
| |
| v17.21 (2026-07-15) — vs v17.20: |
| |
| FIX (diagnosability) — companion to qwen_client.py v1.9. `/ai <question>` |
| replied with the same "🤖 The AI didn't answer — try again in a |
| minute." on every kind of failure (bad API key, wrong model name, |
| timeout, rate limit) with no way for the operator to tell which. |
| `_cmd_ai` now appends `self._qwen.last_error` (the new v1.9 property) |
| to that message when set, so the next occurrence names the real cause |
| instead of a dead end — same fix shape as telegram_client.py's earlier |
| 403-description fix this session. |
| |
| v17.20 (2026-07-11) — vs v17.19: |
| |
| FIX (root cause) — Mint Mode defaulted ON. __init__ set |
| self._mint_mode = True at construction, and effective_min_profit |
| (used by BOTH _run_hunt's manual /hunt path and run_autonomous_scan's |
| 24/7 loop — the only two callers of scanner.scan()) halves |
| self._min_profit whenever mint_mode is True. With min_profit=$4.35 |
| (MIN_PROFIT_USD), that's 4.35 * 0.5 = $2.175 — the exact "$2.17" |
| floor scanner.py v18.22-v18.25 traced across multiple sessions |
| without being able to find the source, since Scanner's own |
| "Constructed with min_profit=$4.35" diagnostic only ever reflects the |
| static constructor baseline, never the per-scan override actually |
| used. Confirmed via scanner.py's scan()-level override-mismatch |
| warning (v18.22): it logs whenever the caller's override differs |
| from baseline by >$0.01, and its total absence from a full log |
| window while mint_mode defaulted True was the tell that this file, |
| not scanner.py/bot.py/config.py, was the source. |
| |
| Root cause of root cause: _cmd_force_reset also re-set |
| self._mint_mode = True under a comment claiming it restored "safe |
| defaults" — so even an operator who noticed mint mode was on and |
| force-reset the bot would have it silently re-enabled. |
| |
| Fix: both defaults changed to False. The bot now starts — and |
| force-resets — with the full configured $4.35 floor unless mint |
| mode is explicitly turned on via /mint_mode. The toggle itself is |
| unchanged; this only changes what it defaults to. |
| |
| v17.19 (2026-07-10) — vs v17.18: |
| |
| REMOVE — /quote and /execute. These were manual, hand-typed on-chain |
| trade commands ported from command_handlers_5.py in the v17.16 merge. |
| They're now strictly worse than the automated path: LocalExecutor |
| (modules/local_executor.py) already does exact constant-product |
| on-chain quoting, fee-aware venue selection across every DEX |
| combination, and a live pre-flight profitability check before ever |
| sending a transaction — none of which /quote or /execute replicate. |
| /execute in particular let an operator hand-craft minIntermediateOut/ |
| minFinalOut and fire a real flash-loan trade with no automated |
| profitability gate at all beyond what the human typed. Keeping a |
| manual bypass around a safer automated path is a pure liability, not |
| a feature — removed both handlers, their dispatcher branches, their |
| HELP_TEXT lines, and _EXECUTE_SLIPPAGE_BUFFER_PCT (only ever consumed |
| by the now-removed /quote). "quote"/"execute" also dropped from |
| _ON_CHAIN_COMMANDS. /withdraw, /withdraweth, /chainstatus, /owner, |
| /sweep are unaffected — those are distinct utilities (fund recovery, |
| read-only status, wallet sweep), not trade-execution bypasses. |
| |
| FIX — /payout no longer attempts the oracle POST. self._oracle_url |
| pointed at the Cloudflare Worker's /payout route, which no longer |
| exists on the now-passive-relay Worker (confirmed dead — the sibling |
| /execute route already returns {"error":"route not found"} in |
| production). Every /payout call was burning up to 20s waiting on a |
| request that can only ever fail, then falling through to the report- |
| export path anyway. Removed the oracle-POST block entirely; /payout |
| now goes straight to the working .js report export. oracle_responded |
| is hardcoded False in the report (nothing was attempted to respond). |
| |
| v17.18 (2026-07-07) — vs v17.17: |
| |
| FIX — /payout's oracle path (_cmd_payout) was treating HTTP 200 alone |
| as a successful payout, with no check that a tx_hash was present or |
| that the Worker's response indicated an actual on-chain confirmation. |
| This is the same class of gap oracle.py's execute_trade() already |
| closed for Scanner's BUY signals (status == "confirmed" AND a |
| non-empty tx_hash — anything else is a failed execution), just |
| reopened on a different code path (/payout, not /execute) that never |
| reused that contract. Fixed: a 200 response is now only reported as |
| "Payout Successful" when tx_hash is non-empty AND status (if present) |
| is "confirmed". A 200 that fails that check now falls through to the |
| same unconfirmed/report-export path a non-200 response already took, |
| instead of claiming success on an unvalidated body. |
| |
| v17.17 (2026-07-06) — vs v17.16: |
| |
| NEW — /payoutreset [eth] [confirm] [full] command. |
| |
| Root cause: /reset and /force_reset only ever cleared in-memory |
| _stats/_recent_results/price cache. Neither one — nor any other |
| command in this file — ever called PayoutManager.reset(). That meant |
| the actual accounting ledger (gross_profit/net_profit/buy_count/ |
| scan_count, the "Payout Ledger — BSC (source of truth)" block in |
| /status and /health) had NO command capable of clearing it, and could |
| carry stale pre-oracle-gating-fix numbers indefinitely with no way to |
| zero them from Telegram. Confirmed in production: ledger showed |
| net_profit=$543.10 with buy_count=0 and scan_count=4 — numbers that |
| predate the oracle.execute_trade() confirmation gate landing in |
| scanner.py's _notify_payout(), never reset since. |
| |
| /payoutreset defaults to BSC, accepts 'eth' for the ETH ledger (if |
| configured), previews current state and requires an explicit |
| 'confirm' arg before actually resetting (destructive to accounting |
| history, though never to on-chain funds/wallet balances), and accepts |
| 'full' to also clear total_swept_wei/anomaly_strikes via |
| PayoutManager.reset()'s existing full_reset param. |
| |
| v17.16 (Architecture Merge) — vs v17.15: |
| |
| This dispatcher was kept as the core framework over the parallel |
| python-telegram-bot prototype (command_handlers_5.py) — lower overhead, |
| and everything else in this bot (scanner, price client, netdiag) is |
| already wired to the custom httpx-based TelegramClient, not to PTB's |
| Application runtime. Rewriting the whole bot onto PTB to gain 6 commands |
| wasn't a good trade. |
| |
| NEW — /chainstatus, /owner, /quote, /execute, /withdraw, /withdraweth. |
| On-chain FlashArbitrageV2 execution, ported from command_handlers_5.py. |
| The web3.py logic itself moved into its own modules/contract_manager.py |
| (framework-agnostic, no telegram imports) rather than being pasted |
| inline here — matches how scanner/price_client/payout_manager are |
| already split out. ContractManager's calls are synchronous web3 I/O; |
| every call site here wraps them in asyncio.to_thread so a slow RPC |
| node can't stall the dispatcher, the same fix v17.14 already applied |
| to /hunt for the same underlying reason (blocking call inline in an |
| async handler blocks every other command from being seen). |
| |
| NEW — On-chain command allowlist (_ON_CHAIN_ALLOWED_CHAT_IDS / |
| TELEGRAM_ALLOWED_CHAT_IDS). command_handlers_5.py gated every single |
| command behind a chat_id allowlist; this dispatcher previously gated |
| none of them. Rather than either extreme, only the 6 new on-chain |
| commands are gated here — they're the ones that can move funds or |
| expose contract internals, and "any chat that can message this bot" |
| was never an acceptable authorization boundary for /execute or |
| /withdraw regardless of what else got merged first. The rest of the |
| bot's commands remain ungated, same as v17.15 — that's a separate, |
| deliberately-deferred follow-up, not an oversight in this merge. |
| |
| v17.15 ADDITION (Hardening Phase 1 — Visibility) — vs v17.14: |
| |
| NEW — /netdiag command. modules/netdiag.py was already wired into |
| bot.py's startup path, but only fires automatically when a startup |
| check fails — by design, so a healthy boot isn't slowed down by a |
| ~5-host IPv4/IPv6 probe. That left no way to check network health |
| on demand once the bot is already running ("flying blind" between |
| failures). /netdiag calls the same diagnose_network() function and |
| renders its per-host IPv4/IPv6 verdict as a Telegram message instead |
| of (only) a log line, so you can pull a live network read any time |
| from your phone without needing log/SSH access in the moment. |
| Degrades cleanly (clear message, no crash) if modules/netdiag.py |
| isn't deployed in a given environment. |
| |
| v17.14 FIXES (vs v17.13) — Incident Report §3.3: |
| |
| FIX — /hunt no longer runs inline inside the dispatcher. |
| Previously `handle()` awaited the entire scan (oracle/OKX/CoinGecko/ |
| Binance round-trips + the scanner itself) before returning. If |
| bot.py's update loop awaits handle() per-update (the usual pattern), |
| a single /hunt could block that loop from seeing the next Telegram |
| update for the full scan duration — read by users as "the bot drops |
| commands / looks unresponsive." |
| |
| Now /hunt: |
| 1. Returns immediately after an ack message if a scan can be |
| started (or a "scan already running" message if not). |
| 2. Runs the actual scan as a tracked background asyncio.Task |
| (self._spawn), so handle() — and whatever loop calls it — is |
| free to keep processing updates while the scan runs. |
| 3. Is guarded by self.scan_lock, a public asyncio.Lock. Wire |
| bot.py's scheduled auto-scan loop to `async with handlers. |
| scan_lock:` around its own scanner.scan() call so a manual |
| /hunt and the automatic loop can never run concurrently and |
| double up on oracle/OKX/CoinGecko/Binance request volume. |
| 4. On shutdown, bot.py should `await handlers.aclose()` to let |
| any in-flight background scan finish instead of being dropped. |
| |
| NOTE (2026-06-30 reconstruction): lines 198-518 of this file were |
| lost to a "< truncated lines 198-518 >" placeholder literally being |
| saved into the file by a previous paste — this restores the |
| dispatcher (`handle()`) and the command handlers it calls into. |
| _cmd_hunt() and _cmd_price() probe for the most likely Scanner / |
| PriceClient method names defensively (getattr with fallbacks) since |
| those two modules weren't available to verify exact signatures — |
| if a method name is wrong, the bot still starts and every other |
| command still works; only that one command replies with a clear |
| error instead of crashing. |
| |
| UNCHANGED from v17.13: |
| /price, /ping, /audit, /arbitrage, /flashloan, /ghost_mode, |
| /mint_mode, /debug, /circuit, /reset, /force_reset, /health, |
| /payout, SPINNER FIX, rate-limiting, ring buffer, |
| BUTTON_TO_COMMAND mapping. |
| |
| v17.13 FIXES (kept): |
| FIX 1 — /ghost_mode real implementation (toggles self._ghost_mode). |
| FIX 2 — /mint_mode real implementation (toggles self._mint_mode). |
| FIX 3 — /payout calls oracle first, falls back to report export. |
| FIX 4 — /status shows Ghost and Mint mode state. |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import hmac |
| import json |
| import logging |
| import os |
| import time |
| from datetime import datetime, timezone |
| from typing import Any, Optional, TYPE_CHECKING |
|
|
| from settings import _env_bool |
| from .telegram_client import TelegramClient, TelegramUpdate |
| from .price_client import PriceClient |
| from .scanner import Scanner |
| from .contract_manager import get_contract_manager, ContractLogicError |
|
|
| if TYPE_CHECKING: |
| from .payout_manager import PayoutManager |
|
|
| |
| |
| |
| |
| try: |
| from .netdiag import diagnose_network |
| except ImportError: |
| diagnose_network = None |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _BOT_START_TIME = time.time() |
|
|
|
|
| def _arb_scan_enabled() -> bool: |
| """ARB_SCAN_ENABLED=false retires the EVM (Arbitrum) leg from every |
| manual scan surface — /hunt here and the dashboard button (app.py's |
| do_hunt), plus bot.py's autonomous loop. Operator decision 2026-07-21 |
| ("remove arb if not have hope"): at the effective loan sizes the |
| liquidity floor allows (~$300-400), ARB's flash fee + gas exceed any |
| realistically available spread, so every scan is structural HOLD |
| noise. Mirrors SOLANA_SCAN_ENABLED's parsing (default ON; explicit |
| 0/false/no/off disables). Read per call, not cached, so a Space |
| variable flip takes effect on restart without a code change. |
| |
| v18.0 — ARB_TRADING_ENABLED (config.py's arb_trading_enabled, default |
| FALSE) is also checked here: either flag being off disables ARB. This |
| file has no cfg object threaded in (env-only, same as |
| SOLANA_SCAN_ENABLED above), so it's read directly, same pattern as |
| ARB_SCAN_ENABLED. bot.py's self._active_chains already empties |
| Scanner's own ARB pairs list when arb_trading_enabled is false — this |
| is the matching gate for the manual/dashboard reporting surfaces this |
| function controls, so their messaging doesn't claim ARB is scanning |
| when Scanner actually has nothing to scan. |
| """ |
| arb_trading_enabled = os.environ.get("ARB_TRADING_ENABLED", "false").strip().lower() in ( |
| "1", "true", "yes", "on", |
| ) |
| if not arb_trading_enabled: |
| return False |
| return os.environ.get("ARB_SCAN_ENABLED", "true").strip().lower() not in ( |
| "0", "false", "no", "off", |
| ) |
|
|
| _stats: dict[str, Any] = { |
| "scans_total": 0, |
| "scans_buy": 0, |
| "commands_total": 0, |
| "errors_total": 0, |
| "last_scan_ts": None, |
| "last_signal": "N/A", |
| } |
|
|
| _RECENT_CAP = 20 |
| _recent_results: list[dict[str, Any]] = [] |
|
|
| _PAYOUT_MAX_ATTEMPTS = 5 |
| _PAYOUT_WINDOW_SECS = 600.0 |
| _payout_attempts: dict[int, list[float]] = {} |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def _load_allowed_chat_ids() -> frozenset[int]: |
| raw = os.environ.get("TELEGRAM_ALLOWED_CHAT_IDS", "") |
| ids: set[int] = set() |
| for chunk in raw.split(","): |
| chunk = chunk.strip() |
| if not chunk: |
| continue |
| try: |
| ids.add(int(chunk)) |
| except ValueError: |
| logger.warning("Ignoring non-integer entry in TELEGRAM_ALLOWED_CHAT_IDS: %r", chunk) |
| if not ids: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| has_any_chat = any( |
| os.environ.get(name, "").strip() |
| for name in ("TELEGRAM_OWNER_CHAT_IDS", "TELEGRAM_CHAT_ID") |
| ) |
| if has_any_chat: |
| logger.info( |
| "[Auth] TELEGRAM_ALLOWED_CHAT_IDS is unset — /chainstatus " |
| "and /owner (Arbitrum-era, on-chain) will refuse every chat. " |
| "Every other command is authorised normally through " |
| "TELEGRAM_CHAT_ID. Set it only if you want those two back." |
| ) |
| else: |
| logger.warning( |
| "TELEGRAM_ALLOWED_CHAT_IDS is empty — /chainstatus and /owner " |
| "will reject every chat until it's set." |
| ) |
| return frozenset(ids) |
|
|
|
|
| _ON_CHAIN_ALLOWED_CHAT_IDS = _load_allowed_chat_ids() |
| |
| |
| _ON_CHAIN_COMMANDS = frozenset({"chainstatus", "owner"}) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def _load_owner_chat_ids() -> frozenset[int]: |
| ids: set[int] = set() |
| for source in ("TELEGRAM_OWNER_CHAT_IDS", "TELEGRAM_ALLOWED_CHAT_IDS", "TELEGRAM_CHAT_ID"): |
| raw = os.environ.get(source, "") |
| for chunk in raw.split(","): |
| chunk = chunk.strip() |
| if not chunk: |
| continue |
| try: |
| ids.add(int(chunk)) |
| except ValueError: |
| logger.warning("Ignoring non-integer entry in %s: %r", source, chunk) |
| if ids: |
| logger.info( |
| "[Auth] command gate active — %d chat(s) authorised, from %s. " |
| "Every other chat is refused.", len(ids), source, |
| ) |
| return frozenset(ids) |
| return frozenset() |
|
|
|
|
| _OWNER_CHAT_IDS = _load_owner_chat_ids() |
| _OWNER_GATE_OPEN = os.environ.get("TELEGRAM_OWNER_OPEN", "").strip().lower() in ( |
| "1", "true", "yes", "on", |
| ) |
|
|
| |
| |
| |
| |
| _PUBLIC_COMMANDS = frozenset({"start", "help"}) |
|
|
| if _OWNER_GATE_OPEN: |
| logger.error( |
| "[Auth] TELEGRAM_OWNER_OPEN=true — ANY Telegram user who finds this " |
| "bot can run /doctor, /routes, /hunt, /ghost_mode and /mint_mode. " |
| "Unset it unless you genuinely mean the bot to be public." |
| ) |
| elif not _OWNER_CHAT_IDS: |
| logger.error( |
| "[Auth] no TELEGRAM_OWNER_CHAT_IDS, TELEGRAM_ALLOWED_CHAT_IDS or " |
| "TELEGRAM_CHAT_ID is set — the command gate has nobody to authorise " |
| "and will refuse EVERY chat, including yours. Set TELEGRAM_CHAT_ID." |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| _EXPLORER_BASE: dict[str, str] = { |
| "ARB": "https://arbiscan.io/tx/", |
| "ARBITRUM": "https://arbiscan.io/tx/", |
| "ETH": "https://etherscan.io/tx/", |
| "ETHEREUM": "https://etherscan.io/tx/", |
| "BSC": "https://bscscan.com/tx/", |
| "BNB": "https://bscscan.com/tx/", |
| } |
|
|
|
|
| def explorer_link(chain: str, tx_hash: str) -> str: |
| """Markdown-link a TxHash to the right block explorer for the chain.""" |
| if not tx_hash: |
| return "N/A" |
| |
| base = _EXPLORER_BASE.get((chain or "ARB").upper(), _EXPLORER_BASE["ARB"]) |
| short = f"{tx_hash[:10]}…{tx_hash[-6:]}" if len(tx_hash) > 18 else tx_hash |
| return f"[{short}]({base}{tx_hash})" |
|
|
| |
| |
| |
| |
| |
| |
| BUTTON_TO_COMMAND: dict[str, str] = { |
| "Status": "status", |
| "Hunt": "hunt", |
| "Price": "price", |
| "Arbitrage": "arbitrage", |
| "Audit": "audit", |
| "Flashloan": "flashloan", |
| "Gas": "gas", |
| "Chain Status": "chainstatus", |
| "Owner": "owner", |
| "Health Check": "health", |
| "Debug": "debug", |
| "Circuit": "circuit", |
| "Net Diag": "netdiag", |
| "Speed Test": "speedtest", |
| "Ghost Mode": "ghost_mode", |
| "Mint Mode": "mint_mode", |
| "Ping": "ping", |
| "Reset": "reset", |
| "Force Clear": "force_reset", |
| "AI Chat": "ai", |
| "AI Idea": "ai_idea", |
| "AI Idea Sol": "ai_idea_sol", |
| "Approve": "approve", |
| "Reject": "reject", |
| } |
|
|
| HELP_TEXT = ( |
| "🪬🧿 *Garden Angel v17.28 — Commands*\n\n" |
| "_Every command below has a tap button on the Command Center menu " |
| "(send /start or /help to see it) — the only one that needs you to " |
| "type something is `/ai_do`, since it takes a real request as its " |
| "argument. `/ai` works both ways: tap it for the learned model, or " |
| "type a question after it._\n\n" |
| "*AI Copilot*\n" |
| "`/ai` — what the bot has LEARNED: clearing rate by hour of day, " |
| "and its landing record. All measured, nothing generated\n" |
| "`/ai <question>` — talk to the AI about the bot/market\n" |
| "`/ai_idea` — AI proposes ONE new Arbitrum pair to watch\n" |
| "`/ai_idea_sol` — AI proposes ONE new Solana route to watch " |
| "(base->mid, verified on-chain before you ever see it)\n" |
| "`/ai_do <request>` — AI turns a plain-language request into ONE " |
| "control action (set loan/min-profit, pause/resume, add/remove pair)\n" |
| "`/ai_do sol <request>` — same, for the Solana leg (min-profit, " |
| "pause/resume, add/remove route — no loan-size knob, Solana has none)\n" |
| "`/approve` / `/reject` — apply (or discard) the AI's pending " |
| "idea OR action — nothing changes until you approve\n\n" |
| "*Market*\n" |
| "`/hunt` — On-demand arbitrage scan\n" |
| "`/price` — Live prices (WETH, BNB, BTC)\n" |
| "`/arbitrage` — Last scan snapshot\n" |
| "`/flashloan` — Cost-model assumptions\n" |
| "`/gas` — Last scan's gas estimate\n\n" |
| "*On-Chain* _(allowlisted chats only; read-only)_\n" |
| "`/chainstatus` — Contract owner, Aave pool, ETH balance\n" |
| "`/owner` — Contract owner address\n" |
| "_Payout, sweep and withdraw moved to the Hugging Face dashboard's " |
| "Payout tab (operator request, 2026-07-12)._\n\n" |
| "*Modes*\n" |
| "`/ghost_mode` — Toggle ghost mode (scan but don't execute)\n" |
| "`/mint_mode` — Toggle aggressive threshold mode\n\n" |
| "*Start here* _(v17.43)_\n" |
| "`/why` — why nothing traded, ranked, ending in what to fix first. " |
| "This is the one to read when something looks wrong.\n" |
| "`/tune` — what to change, what it would have done (replayed against " |
| "your own history), one tap to apply. `/tune undo` reverts.\n" |
| "`/pipeline` — where the time between signal and wire goes, stage by " |
| "stage\n" |
| "`/gates` — which of OUR OWN rules stopped a trade, and what they " |
| "were holding\n\n" |
| "*Solana engine* _(v17.36)_\n" |
| "`/routes` — per-route win rate; which pairs are pruned and why\n" |
| "`/whatif floor=1.50` — replay a config change against real history\n" |
| "`/jito` — dynamic tip auction: γ, tip floor, landing rate\n" |
| "`/venues` — probe the direct Orca/Raydium/Meteora fallback\n" |
| "`/resume` — clear the trading circuit breaker after a pause\n" |
| "`/pumpfun` — pump.fun verification API status\n" |
| "`/decay` — how much edge dies between detection and send\n" |
| "`/nearmiss` — why signals never became trades (lost vs blocked)\n" |
| "`/drawdown` — drawdown, loss streak, and the alert thresholds\n" |
| "`/capture` — of the signals found, what share became trades\n\n" |
| "*System*\n" |
| "`/doctor` — plain-language diagnosis: can it trade, and if not, why\n" |
| "`/status` `/health` `/debug` `/circuit`\n" |
| "`/netdiag` — IPv4/IPv6 network diagnostic\n" |
| "`/speedtest` — Solana RPC/WS + Jupiter latency diagnostic\n" |
| "`/audit` — Recent scan history\n" |
| "`/reset` `/force_reset` — Clear stats/cache\n" |
| "`/ping` — Latency check\n" |
| "`/testreport` — Send a test message to the report channel\n" |
| "`/testlog` — Send a test message to the payout/log channel\n\n" |
| "_Tap a button below or type a command._" |
| ) |
|
|
|
|
| class CommandHandlers: |
|
|
| def __init__( |
| self, |
| telegram: TelegramClient, |
| scanner: Scanner, |
| price_client: PriceClient, |
| payout_manager: Optional["PayoutManager"] = None, |
| payout_manager_eth: Optional["PayoutManager"] = None, |
| dry_run: bool = False, |
| min_profit: float = 100.0, |
| scan_timeout: float = 20.0, |
| oracle_url: str = "", |
| qwen: Any | None = None, |
| ai_pairs_path: str = "", |
| ai_solana_routes_path: str = "", |
| report_channel: Any | None = None, |
| ) -> None: |
| self._tg = telegram |
| self._scanner = scanner |
| self._price = price_client |
| self._payout = payout_manager |
| self._report = report_channel |
| |
| |
| |
| |
| |
| self._payout_eth = payout_manager_eth |
| |
| |
| |
| |
| |
| self._solana: Any | None = None |
| self._dry_run = dry_run |
| self._min_profit = min_profit |
| self._scan_timeout = scan_timeout |
| self._oracle_url = oracle_url.rstrip("/") |
| |
| self._qwen = qwen |
| self._ai_pairs_path = ai_pairs_path |
| self._ai_solana_routes_path = ai_solana_routes_path |
| self._pending_ai_pair: dict | None = None |
| |
| |
| self._pending_ai_action: dict | None = None |
|
|
| |
| self._ghost_mode = False |
| self._mint_mode = False |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| self._scan_lock: asyncio.Lock = asyncio.Lock() |
| self._bg_tasks: set[asyncio.Task] = set() |
|
|
| |
|
|
| @property |
| def ghost_mode(self) -> bool: |
| return self._ghost_mode |
|
|
| @property |
| def mint_mode(self) -> bool: |
| return self._mint_mode |
|
|
| @property |
| def effective_min_profit(self) -> float: |
| """Mint mode halves the minimum profit gate.""" |
| return self._min_profit * (0.5 if self._mint_mode else 1.0) |
|
|
| @property |
| def scan_lock(self) -> asyncio.Lock: |
| """v17.14 — share this with bot.py's auto-scan loop (see module |
| docstring) so manual and scheduled scans never overlap.""" |
| return self._scan_lock |
|
|
| |
|
|
| def _spawn(self, coro) -> asyncio.Task: |
| """ |
| Fire-and-forget a coroutine as a tracked background task. Keeps |
| handle() fast-returning so a long-running /hunt scan never blocks |
| whatever loop is awaiting handle() (typically Telegram polling) |
| from seeing the next update. |
| """ |
| task = asyncio.create_task(coro) |
| self._bg_tasks.add(task) |
| task.add_done_callback(self._bg_tasks.discard) |
| return task |
|
|
| def attach_solana(self, solana: Any) -> None: |
| """bot.py calls this right after building the Solana leg (see that |
| module's own Solana-leg section) — CommandHandlers is constructed |
| earlier in startup, before self._solana exists there, so this is a |
| post-construction attach rather than a constructor param. Lets |
| /hunt surface Solana's own latest scan results alongside the ARB |
| card instead of Telegram-side visibility being ARB-only.""" |
| self._solana = solana |
|
|
| async def aclose(self) -> None: |
| """ |
| Call from bot.py during graceful shutdown to let any in-flight |
| background scan finish instead of being dropped mid-execution. |
| """ |
| if self._bg_tasks: |
| await asyncio.gather(*list(self._bg_tasks), return_exceptions=True) |
|
|
| |
|
|
| async def handle(self, update: TelegramUpdate) -> None: |
| |
| if update.is_callback: |
| try: |
| await self._tg.answer_callback_query(update.callback_query_id) |
| except Exception as exc: |
| logger.warning("[CommandHandlers] answer_callback_query failed: %s", exc) |
|
|
| chat_id = update.chat_id |
| command = (update.command or "").strip().lower() |
|
|
| |
| |
| if not command and update.text: |
| command = BUTTON_TO_COMMAND.get(update.text.strip(), "") |
|
|
| if not command: |
| if not update.is_callback: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| answered = False |
| if _OWNER_GATE_OPEN or chat_id in _OWNER_CHAT_IDS: |
| try: |
| from modules.bot_chat import get_chat |
|
|
| chat = get_chat() |
| chat.attach(self._solana) |
| reply = chat.reply(update.text or "") |
| if reply: |
| await self._safe_send(reply, chat_id) |
| answered = True |
| except Exception as exc: |
| logger.debug("[Chat] reply failed: %s", exc) |
| if not answered: |
| await self._safe_send( |
| "I did not understand that one. I answer plain " |
| "questions like *why no trade*, *how are you*, *can " |
| "you sign*, *how fast are you*, *any signature*, " |
| "*how much profit*.\n\nOr /help for the command list.", |
| chat_id, |
| ) |
| return |
|
|
| _stats["commands_total"] += 1 |
| args = update.args |
|
|
| |
| |
| |
| |
| |
| |
| if ( |
| not _OWNER_GATE_OPEN |
| and command not in _PUBLIC_COMMANDS |
| and chat_id not in _OWNER_CHAT_IDS |
| ): |
| _stats["commands_total"] -= 1 |
| logger.warning( |
| "[Auth] refused /%s from unauthorised chat_id=%s", command, chat_id, |
| ) |
| await self._safe_send( |
| "🔒 This bot is private.\n\n" |
| "It is bound to its operator's chat and does not answer " |
| "commands from anywhere else.", |
| chat_id, |
| ) |
| return |
|
|
| |
| |
| |
| if command in _ON_CHAIN_COMMANDS and chat_id not in _ON_CHAIN_ALLOWED_CHAT_IDS: |
| logger.warning( |
| "Unauthorized on-chain command attempt: chat_id=%s command=%s", |
| chat_id, command, |
| ) |
| await self._safe_send( |
| "Not authorized. This chat isn't on the on-chain command allowlist.", |
| chat_id, |
| ) |
| return |
|
|
| try: |
| if command == "start": |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| await self._tg.send_command_center(chat_id) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if args and (_OWNER_GATE_OPEN or chat_id in _OWNER_CHAT_IDS): |
| try: |
| from modules.bot_chat import get_chat |
|
|
| chat = get_chat() |
| chat.attach(self._solana) |
| reply = chat.reply(" ".join(args) if isinstance(args, list) |
| else str(args)) |
| if reply: |
| await self._safe_send(reply, chat_id) |
| except Exception as exc: |
| logger.debug("[Chat] /start reply failed: %s", exc) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if _OWNER_CHAT_IDS and chat_id not in _OWNER_CHAT_IDS: |
| await self._safe_send( |
| "🔒 *this chat is not authorised yet*\n\n" |
| f"Your chat id is `{chat_id}`.\n\n" |
| "The bot currently answers only the channel it reports " |
| "into, which is a different chat from this one. To open " |
| "a direct chat, run this on the box and restart:\n\n" |
| f"`./scripts/setenv.sh TELEGRAM_OWNER_CHAT_IDS={chat_id}`\n\n" |
| "_To keep the channel working too, list both ids " |
| "comma-separated._", |
| chat_id, |
| ) |
| elif command == "help": |
| await self._tg.send_command_center(chat_id) |
| await self._safe_send(HELP_TEXT, chat_id) |
| elif command == "stats": |
| await self._cmd_stats(chat_id) |
| elif command == "status": |
| await self._cmd_status(chat_id) |
| elif command == "hunt": |
| await self._cmd_hunt(chat_id) |
| elif command == "price": |
| await self._cmd_price(chat_id) |
| elif command == "arbitrage": |
| await self._cmd_arbitrage(chat_id) |
| elif command == "audit": |
| await self._cmd_audit(chat_id) |
| elif command == "flashloan": |
| await self._cmd_flashloan(chat_id) |
| elif command == "ghost_mode": |
| await self._cmd_ghost_mode(chat_id) |
| elif command == "mint_mode": |
| await self._cmd_mint_mode(chat_id) |
| elif command == "debug": |
| await self._cmd_debug(chat_id) |
| elif command == "circuit": |
| await self._cmd_circuit(chat_id) |
| elif command == "reset": |
| await self._cmd_reset(chat_id) |
| elif command == "force_reset": |
| await self._cmd_force_reset(chat_id) |
| elif command == "health": |
| await self._cmd_health(chat_id) |
| elif command == "doctor": |
| await self._cmd_doctor(chat_id) |
| elif command == "netdiag": |
| await self._cmd_netdiag(chat_id) |
| elif command == "speedtest": |
| await self._cmd_speedtest(chat_id) |
| elif command == "journal": |
| await self._cmd_journal(chat_id) |
| elif command == "ping": |
| await self._cmd_ping(chat_id) |
| elif command == "gas": |
| await self._cmd_gas(chat_id) |
| elif command == "testreport": |
| await self._cmd_testreport(chat_id) |
| elif command == "testlog": |
| await self._cmd_testlog(chat_id) |
| elif command == "chainstatus": |
| await self._cmd_chainstatus(chat_id) |
| elif command == "owner": |
| await self._cmd_owner(chat_id) |
| |
| |
| |
| elif command in ("payout", "payoutreset", "sweep", |
| "withdraw", "withdraweth"): |
| await self._safe_send( |
| "💰 Payout, sweep and withdraw were removed from " |
| "Telegram (operator request). Use the Hugging Face " |
| "dashboard → Payout tab — funds can only be moved " |
| "from there now.", |
| chat_id, |
| ) |
| |
| elif command == "ai": |
| await self._cmd_ai(chat_id, args) |
| elif command == "ai_idea": |
| await self._cmd_ai_idea(chat_id) |
| elif command == "ai_idea_sol": |
| await self._cmd_ai_idea_sol(chat_id) |
| elif command == "ai_do": |
| await self._cmd_ai_do(chat_id, args) |
| elif command == "approve": |
| await self._cmd_approve(chat_id) |
| elif command == "reject": |
| await self._cmd_reject(chat_id) |
| |
| |
| |
| |
| elif command == "routes": |
| await self._cmd_routes(chat_id) |
| elif command == "whatif": |
| await self._cmd_whatif(chat_id, args) |
| elif command == "jito": |
| await self._cmd_jito(chat_id) |
| elif command == "venues": |
| await self._cmd_venues(chat_id) |
| elif command == "resume": |
| await self._cmd_resume(chat_id) |
| elif command == "pumpfun": |
| await self._cmd_pumpfun(chat_id) |
| elif command == "decay": |
| await self._cmd_decay(chat_id) |
| elif command in ("nearmiss", "misses"): |
| await self._cmd_nearmiss(chat_id) |
| elif command in ("drawdown", "dd"): |
| await self._cmd_drawdown(chat_id) |
| elif command in ("capture", "catch"): |
| await self._cmd_capture(chat_id) |
| |
| |
| |
| |
| elif command == "why": |
| await self._cmd_why(chat_id) |
| elif command == "tune": |
| await self._cmd_tune(chat_id, args) |
| elif command in ("pipeline", "stages"): |
| await self._cmd_pipeline(chat_id) |
| elif command == "gates": |
| await self._cmd_gates(chat_id) |
| elif command in ("decisions", "why_not"): |
| await self._cmd_decisions(chat_id) |
| elif command == "bench": |
| await self._cmd_bench(chat_id) |
| elif command in ("lighthouse", "light", "stand"): |
| await self._cmd_lighthouse(chat_id) |
| elif command in ("observe", "observatory"): |
| await self._cmd_observe(chat_id) |
| elif command in ("tips", "tipmemory"): |
| await self._cmd_tips(chat_id) |
| elif command in ("sendpath", "speed", "400"): |
| await self._cmd_sendpath(chat_id) |
| elif command.startswith("tune_apply_"): |
| await self._cmd_tune_apply(chat_id, command[len("tune_apply_"):]) |
| |
| |
| elif command.startswith("pc_ok_"): |
| await self._cmd_pending_decide( |
| chat_id, command[len("pc_ok_"):], approve=True) |
| elif command.startswith("pc_no_"): |
| await self._cmd_pending_decide( |
| chat_id, command[len("pc_no_"):], approve=False) |
| elif command == "heal": |
| await self._cmd_heal(chat_id) |
| elif command == "verify": |
| await self._cmd_verify(chat_id) |
| elif command == "quote": |
| await self._cmd_quote(chat_id, args) |
| elif command == "next": |
| await self._cmd_next(chat_id) |
| elif command == "agent": |
| await self._cmd_agent(chat_id) |
| elif command in ("sig", "signatures"): |
| await self._cmd_sig(chat_id) |
| else: |
| await self._safe_send( |
| f"Unknown command: /{command}\nSend /help for the full list.", |
| chat_id, |
| ) |
| except Exception as exc: |
| _stats["errors_total"] += 1 |
| logger.error("[CommandHandlers] /%s failed: %s", command, exc, exc_info=True) |
| await self._safe_send(f"⚠️ /{command} failed: {str(exc)[:300]}", chat_id) |
|
|
| |
|
|
| async def _cmd_status(self, chat_id: int) -> None: |
| last = _recent_results[-1] if _recent_results else None |
| last_signal = (last.get("signal") if last else None) or _stats["last_signal"] |
| last_ts = _stats["last_scan_ts"] |
| last_ts_str = ( |
| datetime.fromtimestamp(last_ts, tz=timezone.utc).strftime("%H:%M:%S UTC") |
| if last_ts else "never" |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| sol = self._solana |
| lines = ["📟 *status*\n"] |
|
|
| dry = bool(getattr(getattr(self, "_bot", None), "config", None) |
| and getattr(self._bot.config, "dry_run", False)) |
| lines.append(f"mode: {'DRY RUN' if dry else 'LIVE'}") |
| lines.append(f"uptime: {int(time.time() - _BOT_START_TIME) // 3600}h " |
| f"{(int(time.time() - _BOT_START_TIME) % 3600) // 60}m") |
|
|
| if sol is not None: |
| age = (f"{time.time() - sol.last_scan_ts:.0f}s ago" |
| if getattr(sol, "last_scan_ts", 0) else "never") |
| lines.append(f"cycles: {sol.scan_count:,} · last {age}") |
| lines.append(f"signals this run: {sol.signal_count:,}") |
| lines.append(f"routes: {len(sol.routes)} configured") |
| lines.append(f"floor: ${sol.min_profit:.2f} net after fees") |
| paused = bool(getattr(sol, "paused", False)) |
| lines.append(f"scanning: {'PAUSED' if paused else 'active'}") |
|
|
| |
| |
| try: |
| from modules.capture_report import capture |
|
|
| c = await asyncio.to_thread(capture) |
| rate = c["capture_rate"] |
| lines.append( |
| f"capture: {'—' if rate is None else f'{rate:.1%}'} " |
| f"({c['landed']}/{c['signalled']} signals)" |
| ) |
| lines.append(f"seen ${c['gross_seen_usd']:,.2f} · " |
| f"taken ${c['net_taken_usd']:,.2f}") |
| except Exception as exc: |
| logger.debug("[/status] capture unavailable: %s", exc) |
|
|
| try: |
| from modules.trading_guard import get_guard |
|
|
| gst = get_guard().status() |
| if gst.get("state") != "closed": |
| lines.append(f"⛔ guard: {str(gst.get('state')).upper()} — " |
| f"{(gst.get('trip_reason') or '')[:90]}") |
| except Exception as exc: |
| logger.debug("[/status] guard unavailable: %s", exc) |
|
|
| lines.append(f"telegram: {'reachable' if self._tg.reachable else 'UNREACHABLE'}") |
| if _stats["errors_total"]: |
| lines.append(f"errors this session: {_stats['errors_total']}") |
| lines.append(f"\n_last: {last_ts_str} — {last_signal}_") |
| await self._tg.send_message("\n".join(lines), chat_id=chat_id, parse_mode="Markdown") |
|
|
| |
|
|
| async def _cmd_hunt(self, chat_id: int) -> None: |
| if not _arb_scan_enabled(): |
| |
| |
| |
| await self._safe_send( |
| "🟣 ARB leg is retired (ARB_SCAN_ENABLED=false) — Solana-only mode.", |
| chat_id, |
| ) |
| if self._solana is not None: |
| await self._send_solana_hunt_summary(chat_id) |
| else: |
| await self._safe_send( |
| "⏳ Solana leg not running either — nothing to scan.", chat_id, |
| ) |
| return |
| if self._scan_lock.locked(): |
| await self._safe_send( |
| "⏳ A scan is already running (manual or scheduled) — try again shortly.", |
| chat_id, |
| ) |
| return |
|
|
| await self._safe_send("🔍 Hunt started — scanning…", chat_id) |
| self._spawn(self._run_hunt(chat_id)) |
|
|
| async def _run_hunt(self, chat_id: int) -> None: |
| """Background task spawned by /hunt — see module docstring §v17.14.""" |
| async with self._scan_lock: |
| try: |
| try: |
| scan_coro = self._scanner.scan(min_profit=self.effective_min_profit) |
| except TypeError: |
| |
| |
| scan_coro = self._scanner.scan() |
| result = await asyncio.wait_for(scan_coro, timeout=self._scan_timeout) |
| except asyncio.TimeoutError: |
| _stats["errors_total"] += 1 |
| await self._safe_send( |
| f"⏱️ Scan timed out after {self._scan_timeout:.0f}s.", chat_id, |
| ) |
| return |
| except Exception as exc: |
| _stats["errors_total"] += 1 |
| logger.error("[CommandHandlers] Hunt scan failed: %s", exc, exc_info=True) |
| await self._safe_send(f"⚠️ Scan failed: {str(exc)[:300]}", chat_id) |
| return |
|
|
| |
| |
| |
| |
| |
| result_dict = self._record_scan_result(result) |
|
|
| send_result = result_dict |
| if self._ghost_mode: |
| send_result = dict(result_dict) |
| send_result["reason"] = ( |
| (send_result.get("reason") or "") + " [ghost mode — not executed]" |
| ).strip() |
|
|
| await self._tg.send_opportunity(send_result, chat_id=chat_id) |
|
|
| |
| |
| |
| |
| |
| |
| |
| if self._solana is not None: |
| await self._send_solana_hunt_summary(chat_id) |
|
|
| async def _send_solana_hunt_summary(self, chat_id: int) -> None: |
| """Solana section for /hunt — see _run_hunt's own comment for why |
| this reads existing state instead of scanning. |
| |
| v17.37 (2026-07-29, operator wrote out the layout they wanted) — |
| was three lines of `· hold SOL USDC->SOL->USDC: loan X -> Y (net Z, |
| N bps)` and nothing else. Every number in it was true, and none of |
| it answered "is it working, and if nothing is trading, what is |
| stopping it?" — no posture, no momentum, no reason code, no view |
| of the scanner's own budget, and only the top 3 of up to 14 pairs |
| so a pruned route was indistinguishable from a broken one. |
| |
| Rendering lives in modules/hunt_view.py (see its docstring for what |
| each part of the layout is for) so the dashboard can print the |
| identical report without a second copy of the format strings. |
| Falls back to the old summary if anything at all goes wrong — a |
| formatting bug must never cost the operator their /hunt. |
| """ |
| try: |
| from modules.hunt_view import render |
|
|
| await self._safe_send(render(self._solana.hunt_snapshot()), chat_id) |
| return |
| except Exception as exc: |
| logger.warning( |
| "[CommandHandlers] rich /hunt view failed (%s) — falling back " |
| "to the plain summary", exc, exc_info=True, |
| ) |
| try: |
| opps = self._solana.last_opportunities |
| last_ts = self._solana.last_scan_ts |
| age = time.time() - last_ts if last_ts else None |
| age_str = f"{age:.0f}s ago" if age is not None else "not scanned yet" |
| if not opps: |
| text = f"🟣 Solana — no opportunities from the last cycle ({age_str})." |
| else: |
| top = sorted(opps, key=lambda o: o.net_profit_usd, reverse=True)[:3] |
| lines = [f"🟣 Solana (Jupiter round-trips) — last scan {age_str}:"] |
| lines.extend(o.summary() for o in top) |
| text = "\n".join(lines) |
| await self._safe_send(text, chat_id) |
| except Exception as exc: |
| logger.debug("[CommandHandlers] Solana hunt summary failed: %s", exc) |
|
|
| |
|
|
| def _record_scan_result(self, result) -> dict: |
| """Shared post-scan bookkeeping for BOTH /hunt (_run_hunt) and the |
| bot's own autonomous hunt loop (run_autonomous_scan). Updates the |
| module stats and the recent-results ring buffer that feed /status, |
| /audit, /arbitrage and the Gradio dashboard, then returns the |
| normalized dict. |
| |
| FIX (Incident Report §1.A): `result` is a ScanResult dataclass |
| returned by Scanner.scan(), not a dict — result.get("signal") raised |
| AttributeError: 'ScanResult' object has no attribute 'get', crashing |
| the pipeline right after a profitable opportunity was found (root |
| cause of scans_buy staying at 0). Normalize to a plain dict once, |
| here, so every downstream consumer works with dicts consistently. |
| """ |
| _stats["scans_total"] += 1 |
| _stats["last_scan_ts"] = time.time() |
|
|
| result_dict = result.to_dict() if hasattr(result, "to_dict") else dict(result) |
|
|
| signal = result_dict.get("signal", "N/A") |
| _stats["last_signal"] = signal |
| if signal == "BUY": |
| _stats["scans_buy"] += 1 |
|
|
| _recent_results.append(result_dict) |
| if len(_recent_results) > _RECENT_CAP: |
| del _recent_results[: len(_recent_results) - _RECENT_CAP] |
|
|
| return result_dict |
|
|
| async def run_autonomous_scan(self) -> Optional[str]: |
| """v17.26 — ONE cycle of the bot's own 24/7 hunt loop (driven by |
| bot.py's _autonomous_hunt_loop). This is the piece that was described |
| all over the v17.14 scan-decoupling notes but never actually built: |
| the scan_lock, effective_min_profit and every "scheduled auto-scan |
| loop" comment already existed, yet nothing on the bot side ever |
| called scan() on a timer — so the bot only scanned when a human sent |
| /hunt or clicked the dashboard button. The instant the operator |
| stopped interacting, hunting stopped (confirmed in production |
| 2026-07-10: last scan at 23:53:31, then 9.5h of nothing but Telegram |
| polling). A real 24/7 hunter needs this. |
| |
| Silent by design: it updates the same stats/ring buffer /hunt does |
| (so /status and the dashboard reflect autonomous scans) but does NOT |
| send a per-cycle Telegram message — scanner.scan() already fires the |
| signals that actually matter on its own (BUY execution + the |
| "✅ LIVE TRADE EXECUTED" alert, near-miss alerts, and the hourly AI |
| Market Pulse digest). A per-scan message here would just spam the |
| chat every interval. |
| |
| Returns the signal string ('BUY'/'HOLD'/…) on a completed cycle, or |
| None if a scan was already running or the cycle failed. NEVER raises |
| — the caller is a forever-loop that must not die on one bad cycle. |
| """ |
| if self._scan_lock.locked(): |
| |
| |
| return None |
|
|
| async with self._scan_lock: |
| try: |
| try: |
| scan_coro = self._scanner.scan(min_profit=self.effective_min_profit) |
| except TypeError: |
| |
| scan_coro = self._scanner.scan() |
| result = await asyncio.wait_for(scan_coro, timeout=self._scan_timeout) |
| except asyncio.TimeoutError: |
| _stats["errors_total"] += 1 |
| logger.warning( |
| "[CommandHandlers] Auto-hunt scan timed out after %.0fs.", |
| self._scan_timeout, |
| ) |
| return None |
| except Exception as exc: |
| _stats["errors_total"] += 1 |
| logger.error( |
| "[CommandHandlers] Auto-hunt scan failed: %s", exc, exc_info=True, |
| ) |
| return None |
|
|
| result_dict = self._record_scan_result(result) |
| return result_dict.get("signal", "N/A") |
|
|
| |
|
|
| async def _cmd_price(self, chat_id: int) -> None: |
| |
| |
| |
| |
| |
| |
| try: |
| prices = await self._price.get_prices(["WETH", "BNB", "BTC"]) |
| except Exception as exc: |
| logger.error("[CommandHandlers] /price fetch failed: %s", exc, exc_info=True) |
| await self._safe_send(f"⚠️ Price fetch failed: {str(exc)[:300]}", chat_id) |
| return |
|
|
| |
| |
| |
| |
| |
| prices_dict: dict[str, dict[str, Any]] = {} |
| for asset, entry in prices.items(): |
| if isinstance(entry, dict): |
| prices_dict[asset] = entry |
| else: |
| prices_dict[asset] = { |
| "price": getattr(entry, "price", 0.0), |
| "source": getattr(entry, "source", "?"), |
| "is_live": getattr(entry, "is_live", False), |
| "change24h": getattr(entry, "change24h", None), |
| } |
|
|
| await self._tg.send_price_report(prices_dict, chat_id=chat_id) |
|
|
| |
|
|
| async def _cmd_arbitrage(self, chat_id: int) -> None: |
| if not _recent_results: |
| await self._safe_send("No scan results yet — run /hunt first.", chat_id) |
| return |
| await self._tg.send_opportunity(_recent_results[-1], chat_id=chat_id) |
|
|
| |
|
|
| async def _cmd_audit(self, chat_id: int) -> None: |
| if not _recent_results: |
| await self._safe_send("No scan history yet — run /hunt first.", chat_id) |
| return |
| lines = ["📜 *Recent Scan History*\n"] |
| for r in _recent_results[-10:]: |
| sig = r.get("signal", "?") |
| chain = r.get("chain", "?") |
| icon = "🚀" if sig == "BUY" else "😴" |
| |
| |
| |
| |
| |
| if sig == "BUY": |
| net = float(r.get("netAfterFee", r.get("net_profit", 0.0))) |
| lines.append(f"{icon} `{chain}` net=`${net:,.2f}`") |
| else: |
| lines.append(f"{icon} `{chain}` no trade — signal=`{sig}`") |
| await self._tg.send_message("\n".join(lines), chat_id=chat_id, parse_mode="Markdown") |
|
|
| |
|
|
| async def _cmd_flashloan(self, chat_id: int) -> None: |
| text = ( |
| "⚡ *Flashloan Cost Model*\n\n" |
| "Loan fee, gas cost, and net profit are computed per-scan by the " |
| "Scanner and shown in the /hunt and /arbitrage results.\n" |
| f"Current min-profit gate: `${self.effective_min_profit:.2f}` " |
| f"({'mint-mode aggressive (halved)' if self._mint_mode else 'standard'})." |
| ) |
| await self._tg.send_message(text, chat_id=chat_id, parse_mode="Markdown") |
|
|
| |
|
|
| async def _cmd_ghost_mode(self, chat_id: int) -> None: |
| self._ghost_mode = not self._ghost_mode |
| await self._tg.send_message( |
| f"👻 Ghost Mode is now {'ON' if self._ghost_mode else 'OFF'}.", |
| chat_id=chat_id, parse_mode=None, |
| ) |
|
|
| async def _cmd_mint_mode(self, chat_id: int) -> None: |
| self._mint_mode = not self._mint_mode |
| await self._tg.send_message( |
| f"🌙 Mint Mode is now {'ON' if self._mint_mode else 'OFF'} " |
| f"(effective min profit: ${self.effective_min_profit:.2f}).", |
| chat_id=chat_id, parse_mode=None, |
| ) |
|
|
| |
|
|
| async def _cmd_ping(self, chat_id: int) -> None: |
| await self._tg.send_message( |
| f"🏓 Pong! Telegram: {'🟢' if self._tg.reachable else '🔴'}", |
| chat_id=chat_id, parse_mode=None, |
| ) |
|
|
| |
|
|
| async def _cmd_gas(self, chat_id: int) -> None: |
| if not _recent_results: |
| await self._safe_send( |
| "No scan data yet — run /hunt first to get a live gas estimate.", |
| chat_id, |
| ) |
| return |
| last = _recent_results[-1] |
| gas = float(last.get("gasCostUSD", last.get("gas_cost_usd", 0.0))) |
| chain = last.get("chain", "?") |
| await self._tg.send_message( |
| f"⛽ Last estimated gas cost on `{chain}`: `${gas:,.4f}`", |
| chat_id=chat_id, parse_mode="Markdown", |
| ) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_testreport(self, chat_id: int) -> None: |
| if self._report is None: |
| await self._safe_send( |
| "⚠️ No report channel configured — neither TELEGRAM_REPORT_CHAT_ID " |
| "nor REPORT_CHAT_ID is set. Set one to a chat/channel id the bot " |
| "is an ADMIN in, then restart the Space.", |
| chat_id, |
| ) |
| return |
| ok, detail = await self._report.send_test() |
| if ok: |
| await self._safe_send( |
| "✅ Test message sent to the report channel successfully — " |
| "go check it landed there.", |
| chat_id, |
| ) |
| else: |
| await self._safe_send(f"❌ Report channel test FAILED: {detail[:600]}", chat_id) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_testlog(self, chat_id: int) -> None: |
| if self._payout is None: |
| await self._safe_send( |
| "⚠️ No payout manager configured — nothing to test.", |
| chat_id, |
| ) |
| return |
| ok, detail = await asyncio.to_thread(self._payout.test_telegram_log) |
| if ok: |
| await self._safe_send( |
| "✅ Test message sent to the payout/log channel successfully — " |
| "go check it landed there.", |
| chat_id, |
| ) |
| else: |
| await self._safe_send(f"❌ Payout/log channel test FAILED: {detail[:600]}", chat_id) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_chainstatus(self, chat_id: int) -> None: |
| cm = get_contract_manager() |
| try: |
| owner, pool, balance = await asyncio.to_thread( |
| lambda: (cm.get_owner(), cm.get_aave_pool(), cm.get_eth_balance()) |
| ) |
| except Exception as exc: |
| await self._safe_send(f"Chain status failed: {str(exc)[:300]}", chat_id) |
| return |
| await self._tg.send_message( |
| f"Owner: `{owner}`\nAave pool: `{pool}`\nContract ETH balance: `{balance}`", |
| chat_id=chat_id, parse_mode="Markdown", |
| ) |
|
|
| async def _cmd_owner(self, chat_id: int) -> None: |
| cm = get_contract_manager() |
| owner = await asyncio.to_thread(cm.get_owner) |
| await self._tg.send_message(f"Owner: `{owner}`", chat_id=chat_id, parse_mode="Markdown") |
|
|
| async def _cmd_debug(self, chat_id: int) -> None: |
| cache_obj = ( |
| getattr(self._price, "cache", None) |
| or getattr(self._price, "_cache", None) |
| or getattr(self._price, "last_prices", None) |
| ) |
| if isinstance(cache_obj, dict) and cache_obj: |
| cache_lines = "\n".join(f" {k}: {v}" for k, v in list(cache_obj.items())[:10]) |
| elif cache_obj: |
| cache_lines = str(cache_obj)[:500] |
| else: |
| cache_lines = "(unavailable)" |
|
|
| last = _recent_results[-1] if _recent_results else None |
| text = ( |
| "Debug Snapshot\n\n" |
| f"Stats: {json.dumps(_stats)}\n\n" |
| f"Ghost mode: {self._ghost_mode} | Mint mode: {self._mint_mode}\n" |
| f"Scan lock held: {self._scan_lock.locked()} | " |
| f"Background tasks: {len(self._bg_tasks)}\n" |
| f"Effective min profit: ${self.effective_min_profit:.2f}\n\n" |
| f"Price cache:\n{cache_lines}\n\n" |
| f"Last scan: {json.dumps(last, default=str) if last else 'none yet'}\n" |
| f"Telegram reachable: {self._tg.reachable}" |
| ) |
| await self._tg.send_message(text[:4000], chat_id=chat_id, parse_mode=None) |
|
|
| async def _cmd_circuit(self, chat_id: int) -> None: |
| text = ( |
| "Circuit Status\n\n" |
| f"Telegram: {'🟢 closed (reachable)' if self._tg.reachable else '🔴 open (unreachable)'}\n" |
| f"Ghost Mode: {'👻 ON' if self._ghost_mode else 'OFF'}\n" |
| f"Mint Mode: {'🌙 ON' if self._mint_mode else 'OFF'}\n" |
| f"Errors this session: {_stats['errors_total']}" |
| ) |
| await self._tg.send_message(text, chat_id=chat_id, parse_mode=None) |
|
|
| async def _cmd_reset(self, chat_id: int) -> None: |
| _stats["scans_total"] = 0 |
| _stats["scans_buy"] = 0 |
| _stats["errors_total"] = 0 |
| _stats["last_scan_ts"] = None |
| _stats["last_signal"] = "N/A" |
| await self._tg.send_message("Scan stats reset.", chat_id=chat_id, parse_mode=None) |
|
|
| async def _cmd_force_reset(self, chat_id: int) -> None: |
| for key, val in ( |
| ("scans_total", 0), ("scans_buy", 0), ("commands_total", 0), |
| ("errors_total", 0), ("last_scan_ts", None), ("last_signal", "N/A"), |
| ): |
| _stats[key] = val |
| _recent_results.clear() |
| self._price.invalidate() |
| |
| self._ghost_mode = False |
| self._mint_mode = False |
| |
| |
| await self._tg.send_message( |
| "Force reset complete — stats, scan history, price cache cleared.\n" |
| "Ghost: OFF | Mint: OFF", |
| chat_id=chat_id, parse_mode=None, |
| ) |
|
|
| async def _cmd_health(self, chat_id: int) -> None: |
| price_feed_ok = await self._price.health_check() |
| text = ( |
| "Health Check\n\n" |
| f"Price Feed: {'🟢' if price_feed_ok else '🔴'}\n" |
| f"Telegram: {'🟢' if self._tg.reachable else '🔴'}\n" |
| f"Ghost Mode: {'👻 ON' if self._ghost_mode else 'OFF'}\n" |
| f"Mint Mode: {'🌙 ON' if self._mint_mode else 'OFF'}\n" |
| f"Uptime: {int(time.time() - _BOT_START_TIME)}s" |
| ) |
| await self._tg.send_message(text, chat_id=chat_id, parse_mode=None) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_routes(self, chat_id: int) -> None: |
| """Per-route win rate mined from the trade journal — and which |
| routes are currently pruned, with the numbers that pruned them.""" |
| from modules.route_scorer import get_scorer |
|
|
| scorer = get_scorer() |
| status = scorer.status() |
| table = scorer.table() |
|
|
| lines = ["📊 *routes* — per-route productivity\n"] |
| if not status["enabled"]: |
| lines.append( |
| "⚠️ pruning is OFF (ROUTE_SCORE_PRUNING_ENABLED=false) — every " |
| "route scans every cycle regardless of the numbers below.\n" |
| ) |
| if not table: |
| lines.append( |
| f"No journal rows scored yet.\n`{status['journal_path']}`\n\n" |
| f"Scoring needs {status['min_rows']} rows per route before it " |
| f"will form an opinion. Leave the bot running and check back." |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
| return |
|
|
| productive = [r for r in table if not r["pruned"]] |
| pruned = [r for r in table if r["pruned"]] |
|
|
| lines.append( |
| f"{status['rows_read']:,} journal rows · {status['routes_scored']} " |
| f"routes · {status['routes_pruned']} pruned\n" |
| ) |
| for row in productive[:12]: |
| mark = {"PRODUCTIVE": "🟢", "near-miss": "🟡", "cold": "⚪", "sampling": "🔵"}.get( |
| row["verdict"], "⚪", |
| ) |
| lines.append( |
| f"{mark} `{row['route']}` — {row['signals']}/{row['rows']} signals " |
| f"({row['signal_rate']:.1%}), mean ${row['mean_net_usd']:+.3f}, " |
| f"best ${row['best_net_usd']:+.2f}, {row['mean_spread_bps']:+.1f} bps" |
| ) |
| if pruned: |
| lines.append( |
| f"\n⏸ *pruned* — not scanned, re-probed every " |
| f"{status['probe_every_cycles']} cycles:" |
| ) |
| for row in pruned: |
| lines.append(f" • `{row['route']}` — {row['prune_reason']}") |
| lines.append( |
| "\n_Every pruned route is Jupiter call budget handed back to the " |
| "routes above it. A pruned route reinstates itself the moment a " |
| "re-probe produces a real signal._" |
| ) |
| if status["pinned"]: |
| lines.append(f"\n📌 pinned (never pruned): {', '.join(status['pinned'])}") |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_whatif(self, chat_id: int, args: list[str]) -> None: |
| """Replay a proposed config change against recorded history.""" |
| from modules.config_whatif import format_result, run |
|
|
| try: |
| result = run(list(args or [])) |
| except Exception as exc: |
| logger.error("[CommandHandlers] /whatif failed: %s", exc, exc_info=True) |
| await self._safe_send(f"🧪 whatif failed: {str(exc)[:250]}", chat_id) |
| return |
| await self._safe_send(format_result(result), chat_id) |
|
|
| async def _cmd_jito(self, chat_id: int) -> None: |
| """Dynamic tip auction state — the operator's own MEV report, live.""" |
| import os |
|
|
| from modules.jito_tip_engine import LAMPORTS_PER_SOL, get_tip_engine |
|
|
| status = get_tip_engine().status() |
| enabled = os.getenv("SOLANA_JITO_ENABLED", "").strip().lower() in ("1", "true", "yes", "on") |
|
|
| lines = ["⚡ *jito* — dynamic tip auction\n"] |
| if not enabled: |
| lines.append( |
| "🔴 SOLANA_JITO_ENABLED is not true — bundles are OFF and every " |
| "send goes out over ordinary RPC. Everything below is the " |
| "engine's configured state, not live behaviour.\n" |
| ) |
| lines.append( |
| f"γ (aggressiveness): `{status['gamma']:.3f}` " |
| f"{'(adaptive, ' if status['gamma_adaptive'] else '(fixed, '}" |
| f"bounds {status['gamma_bounds'][0]:.2f}–{status['gamma_bounds'][1]:.3f})" |
| ) |
| lines.append( |
| f" _γ is the share of net revenue bid to the validator. " |
| f"Tip = min(max, max(floor, γ·(R−C)))._" |
| ) |
| rate = status["landing_rate"] |
| if rate is None: |
| lines.append( |
| f"\nLanding rate: not enough samples yet " |
| f"({status['samples']} bundles) — γ holds until there are." |
| ) |
| else: |
| lines.append( |
| f"\nLanding rate: `{rate:.0%}` over {status['samples']} bundles " |
| f"(target {status['target_land_rate']:.0%})" |
| ) |
| if rate < status["target_land_rate"]: |
| lines.append(" ↗️ below target — γ is climbing (bidding harder)") |
| elif rate > status["target_land_rate"]: |
| lines.append(" ↘️ above target — γ is easing (keeping more edge)") |
| floor = status["tip_floor_lamports"] |
| if floor: |
| lines.append( |
| f"\nTip floor: `{floor:,}` lamports " |
| f"({floor / LAMPORTS_PER_SOL:.6f} SOL) " |
| f"— {status['tip_floor_source']}, {status['tip_floor_age_secs']}s old" |
| ) |
| else: |
| lines.append("\nTip floor: not fetched yet (or the feed is unreachable)") |
| lines.append( |
| f"Ceiling: `{status['tip_max_lamports']:,}` lamports · " |
| f"min keep: `{status['min_keep_lamports']:,}`" |
| ) |
| lines.append( |
| f"\nBundles: {status['bundles_landed']}/{status['bundles_submitted']} landed · " |
| f"{status['priced_out']} priced out · " |
| f"{status['tip_sol_paid']:.6f} SOL paid in tips" |
| ) |
| engines = status["block_engines"] |
| lines.append(f"\nBlock engines ({len(engines)}, submitted in parallel):") |
| for url in engines: |
| host = url.replace("https://", "").split(".")[0] |
| lines.append(f" • `{host}`") |
| lines.append( |
| "\n_Redundant submission is free — the auction dedupes identical " |
| "bundles, so N relays is one entry at one tip._" |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_venues(self, chat_id: int) -> None: |
| """Live probe of the direct-DEX fallback adapters.""" |
| import httpx |
|
|
| from modules.dex_venues import get_router |
|
|
| router = get_router() |
| status = router.status() |
|
|
| lines = ["🔀 *venues* — direct DEX quote fallback\n"] |
| if not status["enabled"]: |
| lines.append( |
| "🔴 DEX_FALLBACK_ENABLED=false — Jupiter is the only price " |
| "source. When it rate-limits, the whole Solana leg goes dark.\n" |
| ) |
| else: |
| lines.append( |
| "🟢 fallback armed — used ONLY when Jupiter is cooling or its " |
| "hourly budget is spent, never as a race against it.\n" |
| ) |
|
|
| await self._safe_send("\n".join(lines) + "\nProbing venues…", chat_id) |
| try: |
| async with httpx.AsyncClient() as client: |
| probes = await router.probe(client) |
| except Exception as exc: |
| await self._safe_send(f"⚠️ probe failed: {str(exc)[:200]}", chat_id) |
| return |
|
|
| out = ["🔀 *venues* — live probe (1 SOL → USDC)\n"] |
| for row in probes: |
| mark = "🟢" if row.get("ok") else "🔴" |
| state = "on" if row["enabled"] else "off" |
| out.append( |
| f"{mark} `{row['venue']}` ({state}, {row.get('latency_ms', '?')}ms)\n" |
| f" {row.get('detail', '')[:200]}" |
| ) |
| |
| |
| |
| |
| |
| |
| |
| try: |
| jup = await self._probe_jupiter_venues() |
| except Exception as exc: |
| jup = [] |
| logger.debug("[Venues] jupiter dexes probe failed: %s", exc) |
| if jup: |
| out.append("\n*Via Jupiter `dexes=` — the path actually used:*") |
| for name, ok, detail in jup: |
| out.append(f"{'🟢' if ok else '🔴'} `{name}` {detail}") |
|
|
| failing = [r["venue"] for r in probes if not r.get("ok")] |
| if failing: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| out.append( |
| f"\n⚠️ _{', '.join(failing)}: enabled but not answering._" |
| ) |
| out.append( |
| "\n*The bot is not blind to them* — see the Jupiter block " |
| "above. `ORCA_API_BASE` / `METEORA_API_BASE` still override " |
| "the host if either ever publishes one again." |
| ) |
| passing = [r["venue"] for r in probes if r.get("ok") and not r["enabled"]] |
| if passing: |
| out.append( |
| f"\n✅ probing clean but disabled: {', '.join(passing)} — enable " |
| f"with `DEX_VENUE_{passing[0].upper()}=true`" |
| ) |
| await self._safe_send("\n".join(out), chat_id) |
|
|
| async def _cmd_resume(self, chat_id: int) -> None: |
| """Clear the trading circuit breaker.""" |
| from modules.trading_guard import get_guard |
|
|
| guard = get_guard() |
| before = guard.status() |
| if before["state"] == "closed": |
| await self._safe_send( |
| "✅ trading circuit breaker is already CLOSED — nothing to " |
| "resume. Trading is allowed; if nothing is executing, " |
| "run /doctor.", |
| chat_id, |
| ) |
| return |
| detail = guard.reset(why=f"/resume from chat {chat_id}") |
| logger.warning("[CommandHandlers] /resume — %s", detail) |
| await self._safe_send( |
| f"▶️ *Trading resumed.*\n\n{detail}\n\n" |
| f"It tripped on: {before['trip_reason'][:300]}\n\n" |
| f"_If the underlying cause is still there it will trip again — " |
| f"the cooldown restarts from its base, not from the doubled " |
| f"value it had reached._", |
| chat_id, |
| ) |
|
|
| async def _cmd_pumpfun(self, chat_id: int) -> None: |
| """pump.fun verification API status + live credential probe.""" |
| import httpx |
|
|
| from modules.pumpfun_client import get_client, is_configured |
|
|
| client_obj = get_client() |
| status = client_obj.status() |
| lines = ["🅿️ *pump.fun* — verification API\n"] |
| if not is_configured(): |
| lines.append( |
| "🔴 `PUMPFUN_API_KEY` is not set.\n\n" |
| "Set it in .env (or Space Variables) and restart. The key is " |
| "origin-restricted, so also check `PUMPFUN_ORIGIN` matches one " |
| "of the allowed origins on the key " |
| "(elghaly.dev / arb.elghaly.dev / bot.elghaly.dev)." |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
| return |
| lines.append( |
| f"key `{status['key_fingerprint']}` · origin `{status['origin']}`\n" |
| f"{status['calls']} call(s), {status['errors']} error(s), " |
| f"{status['cached_mints']} mint(s) cached" |
| ) |
| await self._safe_send("\n".join(lines) + "\n\nProbing…", chat_id) |
| try: |
| async with httpx.AsyncClient() as http: |
| probe = await client_obj.probe(http) |
| except Exception as exc: |
| await self._safe_send(f"⚠️ probe failed: {str(exc)[:200]}", chat_id) |
| return |
| mark = "🟢" if probe.get("reachable") else "🔴" |
| await self._safe_send( |
| f"{mark} *pump.fun probe* ({probe.get('latency_ms', '?')}ms)\n\n" |
| f"{probe.get('detail', '')[:400]}", |
| chat_id, |
| ) |
|
|
| async def _cmd_decay(self, chat_id: int) -> None: |
| """How much edge dies between detection and send — measured, not |
| guessed. See modules/decay_tracker.py.""" |
| from modules.decay_tracker import get_tracker |
|
|
| st = get_tracker().status() |
| lines = ["⏱️ *decay* — edge lost between detection and send\n"] |
| if not st["recording"]: |
| lines.append("🔴 not recording (DECAY_RECORD_ENABLED=false)") |
| await self._safe_send("\n".join(lines), chat_id) |
| return |
| if st["samples"] < st["min_samples"]: |
| lines.append( |
| f"{st['samples']}/{st['min_samples']} real execution attempts " |
| f"recorded so far.\n\n" |
| f"This measures the one thing that has never been measured here: " |
| f"what the scanner saw, what the pre-send re-quote saw, and how " |
| f"long sat between them. Every 'signal fired but nothing sent' in " |
| f"this bot's history is that gap. It needs " |
| f"{st['min_samples'] - st['samples']} more attempt(s)." |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
| return |
|
|
| |
| |
| |
| |
| |
| |
| rec, earlier = st.get("recent") or {}, st.get("earlier") or {} |
| if rec.get("median_secs") is not None: |
| lines.append( |
| f"*last {rec['samples']} attempts: {rec['median_secs']:.2f}s* " |
| f"median pipeline" |
| ) |
| if earlier.get("median_secs"): |
| delta = st.get("pipeline_trend_secs") |
| arrow = "🟢 faster" if (delta or 0) < -0.5 else ( |
| "🔴 slower" if (delta or 0) > 0.5 else "flat") |
| lines.append( |
| f" earlier {earlier['samples']} attempts: " |
| f"{earlier['median_secs']:.2f}s → {arrow} " |
| f"by {abs(delta or 0):.2f}s" |
| ) |
| else: |
| lines.append(" _no earlier samples to compare against yet_") |
| lines.append("") |
|
|
| lines.append( |
| f"decay rate: *${st['decay_usd_per_sec']:.4f}/s* " |
| f"(median over {st['samples']} attempts)" |
| ) |
| lines.append( |
| f"pipeline, WHOLE window: {st['pipeline_median_secs']:.2f}s median, " |
| f"*{st['pipeline_p90_secs']:.2f}s p90*" |
| ) |
| share = st.get("grew_share") |
| if share is not None: |
| lines.append( |
| f"edge GREW on *{st['grew_count']}* of {st['samples']} attempts " |
| f"({share * 100:.0f}%)" |
| ) |
| if share >= 0.35: |
| lines.append( |
| "_Adversarial decay cannot run that close to a coin flip. " |
| "At this share the median is mostly quote-to-quote noise, " |
| "not competitors taking the edge — which means more latency " |
| "work will not move it. Widen the edge, don't shorten the " |
| "wire._" |
| ) |
| else: |
| lines.append( |
| "_Skewed toward shrinking, which is what real competition " |
| "looks like. Latency work pays here._" |
| ) |
| lines.append( |
| f"\n→ the gate charges *${st['implied_adder_usd']:.3f}* of headroom above " |
| f"the floor, from {st['gate_window']} recent attempt(s)." |
| + (" ⚠️ *CAPPED* — the raw figure is higher; read it as " |
| "\"more than this\", not as a measurement." |
| if st.get("gate_capped") else "") |
| ) |
| lines.append( |
| "_This is the p90 of dollars ACTUALLY lost between the two quotes, " |
| "at the same size. It used to be rate × pipeline-p90, which broke " |
| "once the pipeline got fast: the rate is dollars ÷ seconds, so the " |
| "same fixed quote noise reads as a bigger rate the shorter the " |
| "pipeline gets. Measuring the dollars directly cannot drift that way._" |
| ) |
| if st["routes"]: |
| lines.append("\nper route:") |
| for r in st["routes"]: |
| lines.append( |
| f" `{r['route']}` — {r['attempts']} attempt(s), " |
| f"median decay ${r['median_decay_usd']:+.3f} over " |
| f"{r['median_elapsed_ms']:,.0f}ms, {r['survived']} survived" |
| ) |
| lines.append( |
| f"\ngate: {'ON — floor raised by the measurement above' if st['gate_enabled'] else 'OFF (recording only) — set DECAY_GATE_ENABLED=true to enforce'}" |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_nearmiss(self, chat_id: int) -> None: |
| """Why signals are not becoming trades (v17.39). |
| |
| The one question a P&L cannot answer: are we losing races, or not |
| finding opportunities? Both read as $0.00. See |
| modules/near_miss.py for the taxonomy. |
| """ |
| from modules.near_miss import get_log |
|
|
| s = get_log().summary() |
| lines = ["🥀 *near misses* — signals that never became receipts\n"] |
|
|
| if not s["total"]: |
| lines.append(s["verdict"]) |
| lines.append( |
| "\n_Nothing to fix here yet. This log fills only when a signal " |
| "clears the floor and then fails to land — until then the " |
| "constraint is upstream, in finding an edge at all._" |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
| return |
|
|
| fam = s["by_family"] |
| total = s["total"] |
| lines.append( |
| f"*{total:,}* recorded (window {s['window']:,}), " |
| f"{s['oldest_ts'][:16].replace('T', ' ')} → " |
| f"{s['newest_ts'][:16].replace('T', ' ')}\n" |
| ) |
| for name, label, emoji in ( |
| ("lost", "lost — beaten or decayed", "🔴"), |
| ("blocked", "blocked — our own gating", "🟠"), |
| ("broke", "broke — malfunction", "⚪"), |
| ): |
| count = fam.get(name, 0) |
| if count: |
| lines.append(f"{emoji} {label}: *{count:,}* ({count / total:.0%})") |
|
|
| lines.append("\nby outcome:") |
| for code, count in list(s["by_outcome"].items())[:8]: |
| lines.append(f" `{code}` — {count:,}") |
|
|
| if s["value_at_risk_usd"] > 0: |
| lines.append( |
| f"\nscanner valued these at *${s['value_at_risk_usd']:,.2f}* in total " |
| f"at detection. That is not a loss — most never sent — it bounds " |
| f"the size of the prize being missed." |
| ) |
|
|
| lines.append(f"\n{s['verdict']}") |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_stats(self, chat_id: int) -> None: |
| """One screen for "how is it going" (v17.41). |
| |
| Operator, repeatedly: "the stats is the most important". The numbers |
| all existed already, spread across /routes, /nearmiss, /decay, |
| /capture and /journal — five commands on a phone to assemble one |
| answer. See modules/stats_report.py. |
| """ |
| from modules.stats_report import render_stats |
|
|
| text = await asyncio.to_thread(render_stats, self._solana) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_capture(self, chat_id: int) -> None: |
| """The funnel: signalled -> attempted -> landed, and where it leaks. |
| |
| Operator: "there's signal and there's profit why he not catch — we |
| need to know the reason ... how many % he can catch." No existing |
| command answered that: /routes counts signals, /nearmiss counts |
| failures, /decay counts seconds, and none of them divides one by |
| another. See modules/capture_report.py. |
| """ |
| from modules.capture_report import render_capture |
|
|
| text = await asyncio.to_thread(render_capture) |
| await self._safe_send(text, chat_id) |
|
|
| |
| async def _cmd_why(self, chat_id: int) -> None: |
| """One screen: why nothing traded, ranked by where in the funnel it |
| bites, ending in a numbered order to fix it in. |
| |
| Operator, repeatedly: "why no trade and what happen to the routes", |
| "i lose the track before start", "the doctor say use this order to |
| fix". /doctor answers "is anything misconfigured", which on |
| 2026-07-31 was honestly "no" while the scanner sat dark and 17 of 17 |
| attempts had landed nothing. A configuration check structurally |
| cannot notice that nothing is happening. See modules/why_report.py. |
| """ |
| from modules.why_report import report_text |
|
|
| text = await asyncio.to_thread(report_text, self._solana) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_tune(self, chat_id: int, args: Any = None) -> None: |
| """Ranked recommendations, each replayed against real history where |
| it can be, each one tap from being applied. |
| |
| `/tune undo` restores the backup scripts/setenv.sh took. |
| |
| v17.44 — `args` is a LIST of tokens (TelegramClient's |
| `update.args: list[str]`), not a string. The first version called |
| .strip() on it and every /tune crashed with "'list' object has no |
| attribute 'strip'". Accepts both shapes now, because /whatif takes |
| the list form and a future caller passing a string should not be a |
| second outage. |
| """ |
| from modules import tune_report |
|
|
| if isinstance(args, (list, tuple)): |
| text = " ".join(str(a) for a in args) |
| else: |
| text = str(args or "") |
| if text.strip().lower().startswith("all"): |
| ok, message = await asyncio.to_thread(tune_report.apply_all, self._solana) |
| await self._safe_send(message, chat_id) |
| return |
| if text.strip().lower().startswith("undo"): |
| ok, message = await asyncio.to_thread(tune_report.undo) |
| await self._safe_send(message, chat_id) |
| return |
|
|
| recs = await asyncio.to_thread(tune_report.build, self._solana) |
| text = await asyncio.to_thread(tune_report.render, recs) |
| |
| |
| |
| |
| buttons = [ |
| {"text": f"Apply {i}", "callback_data": f"tune_apply_{rec.id}"} |
| for i, rec in enumerate(recs, 1) if rec.applyable |
| ][:6] |
| markup = None |
| if buttons: |
| rows = [buttons[i:i + 3] for i in range(0, len(buttons), 3)] |
| |
| |
| |
| |
| |
| |
| if len(buttons) > 1: |
| rows.append([{"text": f"⚡ Apply ALL {len(buttons)}", |
| "callback_data": "tune_apply_ALL"}]) |
| markup = {"inline_keyboard": rows} |
| try: |
| await self._tg.send_message( |
| text, chat_id=chat_id, parse_mode=None, reply_markup=markup, |
| ) |
| except Exception as exc: |
| logger.error("[CommandHandlers] /tune send failed: %s", exc) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_tune_apply(self, chat_id: int, rec_id: str) -> None: |
| """Turn an Apply tap into a PROPOSAL, not a write. |
| |
| v1.71 — this used to write .env on a single tap, with an "Apply ALL" |
| button beside it. The operator asked for the opposite: "should not |
| follow all what he said, so the system should ask me y/n." |
| |
| So the tap now produces a card showing `was -> now` and asking for a |
| second, explicit yes. The write itself still goes through |
| setenv.sh (backup, verify, undo) exactly as before — what changed is |
| that a decision is now made with the current value visible, rather |
| than after the fact. |
| """ |
| from modules import tune_report |
| from modules.pending_change import get_store |
|
|
| if rec_id == "ALL": |
| |
| |
| |
| recs = await asyncio.to_thread(tune_report.build, self._solana) |
| applyable = [r for r in recs if r.applyable] |
| if not applyable: |
| await self._safe_send("Nothing is one-key applyable right now.", |
| chat_id) |
| return |
| await self._safe_send( |
| f"Proposing {len(applyable)} change(s) one at a time — each " |
| f"needs its own yes.", chat_id, |
| ) |
| for rec in applyable[:6]: |
| await self._propose_change( |
| chat_id, rec.key, rec.value, rec.title, "tune", |
| detail=rec.caveat or "", |
| needs_restart="restart" in (rec.caveat or "").lower(), |
| ) |
| return |
|
|
| recs = await asyncio.to_thread(tune_report.build, self._solana) |
| rec = next((r for r in recs if r.id == rec_id), None) |
| if rec is None: |
| await self._safe_send( |
| f"`{rec_id}` is no longer recommended — the state behind it " |
| f"changed. Run /tune again for the current list.", chat_id, |
| ) |
| return |
| if not rec.applyable: |
| await self._safe_send( |
| f"*{rec.title}* is not a one-key change.\n\n{rec.caveat or ''}", |
| chat_id, |
| ) |
| return |
| await self._propose_change( |
| chat_id, rec.key, rec.value, rec.title, "tune", |
| detail=rec.caveat or "", |
| needs_restart="restart" in (rec.caveat or "").lower(), |
| ) |
|
|
| |
| async def _propose_change( |
| self, chat_id: int, key: str, value: Any, title: str, source: str, |
| *, detail: str = "", needs_restart: bool = False, |
| ) -> None: |
| """Send a was -> now card with Apply / Reject. Writes nothing.""" |
| from modules.pending_change import get_store |
|
|
| change = get_store().propose( |
| key, value, title, source, detail=detail, |
| needs_restart=needs_restart, chat_id=chat_id, |
| ) |
| if str(change.was).strip() == str(value).strip(): |
| await self._safe_send( |
| f"*{key}* is already `{value}` — nothing to change.", chat_id, |
| ) |
| get_store().reject(change.token) |
| return |
| await self._tg.send_message( |
| change.card(), chat_id=chat_id, parse_mode=None, |
| reply_markup=change.keyboard(), |
| ) |
|
|
| async def _cmd_pending_decide(self, chat_id: int, token: str, |
| approve: bool) -> None: |
| """The second tap. This is the only path from a proposal to disk.""" |
| from modules.pending_change import get_store |
|
|
| store = get_store() |
| if not approve: |
| change = store.reject(token) |
| await self._safe_send( |
| f"✖️ rejected — *{change.key}* left at `{change.was or 'unset'}`" |
| if change else |
| "✖️ rejected (that proposal had already expired).", |
| chat_id, |
| ) |
| return |
|
|
| ok, message = await asyncio.to_thread(store.apply, token) |
| await self._safe_send(message, chat_id) |
| if not ok: |
| return |
|
|
| change_needs_restart = "RESTART" in message |
| if change_needs_restart: |
| |
| |
| restart = store.propose( |
| "", "", "restart the bot so that takes effect", "tune", |
| detail="The setting is written. The running process is still " |
| "using the old value until it restarts.", |
| chat_id=chat_id, action=self._restart_action(), |
| ) |
| await self._tg.send_message( |
| restart.card(), chat_id=chat_id, parse_mode=None, |
| reply_markup=restart.keyboard(), |
| ) |
|
|
| def _restart_action(self): |
| """Returns a callable that restarts the service. Approval-gated. |
| |
| Python has never restarted this service — only `gat set` did, via |
| systemctl. Doing it from Telegram is what the operator asked for |
| ("make the system restart from telegram to apply the change"), and it |
| is safe only because it cannot be reached without an explicit tap on |
| a card that says exactly what it will do. |
| """ |
| def _do() -> tuple[bool, str]: |
| import shutil |
| import subprocess |
|
|
| unit = os.getenv("GA_SYSTEMD_UNIT", "garden-angel-terminal") |
| if not shutil.which("systemctl"): |
| return False, ( |
| "systemctl is not available here, so I cannot restart the " |
| f"service. Run it yourself:\n`sudo systemctl restart {unit}`" |
| ) |
| try: |
| result = subprocess.run( |
| ["sudo", "-n", "systemctl", "restart", unit], |
| capture_output=True, text=True, timeout=30, |
| ) |
| except (OSError, subprocess.SubprocessError) as exc: |
| return False, f"restart failed: {str(exc)[:200]}" |
| if result.returncode != 0: |
| return False, ( |
| f"restart failed: {(result.stderr or result.stdout)[:250]}\n\n" |
| f"Run it yourself:\n`sudo systemctl restart {unit}`" |
| ) |
| return True, ( |
| f"♻️ restarting `{unit}` — the new value is live once it comes " |
| f"back. This message may be the last thing sent by the old " |
| f"process." |
| ) |
| return _do |
|
|
| async def _cmd_sig(self, chat_id: int) -> None: |
| """Every signature this bot has put on chain, as solscan links. |
| |
| The operator: "i need to see his signature on chain". |
| |
| There was no way to. The signature is written into the trade |
| journal's free-text `detail` column and nothing ever read it back |
| out — it appeared once, in one Telegram message, at the moment of |
| the send. If you were asleep, it was gone. |
| |
| This deliberately points at scripts/verify_signatures.py at the end. |
| A list of signatures I printed is a list of things I BELIEVE |
| happened; that script asks the chain. On a bot with 20 attempts and |
| 0 confirmed landings, the difference between those two is the entire |
| question. |
| """ |
| from modules.bot_chat import executed_signatures |
|
|
| rows = executed_signatures(10) |
| if not rows: |
| await self._safe_send( |
| "🔗 *signatures* — none yet.\n\n" |
| "No journal row is marked executed with a signature in it. " |
| "That matches /doctor: attempts recorded, none confirmed on " |
| "chain.\n\n" |
| "/gates shows what is holding them, /why ranks the cause.", |
| chat_id) |
| return |
| lines = ["🔗 *signatures on chain*", ""] |
| for sig, when, detail in rows: |
| lines.append(f"• [`{sig[:24]}…`](https://solscan.io/tx/{sig})") |
| lines.append(f" _{when} · {detail[:100]}_") |
| lines += [ |
| "", |
| "_These are what I recorded. To check what the CHAIN says — " |
| "which is the only thing that counts:_", |
| "`venv/bin/python scripts/verify_signatures.py`", |
| ] |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_agent(self, chat_id: int) -> None: |
| """Is the watcher watching, and what is it watching? |
| |
| The agent's whole value is that it stays quiet. That makes it |
| indistinguishable, from the outside, from an agent that died three |
| days ago — which is the same silence-reads-as-health failure it was |
| built to fix. So there has to be a way to ask. |
| """ |
| try: |
| from modules.agent_loop import INTERVAL_SECS, REPEAT_SECS, get_agent |
|
|
| agent = get_agent() |
| if agent is None: |
| await self._safe_send( |
| "🤖 *agent* — not started\n\nThe Solana leg has to be " |
| "running for it to have anything to watch. /doctor.", |
| chat_id) |
| return |
| st = agent.status() |
| lines = [ |
| "🤖 *agent* — the bot watching itself", "", |
| f"{'🟢 running' if st['running'] else '🔴 STOPPED'} · " |
| f"{st['ticks']} check(s) · {st['announcements']} message(s) sent", |
| f"checks every {INTERVAL_SECS / 60:.0f} min, speaks only when " |
| f"something CHANGES, re-raises an unchanged fault every " |
| f"{REPEAT_SECS / 3600:.0f}h", |
| "", |
| ] |
| if st["watching"]: |
| lines.append("*currently blocking:*") |
| lines += [f" • {h}" for h in st["watching"][:5]] |
| if st["unchanged_for_secs"]: |
| lines.append( |
| f"\n_unchanged for " |
| f"{st['unchanged_for_secs'] / 60:.0f} min — that is why " |
| f"it has gone quiet, not because it stopped looking._" |
| ) |
| else: |
| lines.append("*nothing is blocking right now.*") |
| lines += [ |
| "", "_It never writes a setting. Every fix arrives as the " |
| "same y/n card /heal produces._", |
| ] |
| await self._safe_send("\n".join(lines), chat_id) |
| except Exception as exc: |
| await self._safe_send(f"🤖 agent unavailable: {str(exc)[:150]}", chat_id) |
|
|
| def primary_chat_id(self) -> Optional[int]: |
| """Where an UNPROMPTED message should go, or None. |
| |
| Every other send in this class replies to a chat_id that arrived |
| with a command. The agent has no such chat: it speaks first. It |
| needs the operator's own chat, and it must never guess — a proposal |
| card offering to change trading settings, delivered to the wrong |
| chat, is a stranger being handed the controls. |
| |
| So: the same authorised set the command gate itself uses, and the |
| lowest id from it for stability (the set is unordered, and an agent |
| that addresses a different chat on each restart is one the operator |
| cannot follow). None when nothing is configured, which correctly |
| makes the agent report-only rather than making it improvise. |
| """ |
| if not _OWNER_CHAT_IDS: |
| return None |
| return min(_OWNER_CHAT_IDS) |
|
|
| async def _cmd_heal(self, chat_id: int) -> None: |
| """Find everything fixable and offer each fix as one tap. |
| |
| v1.76 — operator, repeatedly: "make the system fix himself by |
| running doctor tune decay", "i run doctor he have problem then fix |
| its mean the system heal him self under my accept". |
| |
| Everything needed for this already existed and was never joined. |
| /doctor finds problems and prints commands to run by hand. /tune |
| ranks changes and, until this branch, applied them on a single |
| untracked tap. /gates and /decay each hold an opinion and no way to |
| act on it. On a phone that is five screens, a lot of scrolling, and |
| a shell prompt the operator does not have. |
| |
| So: one button. It collects every ACTIONABLE finding — meaning one |
| with a concrete key and value, not a paragraph of advice — and sends |
| each as a was -> now card with Apply / Reject. |
| |
| It never applies anything itself. That is the whole point of the |
| word "under my accept": the bot is allowed to find, rank and |
| propose, and the operator is the only thing that writes. A bot that |
| edits its own configuration unattended is a bot whose behaviour |
| cannot be reasoned about afterwards, which is the argument |
| tune_report has made since it was written and this keeps. |
| """ |
| await self._safe_send("🩹 checking everything fixable…", chat_id) |
|
|
| proposals: list[tuple[int, str, str, Any, str, bool]] = [] |
| |
|
|
| |
| try: |
| from modules import tune_report |
|
|
| recs = await asyncio.to_thread(tune_report.build, self._solana) |
| for rec in recs: |
| if getattr(rec, "applyable", False) and rec.key: |
| proposals.append(( |
| int(getattr(rec, "rank", 50)), rec.key, str(rec.value), |
| None, rec.title, |
| "restart" in (getattr(rec, "caveat", "") or "").lower(), |
| )) |
| except Exception as exc: |
| logger.debug("[Heal] tune unavailable: %s", exc) |
|
|
| |
| |
| |
| |
| |
| try: |
| blockers = self._solana._execution_blockers() if self._solana else [] |
| for b in blockers: |
| if "DRY_RUN" in b or "EXECUTION_ENABLED" in b: |
| proposals.append(( |
| 99, "", "", None, |
| "execution is OFF — this one is yours to turn on, " |
| "deliberately, not from a heal button:\n" |
| "`./scripts/setenv.sh DRY_RUN=false " |
| "SOLANA_EXECUTION_ENABLED=true`", |
| False, |
| )) |
| break |
| except Exception as exc: |
| logger.debug("[Heal] blockers unreadable: %s", exc) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules import key_custody |
|
|
| st = key_custody.status() |
| if not st.get("loaded") and st.get("error"): |
| err = str(st["error"]) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| owner_only = chat_id in _OWNER_CHAT_IDS |
| if ("AccessDenied" in err or "IAM" in err) and owner_only: |
| secret = os.getenv("SOLANA_KEY_SECRET_ID", "").strip() \ |
| or "garden-angel/solana-signer" |
| proposals.append(( |
| 100, "", "", None, |
| "🔑 *CANNOT SIGN — this is the top blocker and it is " |
| "not a setting.*\n\n" |
| "The instance role has no " |
| "`secretsmanager:GetSecretValue` on " |
| f"`{secret}`. I cannot grant it: this box correctly " |
| "cannot edit its own IAM, which is why " |
| "`fix_iam.sh --apply` is denied.\n\n" |
| "*Fastest path from a phone — no terminal:*\n" |
| "AWS Console → IAM → Roles → `SSMInstanceRole` → " |
| "Add permissions → Create inline policy → JSON tab → " |
| "paste, name it `garden-angel-read-signer-secret`, " |
| "Create.\n\n" |
| "`bash scripts/fix_iam.sh` prints the exact JSON and " |
| "the ARN with your account id already filled in.\n\n" |
| "_No restart needed after — custody is read per " |
| "attempt, so the next scan cycle picks it up._", |
| False, |
| )) |
| except Exception as exc: |
| logger.debug("[Heal] custody unreadable: %s", exc) |
|
|
| actionable = [p for p in proposals if p[1]] |
| advisory = [p for p in proposals if not p[1]] |
|
|
| if not actionable and not advisory: |
| await self._safe_send( |
| "🩹 *nothing to heal*\n\nNo setting is currently misconfigured " |
| "in a way I can fix with one key. If the bot still is not " |
| "trading, that is the market or a blocker you control — " |
| "/next ranks what is left.", chat_id) |
| return |
|
|
| |
| |
| |
| |
| advisory.sort(key=lambda p: -p[0]) |
| for note in advisory: |
| await self._safe_send(f"⚠️ {note[4]}", chat_id) |
|
|
| if not actionable: |
| return |
|
|
| actionable.sort(key=lambda p: -p[0]) |
| await self._safe_send( |
| f"🩹 found *{len(actionable)}* fixable setting(s). Each needs its " |
| f"own yes — nothing changes until you tap.", chat_id) |
| for _prio, key, value, _unused, title, needs_restart in actionable[:6]: |
| await self._propose_change( |
| chat_id, key, value, title, "heal", |
| needs_restart=needs_restart, |
| ) |
|
|
| async def _cmd_verify(self, chat_id: int) -> None: |
| """Can this bot actually trade, right now? One card, custody-aware. |
| |
| v1.71 — the operator has been reading |
| scripts/push_key_to_secrets.sh's output as a verification and being |
| told `FAILED — SOLANA_PRIVATE_KEY is not set in .env` on a box whose |
| key correctly lives in AWS. That script is a one-way PUSH tool; it |
| cannot verify anything, because it only knows whether a plaintext |
| copy exists. |
| |
| This asks the questions that actually decide whether a signal can |
| become a trade, through the same modules the send path uses. |
| """ |
| from modules import key_custody |
|
|
| lines = ["🧿 *verify*", ""] |
|
|
| |
| try: |
| st = await asyncio.to_thread(key_custody.status) |
| source = st.get("source") or "env" |
| if st.get("loaded") and st.get("pubkey"): |
| lines.append(f"✅ signs as `{st['pubkey']}`") |
| lines.append(f" custody: `{source}`") |
| else: |
| |
| |
| try: |
| await asyncio.to_thread(key_custody.load_private_key) |
| st = await asyncio.to_thread(key_custody.status) |
| lines.append(f"✅ signs as `{st.get('pubkey')}`") |
| lines.append(f" custody: `{source}`") |
| except Exception as exc: |
| lines.append(f"❌ CANNOT SIGN — {str(exc)[:180]}") |
| lines.append(f" custody: `{source}`") |
| if st.get("risk"): |
| lines.append(f"⚠️ {st['risk']}") |
| if st.get("mismatch"): |
| lines.append(f"⚠️ {st['mismatch']}") |
| except Exception as exc: |
| lines.append(f"❌ custody unreadable: {str(exc)[:160]}") |
|
|
| |
| lines.append("") |
| try: |
| blockers = self._solana._execution_blockers() if self._solana else [] |
| if blockers: |
| lines.append("⛔ execution is HELD:") |
| for b in blockers[:4]: |
| lines.append(f" • {b}") |
| else: |
| lines.append("✅ execution is enabled") |
| except Exception as exc: |
| lines.append(f"· execution state unreadable: {str(exc)[:120]}") |
|
|
| |
| try: |
| from modules.ws_state import get_ws_state |
|
|
| ws = get_ws_state().status() |
| if not ws["enabled"]: |
| lines.append("· push cache off (SOLANA_WS_STATE=false)") |
| elif ws.get("live"): |
| lines.append( |
| f"✅ push cache live — {ws['slots_seen']:,} slots, " |
| f"{ws['blockhash_pushes']:,} blockhash pushes, " |
| f"{ws['alt_subscriptions']} ALT(s) watched" |
| + (f", last slot {ws['secs_since_slot']:.1f}s ago" |
| if ws.get("secs_since_slot") is not None else "")) |
| elif ws["connected"]: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| lines.append( |
| f"🔴 push cache CONNECTED BUT NOT DELIVERING — " |
| f"{ws.get('stale_reason', 'no slots arriving')}. " |
| f"{ws['slots_seen']:,} slots seen in total. Every trade " |
| f"is paying the cold blockhash fetch right now.") |
| else: |
| lines.append( |
| f"⚠️ push cache not connected" |
| + (f" — {ws['last_error'][:100]}" if ws["last_error"] else "") |
| + " (harmless: the cache falls back to fetching)") |
| except Exception: |
| pass |
|
|
| |
| |
| |
| try: |
| from modules import grpc_preflight |
|
|
| if grpc_preflight.status()["configured"]: |
| lines.append("") |
| lines.append(grpc_preflight.render()) |
| except Exception: |
| pass |
|
|
| |
| try: |
| from modules.solana_executor import _MIN_REAL_NET_MARGIN |
| from modules.tip_memory import remembered_floor |
|
|
| floor_lam, floor_age = await asyncio.to_thread(remembered_floor) |
| sol_usd = (self._solana._last_sol_usd_price if self._solana else 0) or 0 |
| if floor_lam and sol_usd > 0: |
| tip_usd = floor_lam / 1e9 * sol_usd |
| lines.append("") |
| lines.append( |
| f"💰 an edge must clear ~${tip_usd:.4f} tip + " |
| f"${_MIN_REAL_NET_MARGIN:.2f} margin = " |
| f"*${tip_usd + _MIN_REAL_NET_MARGIN:.4f}* to be worth sending") |
| lines.append( |
| f" _tip floor remembered {floor_age / 60:.0f}m ago_") |
| except Exception: |
| pass |
|
|
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_quote(self, chat_id: int, args: list) -> None: |
| """A live round trip for any pair and size, from the phone. |
| |
| v1.71 — /quote existed and was removed. It is back because the |
| question it answers ("is there anything there right now?") otherwise |
| requires SSH, and the operator has been running scan scripts by hand |
| on a box they reach from a phone. |
| |
| Read-only. Quotes only. No key is loaded, nothing is signed. |
| """ |
| base = (args[0].upper() if len(args) > 0 else "USDC") |
| mid = (args[1].upper() if len(args) > 1 else "SOL") |
| try: |
| size = float(args[2]) if len(args) > 2 else 1000.0 |
| except (TypeError, ValueError): |
| await self._safe_send( |
| "size must be a number — `/quote USDC SOL 1000`", chat_id) |
| return |
|
|
| from constants import SOLANA_TOKENS |
|
|
| if base not in SOLANA_TOKENS or mid not in SOLANA_TOKENS: |
| known = ", ".join(sorted(SOLANA_TOKENS)[:14]) |
| await self._safe_send( |
| f"unknown token. Try: {known}\n\nUsage: `/quote USDC SOL 1000`", |
| chat_id) |
| return |
|
|
| await self._safe_send( |
| f"quoting {base}->{mid}->{base} at {size:,.0f} {base}…", chat_id) |
| try: |
| text = await self._round_trip_quote(base, mid, size) |
| except Exception as exc: |
| text = f"quote failed: {str(exc)[:220]}" |
| await self._safe_send(text, chat_id) |
|
|
| async def _round_trip_quote(self, base: str, mid: str, size: float) -> str: |
| import httpx |
|
|
| from constants import (JUPITER_HEADERS, JUPITER_QUOTE_API, |
| SOLANA_TOKENS) |
|
|
| b, m = SOLANA_TOKENS[base], SOLANA_TOKENS[mid] |
| amount_in = int(size * (10 ** b["decimals"])) |
|
|
| async def _q(in_mint: str, out_mint: str, amount: int): |
| r = await client.get( |
| f"{JUPITER_QUOTE_API}/quote", headers=JUPITER_HEADERS, timeout=15.0, |
| params={"inputMint": in_mint, "outputMint": out_mint, |
| "amount": str(amount), "slippageBps": "50", |
| "swapMode": "ExactIn"}) |
| r.raise_for_status() |
| return r.json() |
|
|
| async with httpx.AsyncClient() as client: |
| q1 = await _q(b["mint"], m["mint"], amount_in) |
| q2 = await _q(m["mint"], b["mint"], int(q1["outAmount"])) |
|
|
| out = int(q2["outAmount"]) |
| gross_bps = (out - amount_in) / amount_in * 10_000.0 |
| gross_usd = (out - amount_in) / (10 ** b["decimals"]) |
|
|
| from modules.env_file import effective_haircut_bps |
|
|
| haircut, _src = effective_haircut_bps() |
| fee_bps = haircut + float(os.getenv("SOLANA_FLASH_FEE_BPS", "0.5") or 0.5) |
| net_bps = gross_bps - fee_bps |
|
|
| verdict = "✅ positive" if net_bps > 0 else "❌ negative" |
| return "\n".join([ |
| f"📉 *{base}->{mid}->{base}* at {size:,.0f} {base}", |
| "", |
| f"round trip: `{gross_bps:+.2f}` bps (${gross_usd:+.4f})", |
| f"fees: `{fee_bps:.2f}` bps", |
| f"net: `{net_bps:+.2f}` bps {verdict}", |
| "", |
| f"_both legs quoted live at this size, both impacts paid._", |
| ]) |
|
|
| async def _cmd_next(self, chat_id: int) -> None: |
| """What should I do next? One ranked answer. |
| |
| v1.71 — operator: "I want the system guide us to what we go next." |
| |
| Everything needed for this already existed and was scattered across |
| /why, /doctor, /gates, /tune, /pipeline and /tips — six screens, each |
| correct, none of which says which one to read first. This ranks. |
| """ |
| items: list[tuple[int, str, str]] = [] |
|
|
| |
| try: |
| from modules import key_custody |
|
|
| st = await asyncio.to_thread(key_custody.status) |
| if st.get("error"): |
| items.append((100, "the bot cannot load its signing key", |
| "/verify shows the custody error")) |
| if st.get("plaintext_env_key_present") and st.get("source") != "env": |
| items.append((90, "a plaintext key is still in .env beside the vault", |
| "remove SOLANA_PRIVATE_KEY from .env")) |
| except Exception: |
| pass |
|
|
| try: |
| blockers = self._solana._execution_blockers() if self._solana else [] |
| if blockers: |
| items.append((95, "execution is switched off, so no signal can trade", |
| f"/why — {blockers[0][:90]}")) |
| except Exception: |
| pass |
|
|
| |
| |
| try: |
| from modules.solana_executor import _MIN_REAL_NET_MARGIN |
| from modules.tip_memory import remembered_floor |
|
|
| floor_lam, _age = await asyncio.to_thread(remembered_floor) |
| sol_usd = (self._solana._last_sol_usd_price if self._solana else 0) or 0 |
| if floor_lam and sol_usd > 0: |
| need = floor_lam / 1e9 * sol_usd + _MIN_REAL_NET_MARGIN |
| items.append(( |
| 60, |
| f"an edge must be worth ${need:.2f}+ to be worth sending", |
| "if signals are smaller than this, the answer is a bigger " |
| "edge, not a bigger tip — /observe, /hunt, discover.py", |
| )) |
| except Exception: |
| pass |
|
|
| |
| try: |
| from modules.pipeline_trace import get_log |
|
|
| summary = await asyncio.to_thread(get_log().summary) |
| p90 = (summary or {}).get("total_ms_p90") |
| if p90 and p90 > 400: |
| items.append(( |
| 70, |
| f"the pipeline p90 is {p90:.0f}ms — over one 400ms slot", |
| "/pipeline names the slowest stage; a quote older than a " |
| "slot is now refused before it is built", |
| )) |
| except Exception: |
| pass |
|
|
| |
| try: |
| from modules import tune_report |
|
|
| recs = await asyncio.to_thread(tune_report.build, self._solana) |
| top = next((r for r in recs if r.applyable), None) |
| if top is not None: |
| items.append((50, f"/tune suggests: {top.title}", |
| "run /tune and approve it if you agree")) |
| except Exception: |
| pass |
|
|
| if not items: |
| await self._safe_send( |
| "🧠 *next*\n\nNothing is blocking and nothing is asking for a " |
| "decision. The bot is scanning; if it is not trading, that is " |
| "the market, not the machine. /observe for the honest view.", |
| chat_id) |
| return |
|
|
| items.sort(key=lambda t: -t[0]) |
| lines = ["🧠 *next* — most important first", ""] |
| for n, (_prio, headline, action) in enumerate(items[:5], 1): |
| lines.append(f"*{n}. {headline}*") |
| lines.append(f" {action}") |
| lines.append("") |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| async def _cmd_pipeline(self, chat_id: int) -> None: |
| """Where the time between signal and wire actually goes.""" |
| from modules.pipeline_report import render_pipeline |
|
|
| text = await asyncio.to_thread(render_pipeline) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_lighthouse(self, chat_id: int) -> None: |
| """One verdict from all eleven screens (v17.49). |
| |
| Operator: "we are at darkness we need go to light." |
| |
| Every screen this bot has is correct and together they are a |
| reading assignment. Each answers a different question and none |
| answers THE question — is there money here, and if not, what is the |
| one next thing to do. This ranks them and names one action. |
| """ |
| from modules.lighthouse_report import render_lighthouse |
|
|
| text = await asyncio.to_thread(render_lighthouse, self._solana) |
| await self._safe_send(text, chat_id) |
|
|
| async def _probe_jupiter_venues(self) -> list[tuple[str, bool, str]]: |
| """Price 1 SOL → USDC through each venue via Jupiter's `dexes=`. |
| |
| This is the venue access the bot actually has. The direct adapters |
| below it are a fallback for a Jupiter outage, and two of the three |
| no longer have an endpoint to fall back to — so probing only those |
| reported the bot as blind to Whirlpool and Meteora while it was |
| quoting both every cycle. |
| """ |
| import httpx |
|
|
| from constants import JUPITER_HEADERS, JUPITER_QUOTE_API, SOLANA_TOKENS |
|
|
| sol, usdc = SOLANA_TOKENS["SOL"], SOLANA_TOKENS["USDC"] |
| venues = ["Whirlpool", "Orca V2", "Meteora DLMM", "Raydium", |
| "Raydium CLMM", "Phoenix"] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| out: list[tuple[str, bool, str]] = [] |
|
|
| async with httpx.AsyncClient(timeout=15.0) as client: |
| for venue in venues: |
| try: |
| r = await client.get( |
| f"{JUPITER_QUOTE_API}/quote", |
| params={"inputMint": sol["mint"], "outputMint": usdc["mint"], |
| "amount": str(10 ** sol["decimals"]), |
| "slippageBps": "50", "dexes": venue, |
| "onlyDirectRoutes": "true"}, |
| headers=JUPITER_HEADERS, |
| ) |
| if r.status_code != 200: |
| out.append((venue, False, f"HTTP {r.status_code}")) |
| continue |
| raw = r.json().get("outAmount") |
| if not raw: |
| |
| |
| |
| out.append((venue, False, "no direct route for this pair")) |
| continue |
| price = int(raw) / (10 ** usdc["decimals"]) |
| out.append((venue, True, f"1 SOL → {price:,.2f} USDC")) |
| except Exception as exc: |
| out.append((venue, False, str(exc)[:60])) |
| await asyncio.sleep(0.3) |
| return out |
|
|
| async def _cmd_sendpath(self, chat_id: int) -> None: |
| """The 400ms test, on demand, without needing a signal (v17.56). |
| |
| Operator: "i need this test alone in telegram." |
| |
| /pipeline can only refresh when a real signal reaches the executor. |
| /bench times stages in isolation on a fresh connection, which |
| measures the network rather than the caches. This calls the exact |
| functions a live send calls, through the same hot_state cache, cold |
| then warm — so the warm column is what a trade actually pays. |
| """ |
| import asyncio as _a |
|
|
| from constants import SOLANA_TOKENS |
| from modules.solana_executor import ( |
| _cached_alt_accounts, _cached_mint_safety, _cached_priority_fee, |
| _cached_reserve, _fresh_blockhash, get_shared_client, |
| ) |
|
|
| rpc = (os.getenv("SOLANA_RPC_PRIMARY") |
| or os.getenv("SOLANA_RPC_URL") or "").strip() |
| if not rpc: |
| await self._safe_send("⏱ *sendpath* — no RPC configured.", chat_id) |
| return |
|
|
| client = get_shared_client() |
| sol, usdc = SOLANA_TOKENS["SOL"], SOLANA_TOKENS["USDC"] |
| stages = [ |
| ("freeze SOL", lambda: _cached_mint_safety(client, rpc, "SOL", sol["mint"])), |
| ("freeze USDC", lambda: _cached_mint_safety(client, rpc, "USDC", usdc["mint"])), |
| ("priority fee", lambda: _cached_priority_fee(client, rpc)), |
| ("reserve", lambda: _cached_reserve(client, "USDC", usdc["mint"], rpc)), |
| ("alt tables", lambda: _cached_alt_accounts(client, rpc, [])), |
| ("blockhash", lambda: _fresh_blockhash(client, rpc)), |
| ] |
|
|
| async def _t(fn): |
| t0 = time.monotonic() |
| try: |
| r = fn() |
| if _a.iscoroutine(r): |
| await r |
| except Exception as exc: |
| return None, str(exc)[:44] |
| return (time.monotonic() - t0) * 1000.0, "" |
|
|
| sign = os.getenv("GA_SIGN", "✝️🪬🧿 🧠🦅👀") |
| out = [f"{sign} ⏱ *sendpath* — the 400ms test", "", |
| "_the real cached calls a send makes. nothing is signed._", "", |
| "`stage COLD WARM`"] |
| warm_total = 0.0 |
| broken = [] |
| for name, fn in stages: |
| cold, err = await _t(fn) |
| if cold is None: |
| broken.append(f"{name}: {err}") |
| out.append(f"`{name:<14} FAILED`") |
| continue |
| warms = [] |
| for _ in range(3): |
| w, _e = await _t(fn) |
| if w is not None: |
| warms.append(w) |
| if not warms: |
| broken.append(f"{name}: warm call failed") |
| continue |
| warm = sorted(warms)[len(warms) // 2] |
| warm_total += warm |
| out.append(f"`{name:<14}{cold:>6.0f}ms{warm:>6.0f}ms`") |
|
|
| if broken: |
| out += ["", "⚠️ *some stages failed — this is not a pass:*"] |
| out += [f" · {b}" for b in broken] |
| await self._safe_send("\n".join(out), chat_id) |
| return |
|
|
| quotes = 120.0 |
| total = warm_total + quotes |
| out += ["", |
| f"cached state, warm `{warm_total:>6.0f} ms`", |
| f"+ quotes & swap-ix `{quotes:>6.0f} ms` _(the market, uncacheable)_", |
| f"*= signal to signed* `{total:>6.0f} ms`", |
| f"a Solana slot is `{400:>6.0f} ms`", ""] |
| out.append( |
| f"✅ *{total:.0f} ms — inside one slot.* The warm cache is working; " |
| f"latency is not what stops a trade. /observe for whether the round " |
| f"trip is positive at all." |
| if total <= 400 else |
| f"🔴 *{total:.0f} ms — longer than a slot.* A warm column over 100ms " |
| f"means that cache is missing — check it restarted and that " |
| f"warm_mint_safety ran." |
| ) |
| await self._safe_send("\n".join(out), chat_id) |
|
|
| async def _cmd_tips(self, chat_id: int) -> None: |
| """What the tip auction actually cost, remembered (v17.53). |
| |
| Operator: "save as memory to the bot". /jito reports the engine's |
| in-RAM counters, which reset on every restart — several times an |
| hour on this box — so its "0/0 landed" was a claim about uptime, |
| not about the auction. This reads modules/tip_memory.py, which |
| persists every bid, the floor it faced, and its fate. |
| """ |
| from modules.tip_memory import render_tips |
|
|
| text = await asyncio.to_thread(render_tips) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_observe(self, chat_id: int) -> None: |
| """Was this trade EVER viable, over recorded history (v17.50). |
| |
| Every other screen answers a question about right now. This one |
| reads the observatory's stored series and answers the question that |
| decides whether to keep going at all — see modules/observatory.py |
| for why latency is deliberately absent from the arithmetic. |
| """ |
| from modules.observatory import render_observe |
|
|
| text = await asyncio.to_thread(render_observe) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_bench(self, chat_id: int) -> None: |
| """Per-stage micro-benchmark of the SEND path (v17.48). |
| |
| /speedtest answers "are the endpoints up" and has been answering |
| ✅ PASS for weeks while nothing traded, because endpoint health and |
| send-path speed are different questions. This times each stage a |
| send actually uses and composes them into the one number that |
| decides a race: quote → signed, against Solana's 400ms slot. |
| """ |
| from modules.bench_report import run_bench |
|
|
| await self._safe_send("⏱ benchmarking the send path… ~15s", chat_id) |
| text = await run_bench(self._solana) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_decisions(self, chat_id: int) -> None: |
| """Why each EVALUATION did not become a trade — the 99.9% every |
| other screen structurally cannot see (v17.47). |
| |
| Operator, showing their Rust bot's equivalent: a quarter of a |
| million rows reading `skip:negative_gross`. That table is not |
| noise, it is the finding — it says the constraint sits upstream of |
| every gate this bot argues about. /capture, /nearmiss and /gates |
| all start from a signal, so none of them can say anything about |
| the cycles that never produced one. |
| """ |
| from modules.pipeline_report import render_decisions |
|
|
| text = await asyncio.to_thread(render_decisions) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_gates(self, chat_id: int) -> None: |
| """Which of our OWN rules stopped a trade, and what they held.""" |
| from modules.pipeline_report import render_gates |
|
|
| text = await asyncio.to_thread(render_gates) |
| await self._safe_send(text, chat_id) |
|
|
| async def _cmd_drawdown(self, chat_id: int) -> None: |
| """Drawdown, loss streak, 24h net, and the alert latches (v17.39).""" |
| from modules.drawdown_alert import get_watcher |
|
|
| watcher = get_watcher() |
| st = watcher.status() |
| cur = st["current"] or {} |
| if not cur: |
| |
| |
| try: |
| await asyncio.to_thread(watcher.check) |
| st = watcher.status() |
| cur = st["current"] or {} |
| except Exception as exc: |
| logger.debug("[/drawdown] on-demand check failed: %s", exc) |
|
|
| th = st["thresholds"] |
| lines = ["📉 *drawdown watch*\n"] |
| if not st["enabled"]: |
| lines.append("🔴 disabled (DRAWDOWN_ALERT_ENABLED=false)") |
| await self._safe_send("\n".join(lines), chat_id) |
| return |
|
|
| if not cur or not cur.get("executed"): |
| lines.append( |
| "No executed trades yet, so every figure here is trivially zero. " |
| "Alerts stay silent until there is a realised curve to draw down " |
| "from — a $0.00 drawdown on 0 trades is not a clean record." |
| ) |
| else: |
| lines.append( |
| f"drawdown: *-${cur['drawdown_usd']:,.2f}* " |
| f"(alerts at ${th['drawdown_usd']:,.2f})" |
| ) |
| lines.append( |
| f"net realised: ${cur['net_profit_usd']:,.2f} over " |
| f"{cur['executed']:,} trade(s)" |
| + (f", win rate {cur['win_rate']:.0%}" if cur.get("win_rate") is not None else "") |
| ) |
| lines.append( |
| f"losing streak: *{cur['loss_streak']}* " |
| f"(alerts at {th['loss_streak']})" |
| ) |
| lines.append( |
| f"last 24h: ${cur['day_net_usd']:,.2f} over " |
| f"{cur['day_trades']:,} trade(s) " |
| f"(alerts at -${th['day_loss_usd']:,.2f})" |
| ) |
|
|
| armed = [n for n, l in st["latches"].items() if l["armed"]] |
| fired = [n for n, l in st["latches"].items() if not l["armed"]] |
| lines.append( |
| f"\nlatches: {', '.join(armed) or 'none'} armed" |
| + (f" · {', '.join(fired)} already fired" if fired else "") |
| ) |
| lines.append( |
| f"halt on breach: " |
| f"{'ON — execution pauses too' if st['halt_on_breach'] else 'off (alert only)'}" |
| ) |
| lines.append( |
| "\n_Each alert fires once on crossing, again only if it gets " |
| f"${th['step_usd']:,.2f} worse, and re-arms when the metric halves. " |
| "An alert that repeats every poll is one you mute._" |
| ) |
| await self._safe_send("\n".join(lines), chat_id) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_doctor(self, chat_id: int) -> None: |
| problems: list[str] = [] |
| warnings: list[str] = [] |
| info: list[str] = [] |
|
|
| sol = self._solana |
| if sol is None: |
| problems.append( |
| "Solana engine is not attached — the scan loop never started " |
| "(check the server startup log)." |
| ) |
| else: |
| try: |
| problems.extend(sol._execution_blockers()) |
| except Exception as exc: |
| warnings.append(f"could not read execution blockers: {str(exc)[:100]}") |
|
|
| if sol.scan_count == 0: |
| warnings.append( |
| "no scan cycle has completed yet — startup warm-up is ~45s, " |
| "give it a minute" |
| ) |
| else: |
| age = time.time() - sol.last_scan_ts if sol.last_scan_ts else None |
| if age is not None and age > 180: |
| problems.append( |
| f"scan loop looks STALE — last completed cycle was {age:.0f}s " |
| "ago (expected every ~20-40s); a restart may be needed" |
| ) |
| else: |
| info.append( |
| f"scan loop alive — last cycle {age:.0f}s ago, " |
| f"{sol.scan_count} cycles, {sol.signal_count} signal(s) this run" |
| ) |
|
|
| try: |
| if sol._jupiter_is_cooling(): |
| remaining = max(0.0, sol._jupiter_backoff_until - time.monotonic()) |
| warnings.append( |
| f"Jupiter is rate-limited (cooling {remaining:.0f}s more) — " |
| "scanning and execution resume by themselves after" |
| ) |
| else: |
| info.append("Jupiter: not rate-limited right now") |
| except Exception: |
| pass |
|
|
| |
| |
| |
| try: |
| from modules.trading_guard import get_guard |
|
|
| guard = get_guard().status() |
| if guard["state"] == "open": |
| problems.append( |
| f"trading is AUTO-PAUSED by the circuit breaker — " |
| f"{guard['trip_reason'][:220]}. Retries by itself in " |
| f"{guard['cooldown_remaining_secs'] / 60:.1f} min, or " |
| f"/resume to clear now. (Scanning is unaffected.)" |
| ) |
| elif guard["state"] == "half_open": |
| warnings.append( |
| "trading circuit breaker is HALF-OPEN — the next execution " |
| "attempt is a trial run; if it succeeds, normal trading resumes" |
| ) |
| elif guard["consecutive"]: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| thresholds = guard["thresholds"] or {} |
| live = { |
| kind: n for kind, n in guard["consecutive"].items() |
| if thresholds.get(kind, 0) > 0 |
| } |
| inert = { |
| kind: n for kind, n in guard["consecutive"].items() |
| if thresholds.get(kind, 0) <= 0 |
| } |
| if live: |
| warnings.append( |
| "consecutive failure streak building: " |
| + ", ".join( |
| f"{k}×{v} (trips at {thresholds[k]})" |
| for k, v in live.items() |
| ) |
| ) |
| if inert: |
| info.append( |
| "counted, cannot pause trading: " |
| + ", ".join(f"{k}×{v}" for k, v in inert.items()) |
| + " — a correct refusal is not a fault" |
| ) |
| except Exception as exc: |
| warnings.append(f"could not read the trading guard: {str(exc)[:100]}") |
|
|
| |
| |
| |
| try: |
| from modules import key_custody |
|
|
| custody = key_custody.status() |
| if custody.get("mismatch"): |
| problems.append(custody["mismatch"]) |
| if custody.get("risk"): |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if _env_bool("GA_ACK_PLAINTEXT_KEY", False): |
| info.append( |
| "key custody: PLAINTEXT (acknowledged) — still " |
| "bearer authority, no revocation. `bash " |
| "scripts/push_key_to_secrets.sh` when you want it " |
| "gone for real." |
| ) |
| else: |
| warnings.append( |
| f"key custody — {custody['risk']} " |
| f"Fix: `bash scripts/push_key_to_secrets.sh`. " |
| f"Or silence this line with " |
| f"`./scripts/setenv.sh GA_ACK_PLAINTEXT_KEY=true` " |
| f"— the risk stays, the reminder stops." |
| ) |
| else: |
| info.append( |
| f"key custody: {custody['source']}" |
| + (f", signs for {custody['pubkey']}" if custody.get("pubkey") else "") |
| ) |
| except Exception as exc: |
| warnings.append(f"could not read key custody: {str(exc)[:100]}") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.rpc_failover import failover_health |
|
|
| fh = failover_health() |
| problems.extend(fh["problems"]) |
| warnings.extend(fh["warnings"]) |
| if fh["has_secondary"] and not fh["same_host"] and not fh["same_domain"]: |
| info.append( |
| f"RPC failover: {fh['primary_host']} → " |
| f"{fh['secondary_host']} — genuinely separate " |
| f"providers, so one outage does not take both" |
| ) |
| except Exception as exc: |
| warnings.append(f"could not check RPC failover: {str(exc)[:100]}") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.feed_client import ENABLED as _feed_on, get_feed |
|
|
| if _feed_on: |
| fd = get_feed().describe() |
| |
| |
| |
| |
| |
| for p in fd.get("problems", []): |
| problems.append(f"pool feed: {p}") |
| if fd.get("hint"): |
| problems.append(f" fix: `{fd['hint']}`") |
| for w in fd.get("warnings", []): |
| warnings.append(w) |
| if fd.get("info"): |
| info.append(fd["info"]) |
| except Exception as exc: |
| warnings.append(f"could not check the pool feed: {str(exc)[:100]}") |
|
|
| |
| |
| |
| |
| |
| try: |
| from modules.jupiter_limiter import get_limiter |
|
|
| jl = get_limiter().describe() |
| problems.extend(jl["problems"]) |
| if jl["info"]: |
| info.append(jl["info"]) |
| except Exception as exc: |
| warnings.append(f"could not check the Jupiter limiter: {str(exc)[:100]}") |
|
|
| |
| |
| |
| try: |
| from modules.route_scorer import get_scorer |
|
|
| scoring = get_scorer().status() |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if scoring.get("blackout"): |
| problems.append( |
| f"SCANNER IS DARK — {scoring.get('blackout_reason', 'no routes scanned')}. " |
| f"No quotes means no signals, however healthy everything else " |
| f"looks. Fix order: (1) `./scripts/setenv.sh ROUTE_FOCUS_TOP_N=0` " |
| f"to stop concentrating, (2) `./scripts/setenv.sh " |
| f"ROUTE_SCORE_PRUNING_ENABLED=false` if it persists, " |
| f"(3) /routes to see which rule excluded what." |
| ) |
| planned, total = scoring.get("planned_scan_count"), scoring.get("planned_total") |
| if planned is not None and total: |
| frac = scoring.get("budget_fraction") |
| budget = ( |
| f"{scoring.get('budget_used', 0):,}/{scoring.get('budget_total', 0):,} " |
| f"Jupiter calls used this hour" |
| + (f" ({frac * 100:.0f}%)" if frac is not None else "") |
| ) |
| if not scoring.get("rationing"): |
| info.append( |
| f"scan set: ALL {planned} of {total} route(s) — {budget}, " |
| f"below the {scoring.get('budget_pressure_on', 0.6) * 100:.0f}% " |
| f"line where concentrating starts to pay for itself. " |
| f"Nothing is being skipped to save a budget that is idle." |
| ) |
| else: |
| info.append( |
| f"scan set: {planned} of {total} route(s) — {budget}, " |
| f"so the budget is being concentrated on the producers " |
| f"(/routes for who and why)" |
| ) |
| |
| |
| |
| if scoring.get("routes_undersampled"): |
| info.append( |
| f"{scoring['routes_undersampled']} route(s) UNDERSAMPLED — not " |
| f"enough evaluations yet for '0 signals' to mean anything at a " |
| f"{scoring.get('min_detectable_rate', 0.002) * 100:.2f}% signal " |
| f"rate. They keep scanning; /routes shows how many more each needs." |
| ) |
| if scoring.get("routes_proven"): |
| info.append( |
| f"{scoring['routes_proven']} route(s) PROVEN — they have cleared " |
| f"the floor at least once, so nothing may prune them however " |
| f"quiet the last few hundred evals were" |
| ) |
| if scoring.get("rescued"): |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| rescued_txt = ", ".join(scoring["rescued"]) |
| try: |
| from modules.observatory import settled_unprofitable |
|
|
| verdict = settled_unprofitable() |
| except Exception: |
| verdict = None |
| if verdict is not None: |
| info.append( |
| f"survival floor holding {rescued_txt} open — " |
| f"working as designed. /observe priced " |
| f"{verdict['priced']:,} real moments over " |
| f"{verdict.get('hours', 0):.0f}h and none cleared " |
| f"full cost, so this is the market, not the " |
| f"thresholds. Nothing to change here." |
| ) |
| else: |
| warnings.append( |
| "the survival floor is binding — pruning and focus " |
| "together would have left too few routes scanning, " |
| f"so {rescued_txt} were kept regardless. Either " |
| "everything is genuinely unprofitable right now (a " |
| "market fact) or the thresholds are too tight — " |
| "/observe has not priced enough moments to say " |
| "which." |
| ) |
| if scoring["routes_pruned"]: |
| info.append( |
| f"route pruning: {scoring['routes_pruned']} of " |
| f"{scoring['routes_scored']} routes paused for never coming " |
| f"near the floor (/routes for the list) — their Jupiter " |
| f"budget goes to the routes that do produce" |
| ) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| try: |
| effective = sol.effective_min_profit() |
| if effective > sol.min_profit * 1.05: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| bd = sol.min_profit_breakdown() |
| parts = [] |
| if bd["dynamic_adder"] > 0.005: |
| parts.append( |
| f"+${bd['dynamic_adder']:.2f} priority-fee " |
| f"volatility (DYNAMIC_FLOOR_ENABLED)" |
| ) |
| if bd["decay_adder"] > 0.005: |
| parts.append( |
| f"+${bd['decay_adder']:.2f} edge-decay gate " |
| f"(DECAY_GATE_ENABLED) — /decay" |
| ) |
| cause = " · ".join(parts) or "composed gates" |
| knob = { |
| "decay": "DECAY_GATE_ENABLED=false removes it " |
| "(it is priced from 20 attempts that all failed)", |
| "dynamic": "it drops back by itself in a quiet moment", |
| "bps": "MIN_PROFIT_FLOOR_BPS is what binds, not the dollar floor", |
| }.get(bd["dominant"], "") |
| info.append( |
| f"signal floor: net ≥ ${effective:.2f} right now = " |
| f"${sol.min_profit:.2f} configured {cause}. " |
| f"Raising MIN_PROFIT_FLOOR_USD is NOT what set this" |
| + (f" — {knob}" if knob else "") |
| ) |
| except Exception: |
| info.append( |
| f"signal floor: net ≥ ${effective:.2f} right now " |
| f"(configured ${sol.min_profit:.2f}, raised by a " |
| f"composed gate — /decay and /tune for which)" |
| ) |
| else: |
| info.append(f"signal floor: net ≥ ${effective:.2f} after fees") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.solana_arb import _MIN_PROFIT_FLOOR_BPS |
|
|
| if _MIN_PROFIT_FLOOR_BPS > 0: |
| probe = float(os.getenv("SOLANA_PROBE_BASE_UNITS", "") or 10_000) |
| usd = probe * sol._usd_per_base_unit("USDC") |
| as_dollars = usd * _MIN_PROFIT_FLOOR_BPS / 10_000.0 |
| binding = "THIS is the binding floor" if as_dollars > effective \ |
| else "the dollar floor above is higher, so it binds" |
| info.append( |
| f"percentage floor: {_MIN_PROFIT_FLOOR_BPS:.2f} bps of the " |
| f"loan = ${as_dollars:.2f} at the current {probe:,.0f} probe " |
| f"— {binding}. Unlike a dollar floor this does not change " |
| f"meaning when the probe size does." |
| ) |
| except Exception: |
| pass |
| except Exception: |
| info.append(f"signal floor: net ≥ ${sol.min_profit:.2f} after fees") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.env_file import effective_haircut_bps |
|
|
| haircut, haircut_src = effective_haircut_bps() |
| except Exception: |
| haircut, haircut_src = float( |
| os.getenv("SOLANA_LEG2_HAIRCUT_BPS") |
| or os.getenv("SOLANA_SOL_WRAP_HAIRCUT_BPS") |
| or 2.0 |
| ), "configured" |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| best_bps = None |
| try: |
| from modules.route_scorer import get_scorer |
|
|
| spreads = [ |
| r["mean_spread_bps"] for r in get_scorer().table() if r["rows"] > 0 |
| ] |
| if spreads: |
| best_bps = max(spreads) |
| except Exception: |
| best_bps = None |
|
|
| if best_bps is not None and haircut > abs(best_bps) + 1.0: |
| |
| |
| |
| |
| |
| fix = ( |
| "The learned governor is what applies it — " |
| "`./scripts/setenv.sh HAIRCUT_GOVERNOR_ENABLED=false " |
| f"SOLANA_LEG2_HAIRCUT_BPS={min(2.0, max(0.1, abs(best_bps) / 2)):.1f}` " |
| "pins it instead. /decay shows what the governor is buying." |
| if haircut_src == "learned" else |
| f"Set SOLANA_LEG2_HAIRCUT_BPS=" |
| f"{min(2.0, max(0.1, abs(best_bps) / 2)):.1f} and restart." |
| ) |
| problems.append( |
| f"leg-2 haircut is {haircut:.2f} bps ({haircut_src}), but the best " |
| f"route in your own trade journal averages {best_bps:+.1f} bps. The " |
| f"haircut is bigger than the entire edge — every re-quote is " |
| f"arithmetically certain to come back short, which is exactly why " |
| f"signals fire and nothing ever sends. {fix}" |
| ) |
| elif haircut >= 5.0: |
| problems.append( |
| f"leg-2 haircut is {haircut:.1f} bps — larger than almost any real " |
| "edge these routes produce (0.1-3 bps), so every re-quote fails the " |
| "margin check. This exact misconfiguration froze all trading before " |
| "2026-07-27; the fixed default is 2.0. Set " |
| "SOLANA_LEG2_HAIRCUT_BPS=2 in .env and restart." |
| ) |
| else: |
| |
| |
| |
| |
| |
| |
| |
| adaptive_on = False |
| try: |
| from modules.haircut_governor import ENABLED as _HG_ON |
|
|
| adaptive_on = bool(_HG_ON) |
| except Exception: |
| pass |
| if adaptive_on: |
| info.append( |
| f"leg-2 drift buffer: {haircut:.1f} bps configured — " |
| "NOT in use, the learned value below is what BOTH the " |
| "scanner and the executor apply (fallback only, if the " |
| "governor is disabled or unreadable). Before v17.43 the " |
| "scanner used this static number while the executor used " |
| "the learned one, so the scan was pricing a worse trade " |
| "than the one it was screening for — on 0.1-3 bps routes, " |
| "most of the signal." |
| ) |
| else: |
| info.append( |
| f"leg-2 drift buffer: {haircut:.1f} bps" |
| + (f" (best observed edge {best_bps:+.1f} bps)" if best_bps else "") |
| ) |
| |
| |
| |
| try: |
| from modules.haircut_governor import get_governor |
|
|
| hg = get_governor().status() |
| if hg["enabled"]: |
| quiet = hg["hours_since_shortfall"] |
| info.append( |
| f"learned haircut: {hg['bps']:.2f} bps " |
| f"({hg['raises']} raise(s), {hg['decays']} decay(s)" |
| + (f", quiet {quiet:.1f}h" if quiet is not None else "") |
| + ") — rises on a real 6024, decays back down when " |
| "none appear. /decay for what it is buying." |
| ) |
| if hg["at_ceiling"]: |
| problems.append( |
| f"learned haircut is pinned at its {hg['ceiling_bps']:.1f} " |
| "bps ceiling — leg 1 keeps delivering far less than it " |
| "quotes. That is not ordinary drift: suspect the route, " |
| "the slippage setting, or a stale reserve. Raising the " |
| "ceiling would just recreate the paralysis." |
| ) |
| except Exception: |
| pass |
|
|
| |
| |
| try: |
| from modules.decay_tracker import get_tracker |
|
|
| decay = get_tracker().status() |
| if decay["samples"] >= decay["min_samples"] and decay["decay_usd_per_sec"]: |
| info.append( |
| f"edge decay: ${decay['decay_usd_per_sec']:.4f}/s over a " |
| f"{decay['pipeline_p90_secs']:.2f}s pipeline (p90) — a signal " |
| f"needs ${decay['implied_adder_usd']:.3f} of headroom above the " |
| f"floor just to survive to send. /decay for the breakdown" |
| ) |
| elif decay["recording"]: |
| info.append( |
| f"edge decay: {decay['samples']}/{decay['min_samples']} attempts " |
| f"recorded — /decay once there are enough to measure" |
| ) |
| except Exception: |
| pass |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.solana_arb import learned_flash_fee_status |
|
|
| fee_state = learned_flash_fee_status() |
| if not fee_state["durable"]: |
| problems.append( |
| f"the learned flash-fee floor CANNOT BE SAVED " |
| f"({fee_state['detail']}). Every restart forgets what the " |
| f"last one proved on-chain, so the same unprofitable signal " |
| f"gets found, announced and burned again — forever. Fix: " |
| f"`./scripts/setenv.sh SOLANA_FLASH_FEE_STATE_FILE=data/" |
| f"flash_fee_learned.json`" |
| ) |
| if sol.flash_fee_bps <= 0: |
| problems.append( |
| "the flash-loan fee is assumed to be ZERO. It is not — a " |
| "flash loan is a service. Assuming it is free is what turns " |
| "a +0.5 bps quote into an announced signal that simulation " |
| "then rejects. Set SOLANA_FLASH_FEE_BPS=3 or higher." |
| ) |
| else: |
| per = fee_state.get("by_reserve") or {} |
| proven = ( |
| " · proven per reserve: " |
| + ", ".join(f"{k[:8]}…={v:.2f}" for k, v in sorted(per.items())) |
| if per else "" |
| ) |
| info.append( |
| f"flash-fee floor: {sol.flash_fee_bps:.2f} bps " |
| f"(hard floor {fee_state['floor_bps']:.2f}, learned " |
| f"{fee_state['learned_bps']:.2f}){proven} — " |
| f"{'remembered across restarts' if fee_state['durable'] else 'NOT DURABLE'}" |
| ) |
| except Exception: |
| if sol.flash_fee_bps > 0: |
| info.append( |
| f"learned flash-fee floor: {sol.flash_fee_bps:.2f} bps " |
| "(proven on-chain; raises the bar on every route)" |
| ) |
|
|
| |
| |
| |
| |
| try: |
| from modules.noise_gate import status as noise_status |
|
|
| ng = noise_status() |
| if ng["checked"]: |
| info.append( |
| f"noise gate: {ng['rejected']} of {ng['checked']} candidate " |
| f"signal(s) held back for being inside their own scatter. " |
| f"Each one would have announced and then failed its " |
| f"pre-send re-quote." |
| ) |
| except Exception: |
| pass |
| loan_override = os.getenv("SOLANA_REAL_LOAN_UNITS", "").strip() |
| if loan_override: |
| info.append( |
| f"real loan override: {loan_override} units (still capped to " |
| "each signal's own proven size)" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.capture_report import capture |
|
|
| cap = await asyncio.to_thread(capture) |
| attempted = cap.get("attempted") or 0 |
| landed = cap.get("landed") or 0 |
| signalled = cap.get("signalled") or 0 |
| if attempted >= 10 and landed == 0: |
| problems.append( |
| f"{attempted} execution attempts, {landed} landed. Every " |
| f"one died between the signal and a confirmed " |
| f"transaction. /why ranks the cause; /pipeline says " |
| f"where the time goes; /gates says how many were " |
| f"stopped by our own rules rather than by the market." |
| ) |
| elif signalled >= 10 and attempted == 0: |
| problems.append( |
| f"{signalled} signals found and not one was ever " |
| f"attempted — something is refusing before the " |
| f"executor is reached. /gates names it." |
| ) |
| except Exception: |
| pass |
|
|
| try: |
| from modules.pipeline_trace import get_log as _pipe_log |
|
|
| pipe = _pipe_log().status() |
| p50 = pipe.get("total_p50_ms") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if p50 and pipe.get("stale"): |
| age_d = (pipe.get("newest_age_secs") or 0) / 86400.0 |
| info.append( |
| f"pipeline: last traced {age_d:.1f}d ago at " |
| f"{p50 / 1000.0:.2f}s — too old to judge, and it " |
| f"cannot refresh until a signal reaches the executor. " |
| f"Not counted as a problem. `scripts/test_sendpath.py` " |
| f"measures the send path without needing a signal." |
| ) |
| elif p50 and p50 > 1500: |
| slowest = pipe.get("slowest") or "?" |
| problems.append( |
| f"the pipeline takes {p50 / 1000.0:.2f}s from signal to " |
| f"wire, and the slowest stage is `{slowest}`. On edges " |
| f"that live for seconds that is most of the reason a " |
| f"re-quote comes back short. /pipeline for the split." |
| ) |
| elif p50: |
| info.append( |
| f"pipeline: {p50 / 1000.0:.2f}s p50 signal → wire " |
| f"(slowest stage `{pipe.get('slowest')}`) — /pipeline" |
| ) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| try: |
| from modules.decay_control import get_control |
|
|
| control = get_control().status() |
| if control.get("verdict") not in (None, "off", "not yet"): |
| info.append( |
| f"decay control: {control['verdict']} — " |
| f"{control.get('explanation', '')[:300]}" |
| ) |
| except Exception: |
| pass |
|
|
| |
| |
| for opp in reversed(sol.last_opportunities or []): |
| detail = getattr(opp, "exec_detail", "") or "" |
| if getattr(opp, "is_signal", False) and detail: |
| info.append(f"last signal outcome: {detail[:350]}") |
| break |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| signer, signer_source = sol.resolve_wallet_address() |
| wallet_env = os.getenv("SOLANA_WALLET", "").strip() |
|
|
| async def _lamports(address: str) -> tuple[Optional[int], str]: |
| """Balance via primary RPC, falling back to the secondary. |
| |
| A single-endpoint read was its own quiet failure mode here: |
| a throttled or lagging RPC answers 0 for an account it |
| hasn't indexed, which is indistinguishable from an empty |
| wallet in the old message. |
| """ |
| import httpx |
|
|
| urls = [u for u in (sol.rpc_url, getattr(sol, "rpc_url_secondary", "")) if u] |
| last = "" |
| async with httpx.AsyncClient() as client: |
| for url in urls: |
| try: |
| r = await client.post( |
| url, |
| json={"jsonrpc": "2.0", "id": 1, |
| "method": "getBalance", "params": [address]}, |
| timeout=6.0, |
| ) |
| body = r.json() |
| if isinstance(body, dict) and body.get("error"): |
| last = str(body["error"])[:100] |
| continue |
| return int(body["result"]["value"]), "" |
| except Exception as exc: |
| last = f"{type(exc).__name__}: {str(exc)[:70]}" |
| return None, last or "no RPC endpoint configured" |
|
|
| checked: dict[str, Optional[int]] = {} |
| if signer and sol.rpc_url: |
| lamports, err = await _lamports(signer) |
| checked[signer] = lamports |
| if lamports is None: |
| warnings.append( |
| f"could not read the signing wallet's balance ({err}) — " |
| f"address `{signer}`. Not a trade blocker by itself, but " |
| f"nothing below could be verified either." |
| ) |
| elif lamports < 3_000_000: |
| problems.append( |
| f"the signing wallet holds {lamports:,} lamports " |
| f"({lamports / 1e9:.4f} SOL) — not enough to pay " |
| f"transaction fees, so every send fails at simulation. " |
| f"Top up ~0.01 SOL.\n" |
| f" address: `{signer}`\n" |
| f" ({signer_source})\n" |
| f" ⚠️ check that address against your wallet app before " |
| f"sending anything — if your funded account shows a " |
| f"DIFFERENT address, the bot is signing with the wrong key " |
| f"and topping this one up will not help." |
| ) |
| else: |
| info.append( |
| f"fee wallet: {lamports / 1e9:.4f} SOL — enough for fees " |
| f"(`{signer[:8]}…{signer[-6:]}`, {signer_source})" |
| ) |
| elif not signer: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules import key_custody as _kc |
|
|
| src = _kc.active_source() or "env" |
| except Exception: |
| src = "env" |
| where = ("SOLANA_PRIVATE_KEY" if src == "env" |
| else f"the {src} signing key") |
| warnings.append( |
| f"no signing address could be resolved — {where} could not " |
| f"be read and SOLANA_WALLET is not usable either, so the " |
| f"fee-balance check was skipped. `/verify` says why." |
| ) |
|
|
| |
| |
| |
| if wallet_env and signer and wallet_env != signer: |
| env_lamports, _ = await _lamports(wallet_env) |
| checked[wallet_env] = env_lamports |
| env_desc = ( |
| f"{env_lamports / 1e9:.4f} SOL" if env_lamports is not None else "unreadable" |
| ) |
| signer_lamports = checked.get(signer) |
| signer_desc = ( |
| f"{signer_lamports / 1e9:.4f} SOL" |
| if signer_lamports is not None else "unreadable" |
| ) |
| message = ( |
| f"SOLANA_WALLET and the actual signing key are DIFFERENT " |
| f"addresses:\n" |
| f" signs with: `{signer}` — {signer_desc}\n" |
| f" SOLANA_WALLET: `{wallet_env}` — {env_desc}\n" |
| f"Fees are paid by the SIGNER. Whichever of these your wallet " |
| f"app shows as funded is the one that matters" |
| ) |
| if signer_lamports is not None and signer_lamports < 3_000_000 \ |
| and env_lamports is not None and env_lamports >= 3_000_000: |
| |
| |
| problems.append( |
| message + " — and right now the funded one is NOT the " |
| "signer, which is why nothing can trade. Either set " |
| "SOLANA_PRIVATE_KEY to the key for the funded account, or " |
| "move ~0.01 SOL to the signer's address above." |
| ) |
| else: |
| warnings.append( |
| message + ". Set SOLANA_WALLET to the signer's address to " |
| "stop the dashboard and this check disagreeing." |
| ) |
| elif wallet_env and not signer: |
| info.append(f"SOLANA_WALLET: `{wallet_env}` (no signing key derived)") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from modules.why_report import build as _why_build |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _STOP = {"the", "a", "an", "and", "of", "in", "to", "is", "was", |
| "it", "on", "at", "by", "for", "with", "that", "every", |
| "one", "not", "no", "its", "this", "are", "from"} |
|
|
| def _sig(text: str) -> set: |
| import re as _re |
|
|
| return {w for w in _re.findall(r"[a-z0-9.$]+", text.lower()) |
| if w not in _STOP and len(w) > 1} |
|
|
| _seen = [_sig(p) for p in problems] |
| for _f in _why_build(sol).blocking(): |
| _line = f"{_f.headline} — {_f.evidence[:160]}" if _f.evidence else _f.headline |
| _s = _sig(_f.headline) |
| |
| |
| _dupe = any( |
| _s and len(_s & prior) / len(_s) >= 0.66 for prior in _seen |
| ) |
| if not _dupe: |
| problems.append(f"{_line} (/why for the full picture)") |
| _seen.append(_s) |
| except Exception as exc: |
| logger.debug("[CommandHandlers] /why cross-check failed: %s", exc) |
|
|
| lines = ["🩺 doctor — Solana leg\n"] |
| if problems: |
| lines.append(f"⛔ {len(problems)} problem(s):") |
| lines.extend(f"• {p}" for p in problems) |
| else: |
| lines.append("✅ no problems found — configuration can trade.") |
| lines.append( |
| "(a signal still only sends when its fresh re-quote clears the " |
| "margin at send time — a quiet stretch means thin edges, not a fault)" |
| ) |
| if warnings: |
| lines.append("") |
| lines.append("⚠️ worth knowing:") |
| lines.extend(f"• {w}" for w in warnings) |
| if info: |
| lines.append("") |
| lines.append("ℹ️ state:") |
| lines.extend(f"• {i}" for i in info) |
| await self._tg.send_message("\n".join(lines), chat_id=chat_id, parse_mode=None) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_netdiag(self, chat_id: int) -> None: |
| if diagnose_network is None: |
| await self._tg.send_message( |
| "⚠️ /netdiag unavailable — modules/netdiag.py is not deployed " |
| "in this environment.", |
| chat_id=chat_id, parse_mode=None, |
| ) |
| return |
|
|
| await self._safe_send( |
| "🔍 Running network diagnostic (IPv4 + IPv6, up to ~50s worst case)…", |
| chat_id, |
| ) |
| self._spawn(self._run_netdiag(chat_id)) |
|
|
| async def _run_netdiag(self, chat_id: int) -> None: |
| try: |
| results = await diagnose_network() |
| except Exception as exc: |
| logger.error("[CommandHandlers] /netdiag failed: %s", exc, exc_info=True) |
| await self._safe_send(f"⚠️ Diagnostic failed: {str(exc)[:300]}", chat_id) |
| return |
|
|
| if not results: |
| await self._safe_send("Diagnostic returned no results.", chat_id) |
| return |
|
|
| lines = ["🌐 *Network Diagnostic*\n"] |
| for r in results: |
| ipv4 = r.get("ipv4") |
| ipv6 = r.get("ipv6") |
| ipv4_ok = bool(ipv4 and ipv4[0]) |
| ipv6_ok = bool(ipv6 and ipv6[0]) |
|
|
| if ipv4_ok and ipv6_ok: |
| verdict = "✅ both OK" |
| elif ipv4_ok and not ipv6_ok and ipv6: |
| verdict = "⚠️ IPv4 OK / IPv6 broken" |
| elif ipv6_ok and not ipv4_ok and ipv4: |
| verdict = "⚠️ IPv6 OK / IPv4 broken" |
| elif ipv4 or ipv6: |
| verdict = "❌ both failed" |
| else: |
| verdict = "— no records" |
|
|
| lines.append(f"`{r.get('host', '?')}`\n {verdict}") |
|
|
| await self._tg.send_message("\n".join(lines), chat_id=chat_id, parse_mode="Markdown") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| async def _cmd_journal(self, chat_id: int) -> None: |
| """v1.35 — plain-language read of the trade journal. Reading and |
| aggregating a CSV is blocking file I/O, so it runs in a thread |
| rather than stalling the command loop once the file is large.""" |
| import asyncio |
| from modules.journal_report import render_journal_report |
| try: |
| report = await asyncio.to_thread(render_journal_report) |
| except Exception as exc: |
| logger.error("[CommandHandlers] /journal failed: %s", exc, exc_info=True) |
| await self._safe_send(f"⚠️ Journal read failed: {str(exc)[:300]}", chat_id) |
| return |
| await self._tg.send_message(report, chat_id=chat_id, parse_mode="Markdown") |
|
|
| async def _cmd_speedtest(self, chat_id: int) -> None: |
| await self._safe_send("⚡ Running speedtest (Solana RPC/WS + Jupiter)…", chat_id) |
| self._spawn(self._run_speedtest(chat_id)) |
|
|
| async def _run_speedtest(self, chat_id: int) -> None: |
| from modules import dashboard_panels as panels |
| try: |
| result = await panels.render_speedtest(self) |
| except Exception as exc: |
| logger.error("[CommandHandlers] /speedtest failed: %s", exc, exc_info=True) |
| await self._safe_send(f"⚠️ Speedtest failed: {str(exc)[:300]}", chat_id) |
| return |
| await self._tg.send_message(result, chat_id=chat_id, parse_mode="Markdown") |
|
|
| |
|
|
| async def _cmd_ai(self, chat_id: int, args: list[str]) -> None: |
| question = " ".join(args).strip() |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if not question: |
| from modules.ai_model import render_model |
|
|
| try: |
| screen = render_model() |
| except Exception as exc: |
| logger.warning("[AI] learned screen failed: %s", str(exc)[:200]) |
| screen = ("🧠 could not build the learned screen " |
| f"({str(exc)[:120]}). Ask a question with " |
| "`/ai <question>`.") |
| if self._qwen is None or not getattr(self._qwen, "enabled", False): |
| screen += ("\n\n_Free-form questions need `QWEN_API_KEY`, " |
| "which is not set — the numbers above do not._") |
| await self._safe_send(screen, chat_id) |
| return |
| if self._qwen is None or not getattr(self._qwen, "enabled", False): |
| await self._safe_send( |
| "🤖 AI is not configured — set QWEN_API_KEY in the Space " |
| "secrets and restart.\n\nSend `/ai` with no question for the " |
| "learned model, which needs no key.", chat_id, |
| ) |
| return |
| last = _recent_results[-1] if _recent_results else {} |
| try: |
| pair_count = len(getattr(self._scanner, "_pairs", []) or []) |
| except Exception: |
| pair_count = 0 |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| loan_amount = float(getattr(self._scanner, "loan_amount", 0) or 0) |
| loan_fee_pct = float(getattr(self._scanner, "loan_fee_pct", 0) or 0) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| solana_ctx = "" |
| if self._solana is not None: |
| try: |
| sol = self._solana |
| sol_lines = [ |
| f"Solana leg: floor ${float(sol.min_profit):.2f}/trade; " |
| f"scan probe size {float(sol.probe_base_units):,.0f} base units; " |
| f"REAL sends are capped to a small fraction of probe size " |
| f"(a signal at probe size can die at real size — " |
| f"'real size too thin'); " |
| f"flash fee {float(sol.flash_fee_bps):.1f} bps" |
| ] |
| opps = sol.last_opportunities or [] |
| if opps: |
| best = max(opps, key=lambda o: o.net_profit_usd) |
| sol_lines.append("; best route last scan: " + best.near_miss_line(float(sol.min_profit))) |
| solana_ctx = "".join(sol_lines) + "; " |
| except Exception: |
| solana_ctx = "" |
| context = ( |
| f"pairs watched: {pair_count}; ARB loan size per trade: ${loan_amount:,.2f}; " |
| f"ARB flash-loan fee: {loan_fee_pct:.3f}% (this deployment's REAL " |
| f"configured rate — do not substitute a generic/typical industry " |
| f"figure; these two numbers are ARBITRUM-ONLY — never reuse them " |
| f"for Solana claims); " |
| + solana_ctx + |
| f"last ARB scan: {last.get('signal', '?')} " |
| f"{last.get('base', '?')} {last.get('buyOn', '?')}→{last.get('sellOn', '?')} " |
| f"net ${float(last.get('netAfterFee', 0) or 0):.2f} " |
| f"(floor ${float(last.get('minProfit', 0) or 0):.2f}); " |
| f"ghost={self._ghost_mode} mint={self._mint_mode} dry_run={self._dry_run}" |
| ) |
| answer = await self._qwen.ask(question, context=context) |
| if answer: |
| await self._safe_send("🤖 " + answer, chat_id) |
| return |
| reason = getattr(self._qwen, "last_error", None) |
| await self._safe_send( |
| "🤖 The AI didn't answer" |
| + (f" ({reason})" if reason else "") |
| + " — try again in a minute.", |
| chat_id, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| _AI_IDEA_MAX_ATTEMPTS = 3 |
|
|
| async def _cmd_ai_idea(self, chat_id: int) -> None: |
| if self._qwen is None or not getattr(self._qwen, "enabled", False): |
| await self._safe_send( |
| "🤖 AI is not configured — set QWEN_API_KEY in the Space " |
| "secrets and restart.", chat_id, |
| ) |
| return |
| await self._safe_send("🤖 Asking the AI for one concrete pair idea…", chat_id) |
| try: |
| watching = [ |
| f"{p['base']}/{p['stable']}" |
| for p in (getattr(self._scanner, "_pairs", []) or []) |
| ] |
| except Exception: |
| watching = [] |
| idea: dict | None = None |
| verify: dict | None = None |
| reject_reasons: list[str] = [] |
| for _attempt in range(self._AI_IDEA_MAX_ATTEMPTS): |
| |
| |
| |
| |
| |
| |
| candidate = await self._qwen.propose_pair(chain="ARB", already_watching=watching) |
| if not candidate: |
| reject_reasons.append( |
| getattr(self._qwen, "last_error", None) |
| or "model returned no usable suggestion" |
| ) |
| continue |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| v = await self._scanner.verify_erc20_token( |
| str(candidate.get("chain", "ARB")), candidate["address"], |
| ) |
| except Exception as exc: |
| logger.warning("[CommandHandlers] verify_erc20_token failed: %s", exc) |
| v = None |
| if v is not None and not v.get("exists", False): |
| reject_reasons.append( |
| f"{candidate.get('base', '?')} at " |
| f"{candidate['address'][:10]}… — {v.get('reason', 'not a real contract')}" |
| ) |
| continue |
| idea, verify = candidate, v |
| break |
| if not idea: |
| detail = reject_reasons[-1] if reject_reasons else "no reason given" |
| await self._safe_send( |
| f"🤖 No usable suggestion after {self._AI_IDEA_MAX_ATTEMPTS} " |
| f"tries ({detail}). Try /ai_idea again.", |
| chat_id, |
| ) |
| return |
| reason = str(idea.pop("reason", "") or "") |
| |
| verify_note = "" |
| if verify and verify.get("exists"): |
| if verify.get("decimals") is not None: |
| idea["base_decimals"] = int(verify["decimals"]) |
| onchain_symbol = str(verify.get("symbol") or "").strip().upper() |
| verify_note = "\n✅ Verified on-chain: contract exists" |
| if onchain_symbol: |
| verify_note += f", symbol={onchain_symbol}" |
| if onchain_symbol != str(idea.get("base", "")).upper(): |
| verify_note += ( |
| f"\n⚠️ Mismatch: the AI called this '{idea.get('base')}' " |
| f"but the contract's own symbol() says '{onchain_symbol}' — " |
| "double-check this is really the token you think it is." |
| ) |
| verify_note += "." |
| else: |
| verify_note = ( |
| "\n⚠️ Could not verify on-chain right now (RPC unavailable) — " |
| "check the explorer yourself before approving." |
| ) |
| self._pending_ai_pair = idea |
| explorer = {"ARB": "arbiscan.io", "BSC": "bscscan.com"}.get( |
| str(idea.get("chain", "")).upper(), "the block explorer" |
| ) |
| await self._safe_send( |
| "🤖 AI pair idea — awaiting YOUR approval\n\n" |
| f"Pair: {idea['base']}/{idea['stable']} on {idea['chain']}\n" |
| f"DEXs: {idea['dex_a']} ↔ {idea['dex_b']}\n" |
| f"Token: {idea['address']}\n" |
| + (f"Why: {reason}\n" if reason else "") |
| + verify_note |
| + f"\n\n⚠️ Verify the contract on {explorer} before approving. " |
| "Even after approval, the scanner's live quote-verification " |
| "still gates every trade.\n\n" |
| "Reply /approve to add it to the LIVE scan rotation, or /reject.", |
| chat_id, |
| ) |
|
|
| |
| |
| |
| |
| |
| async def _cmd_ai_idea_sol(self, chat_id: int) -> None: |
| if self._qwen is None or not getattr(self._qwen, "enabled", False): |
| await self._safe_send( |
| "🤖 AI is not configured — set QWEN_API_KEY in the Space " |
| "secrets and restart.", chat_id, |
| ) |
| return |
| if self._solana is None: |
| await self._safe_send( |
| "🤖 The Solana leg isn't running (SOLANA_SCAN_ENABLED is off " |
| "or it hasn't started yet) — nothing to add a route to.", |
| chat_id, |
| ) |
| return |
| await self._safe_send("🤖 Asking the AI for one concrete Solana route idea…", chat_id) |
| watching = [f"{r['base']}->{r['mid']}" for r in (self._solana.routes or [])] |
| idea: dict | None = None |
| reject_reasons: list[str] = [] |
| for _attempt in range(self._AI_IDEA_MAX_ATTEMPTS): |
| candidate = await self._qwen.propose_solana_route(already_watching=watching) |
| if not candidate: |
| reject_reasons.append( |
| getattr(self._qwen, "last_error", None) |
| or "model returned no usable suggestion" |
| ) |
| continue |
| base_v = await self._solana.verify_solana_mint(candidate["base"]) |
| mid_v = await self._solana.verify_solana_mint(candidate["mid"]) |
| if not base_v.get("exists") or not mid_v.get("exists"): |
| bad = base_v if not base_v.get("exists") else mid_v |
| reject_reasons.append( |
| f"{candidate['base']}->{candidate['mid']} — {bad.get('reason', 'not verified')}" |
| ) |
| continue |
| idea = candidate |
| break |
| if not idea: |
| detail = reject_reasons[-1] if reject_reasons else "no reason given" |
| await self._safe_send( |
| f"🤖 No usable suggestion after {self._AI_IDEA_MAX_ATTEMPTS} " |
| f"tries ({detail}). Try /ai_idea_sol again.", |
| chat_id, |
| ) |
| return |
| reason = str(idea.pop("reason", "") or "") |
| idea["chain"] = "SOL" |
| self._pending_ai_pair = idea |
| await self._safe_send( |
| "🤖 AI Solana route idea — awaiting YOUR approval\n\n" |
| f"Route: {idea['base']}->{idea['mid']} (via Jupiter)\n" |
| + (f"Why: {reason}\n" if reason else "") |
| + "✅ Both mints verified on-chain.\n\n" |
| "⚠️ Even after approval, the same live re-quote/margin/" |
| "simulation gates decide whether it ever trades.\n\n" |
| "Reply /approve to add it to the LIVE scan rotation, or /reject.", |
| chat_id, |
| ) |
|
|
| async def _cmd_ai_do(self, chat_id: int, args: list[str]) -> None: |
| if self._qwen is None or not getattr(self._qwen, "enabled", False): |
| await self._safe_send( |
| "🤖 AI is not configured — set QWEN_API_KEY in the Space " |
| "secrets and restart.", chat_id, |
| ) |
| return |
| |
| |
| |
| |
| |
| target_sol = bool(args) and args[0].strip().lower() == "sol" |
| request = " ".join(args[1:] if target_sol else args).strip() |
| if not request: |
| await self._safe_send( |
| "Usage: /ai_do <what you want her to do>\n" |
| " /ai_do sol <what you want her to do>\n\nExamples:\n" |
| "/ai_do set loan to 20000\n" |
| "/ai_do set min profit to 5\n" |
| "/ai_do pause hunting\n" |
| "/ai_do resume\n" |
| "/ai_do stop watching BNB/USDC\n" |
| "/ai_do sol set min profit to 5\n" |
| "/ai_do sol pause hunting\n" |
| "/ai_do sol stop watching SOL/BONK\n\n" |
| "She proposes ONE action; nothing changes until you /approve.", |
| chat_id, |
| ) |
| return |
| await self._safe_send("🤖 Working out one action…", chat_id) |
| if target_sol: |
| if self._solana is None: |
| await self._safe_send( |
| "🤖 The Solana leg isn't running (SOLANA_SCAN_ENABLED is " |
| "off or it hasn't started yet).", chat_id, |
| ) |
| return |
| sol = self._solana |
| opps = sol.last_opportunities or [] |
| best = max(opps, key=lambda o: o.net_profit_usd) if opps else None |
| context = ( |
| f"routes={len(sol.routes)}; " |
| f"min_profit=${float(sol.min_profit):.2f}; " |
| f"paused={getattr(sol, 'paused', False)}; " |
| f"last={best.near_miss_line(float(sol.min_profit)) if best else 'no data yet'}" |
| ) |
| action = await self._qwen.propose_action(request, context=context, chain="SOL") |
| else: |
| last = _recent_results[-1] if _recent_results else {} |
| try: |
| pair_count = len(getattr(self._scanner, "_pairs", []) or []) |
| except Exception: |
| pair_count = 0 |
| context = ( |
| f"pairs={pair_count}; " |
| f"loan=${getattr(self._scanner, 'loan_amount', 0):,.0f}; " |
| f"min_profit=${getattr(self._scanner, 'min_profit', 0):,.2f}; " |
| f"paused={getattr(self._scanner, 'paused', False)}; " |
| f"last={last.get('signal', '?')} " |
| f"net ${float(last.get('netAfterFee', 0) or 0):.2f}" |
| ) |
| |
| |
| action = await self._qwen.propose_action(request, context=context, chain="ARB") |
| if not action: |
| await self._safe_send( |
| "🤖 Couldn't turn that into a valid action — try rephrasing, " |
| "e.g. '/ai_do set loan to 20000'.", chat_id, |
| ) |
| return |
| if action.get("action") == "none": |
| reason = action.get("reason") or "not a supported control action" |
| await self._safe_send( |
| f"🤖 I can't do that as an action ({reason}).\n" |
| "For questions use /ai; to add a pair use /ai_idea " |
| "(or /ai_idea_sol for Solana).", chat_id, |
| ) |
| return |
| self._pending_ai_action = action |
| await self._safe_send( |
| "🤖 AI action — awaiting YOUR approval\n\n" |
| f"Proposed: {action.get('desc', '(action)')}\n\n" |
| "Reply /approve to apply it, or /reject.\n" |
| "⚠️ Every trade still clears the same live on-chain gates " |
| "regardless of this setting.", |
| chat_id, |
| ) |
|
|
| def _apply_ai_action(self, action: dict) -> tuple[bool, str]: |
| """Execute ONE operator-approved AI action against the live scanner |
| (or, for chain="SOL" actions, the live SolanaArbEngine — v17.27). |
| Never raises. Setting changes are runtime-only (they revert on |
| restart — set the matching env var for a permanent change); added |
| pairs/routes persist via ai_pairs.json/ai_solana_routes.json exactly |
| like /ai_idea → /approve / /ai_idea_sol → /approve.""" |
| try: |
| kind = action.get("action") |
| |
| |
| |
| |
| if kind in ("pause", "resume", "set_min_profit") and str(action.get("chain", "")).upper() == "SOL": |
| if self._solana is None: |
| return False, "the Solana leg isn't running anymore" |
| sol = self._solana |
| if kind == "pause": |
| sol.paused = True |
| return True, ("Solana hunting PAUSED — nothing trades until you " |
| "resume (/ai_do sol resume → /approve). Runtime-only.") |
| if kind == "resume": |
| sol.paused = False |
| return True, "Solana hunting RESUMED." |
| val = float(action["value"]) |
| sol.min_profit = val |
| return True, (f"Solana minimum profit floor set to ${val:,.2f} for " |
| "this run — set SOLANA_MIN_PROFIT_USD to persist.") |
| if kind == "add_route": |
| if self._solana is None: |
| return False, "the Solana leg isn't running anymore" |
| ok, msg = self._solana.add_route(action["base"], action["mid"]) |
| if ok: |
| self._persist_ai_solana_route(action) |
| return ok, msg |
| if kind == "remove_route": |
| if self._solana is None: |
| return False, "the Solana leg isn't running anymore" |
| return self._solana.remove_route(action["base"], action["mid"]) |
|
|
| sc = self._scanner |
| if kind == "pause": |
| sc.paused = True |
| return True, ("hunting PAUSED — nothing trades until you resume " |
| "(/ai_do resume → /approve). Runtime-only.") |
| if kind == "resume": |
| sc.paused = False |
| return True, "hunting RESUMED." |
| if kind == "set_loan": |
| val = float(action["value"]) |
| sc.loan_amount = val |
| return True, (f"flash-loan size set to ${val:,.0f} for this run — " |
| "set FLASH_LOAN_USD in Space Variables to persist.") |
| if kind == "set_min_profit": |
| val = float(action["value"]) |
| sc.min_profit = val |
| return True, (f"minimum profit floor set to ${val:,.2f} for this run — " |
| "set MIN_PROFIT_USD to persist.") |
| if kind == "remove_pair": |
| return sc.remove_pair( |
| action["base"], action["stable"], action.get("chain", "BSC"), |
| ) |
| if kind == "add_pair": |
| pair = dict(action["pair"]) |
| ok, msg = sc.add_pair(pair) |
| if ok: |
| self._persist_ai_pair(pair) |
| return ok, msg |
| return False, f"unknown action {kind!r}" |
| except Exception as exc: |
| return False, f"action failed: {exc}" |
|
|
| async def _cmd_approve(self, chat_id: int) -> None: |
| |
| if self._pending_ai_action is not None: |
| action = self._pending_ai_action |
| self._pending_ai_action = None |
| ok, msg = self._apply_ai_action(action) |
| await self._safe_send( |
| (f"✅ Approved — {msg}" if ok else f"❌ Not applied: {msg}"), |
| chat_id, |
| ) |
| return |
| idea = self._pending_ai_pair |
| if not idea: |
| await self._safe_send( |
| "Nothing pending — get a suggestion with /ai_idea, /ai_idea_sol, " |
| "or /ai_do first.", |
| chat_id, |
| ) |
| return |
| self._pending_ai_pair = None |
| |
| |
| |
| |
| if str(idea.get("chain", "")).upper() == "SOL": |
| if self._solana is None: |
| await self._safe_send( |
| "❌ Not added: the Solana leg isn't running anymore.", chat_id, |
| ) |
| return |
| ok, msg = self._solana.add_route(idea["base"], idea["mid"]) |
| if not ok: |
| await self._safe_send(f"❌ Not added: {msg}", chat_id) |
| return |
| self._persist_ai_solana_route(idea) |
| await self._safe_send( |
| f"✅ Approved — {msg}.\n" |
| "It joins the Solana scan rotation next cycle and persists " |
| "across restarts (ai_solana_routes.json). The same live " |
| "re-quote/margin/simulation gates still decide whether it " |
| "ever trades.", |
| chat_id, |
| ) |
| return |
| ok, msg = self._scanner.add_pair(dict(idea)) |
| if not ok: |
| await self._safe_send(f"❌ Not added: {msg}", chat_id) |
| return |
| self._persist_ai_pair(idea) |
| await self._safe_send( |
| f"✅ Approved — {msg}.\n" |
| "It joins the scan rotation next cycle and persists across " |
| "restarts (ai_pairs.json). The same live quote-verification " |
| "gates still decide whether it ever trades.", |
| chat_id, |
| ) |
|
|
| async def _cmd_reject(self, chat_id: int) -> None: |
| |
| if self._pending_ai_action is not None: |
| act = self._pending_ai_action |
| self._pending_ai_action = None |
| await self._safe_send( |
| f"🗑️ Rejected action — {act.get('desc', '(action)')}. Nothing changed.", |
| chat_id, |
| ) |
| return |
| if self._pending_ai_pair is None: |
| await self._safe_send("Nothing pending to reject.", chat_id) |
| return |
| rejected = self._pending_ai_pair |
| self._pending_ai_pair = None |
| |
| |
| if str(rejected.get("chain", "")).upper() == "SOL": |
| await self._safe_send( |
| f"🗑️ Rejected {rejected.get('base', '?')}->" |
| f"{rejected.get('mid', '?')} — discarded.", |
| chat_id, |
| ) |
| return |
| await self._safe_send( |
| f"🗑️ Rejected {rejected.get('base', '?')}/" |
| f"{rejected.get('stable', '?')} — discarded.", |
| chat_id, |
| ) |
|
|
| def _persist_ai_pair(self, pair: dict) -> None: |
| """Append an approved pair to ai_pairs.json — best-effort: a failed |
| write only costs restart-persistence, never the running scan.""" |
| if not self._ai_pairs_path: |
| return |
| try: |
| from pathlib import Path as _Path |
| path = _Path(self._ai_pairs_path) |
| existing: list = [] |
| if path.exists(): |
| loaded = json.loads(path.read_text() or "[]") |
| if isinstance(loaded, list): |
| existing = loaded |
| existing.append(pair) |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text(json.dumps(existing, indent=2)) |
| logger.info("[CommandHandlers] AI pair persisted to %s", path) |
| except Exception as exc: |
| logger.warning( |
| "[CommandHandlers] could not persist AI pair (%s) — it stays " |
| "active until restart.", exc, |
| ) |
|
|
| def _persist_ai_solana_route(self, route: dict) -> None: |
| """v17.27 — Solana counterpart to _persist_ai_pair(), same best-effort |
| contract (a failed write only costs restart-persistence, never the |
| running scan). Separate file (ai_solana_routes.json) because the |
| schema is different (base/mid, no stable/address/dex_a/dex_b) and |
| bot.py loads/merges the two independently at startup.""" |
| if not self._ai_solana_routes_path: |
| return |
| try: |
| from pathlib import Path as _Path |
| path = _Path(self._ai_solana_routes_path) |
| existing: list = [] |
| if path.exists(): |
| loaded = json.loads(path.read_text() or "[]") |
| if isinstance(loaded, list): |
| existing = loaded |
| existing.append({"base": route["base"], "mid": route["mid"]}) |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text(json.dumps(existing, indent=2)) |
| logger.info("[CommandHandlers] AI Solana route persisted to %s", path) |
| except Exception as exc: |
| logger.warning( |
| "[CommandHandlers] could not persist AI Solana route (%s) — " |
| "it stays active until restart.", exc, |
| ) |
|
|
| |
|
|
| async def _safe_send(self, text: str, chat_id: int) -> None: |
| try: |
| await self._tg.send_message(text, chat_id=chat_id, parse_mode=None) |
| except Exception as exc: |
| logger.error("_safe_send failed for chat %s: %s", chat_id, exc) |
|
|